Compare commits

..

20 Commits

Author SHA1 Message Date
136a17c0bf feat: 任务列表右键菜单回调 + 主进程下载 + sharp 缩略图 + IPC 精简重构
- 任务右键菜单:实现打开输出目录(inputs/outputs/.cache 结构)、
    复制资源链接、重新编辑、再次生成、拉取结果、重新下载、收藏、删除记录
  - 主进程下载:通过 Electron net 模块绕过 CORS,支持 CDN 媒体文件下载
  - sharp 缩略图:替换 nativeImage,支持 4K/8K+ 大尺寸素材的流式处理
  - IPC 精简:40+ 冗余通道 → 15 个分组通道,移除未使用的 preload 桥接方法
  - 任务存储:新增收藏字段、schema 迁移、task-store 持久化
  - 清理废弃模块:canvas-ipc / updater / request / pricing

refactor: IPC/Preload/Services 模块化拆分 — 单一职责 + 向后兼容

  主要变更:
  - shared/constants/ipc-channels.ts → ipc/ 模块 (base/storage/canvas/updater)
  - electron/preload.ts → preload/ 模块 (base/safe-storage/file-storage/canvas)
  - electron/main/ipc/canvas-ipc.ts → canvas/ 模块 (types/walk/sidecar/mime/thumbnail)
  - electron/main/ipc/storage-ipc.ts → 提取 utils/ (path/download/thumbnail)
  - electron/main/updater.ts → updater/ 模块 (provider/platform-updaters)
  - src/services/request.ts → request/ 模块 (token/interceptors/methods)
  - src/shared/utils/pricing.ts → pricing/ 模块 (types/helpers/format/calculate/fields)
  - src/modules/home/center/views/ModelInputForm.tsx → 提取 utils + DependentFormItem

  架构优化:
  - 所有拆分均保持向后兼容(旧导入路径仍可用)
  - TypeScript 编译通过 ✓
  - 每个模块单一职责,平均 ~130 行
2026-07-03 11:13:36 +08:00
904edecd90 refactor: Home 模块 VCHSM 三栏重构 + Services 迁回 + 右键菜单体系 + 公告修复
## Home 模块三栏 VCHSM 重组
- 按渲染归属重新分配文件:ModelSelector/TaskHistory → left/,ModelInputForm/widgets → center/,OutputPreview → right/
- BannerCarousel 移至 shared/views/(全局组件)
- controls/ → widgets/ 重命名,消除与 controllers/ 的命名歧义
- 新增 barrel 文件(left/center/right/shared 各级 index.ts)

## Services 层迁回 src/services/modules/
- 全部 API 类型定义与请求函数从 modules/*/controllers/ 迁回 services/modules/{domain}/
- 恢复原始文件名(model-api.ts → models.ts,task-api.ts → task.ts 等)
- 新增 domain 子文件夹(home/auth/announcement/account)
- 更新全局 ~70 处导入路径

## TaskHistory 提取 Controller + Hook 层
- 新建 left/controllers/task-table-config.ts(常量 + 纯工具函数,零 React 依赖)
- 新建 left/hooks/useTaskTable.tsx(状态管理 + 数据获取 + 列定义 + 滚动同步)
- TaskHistory.tsx 从 915 行精简为 265 行纯视图

## 右键菜单体系完善
- 共享 <ContextMenu> 组件(antd Dropdown 封装,防 Select 冲突)
- 四组菜单定义:任务列表 / 文本输入 / 媒体参考 / 预览区域
- 统一的 ContextMenuItems → AntD MenuProps 转换

## 公告功能修复
- useAnnouncements 增加 isLoggedIn 登录门控,避免未登录时 401 双重请求
- 抽屉打开时触发 refetch(),确保拿到最新数据

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-30 13:39:38 +08:00
337d6c1205 feat: 构建系统重构 + VCHSM 架构迁移 + 右键菜单修复 + 尺寸参数统一
## 构建、打包与分发系统重构 (build/)

- 构建系统收敛至 build/ 目录:build.mjs + electron-builder.js + 6 个脚本

- electron-builder 配置从 package.json 提取为 build/electron-builder.js

- 新增 --edition (personal/enterprise) 和 --channel (stable/beta/alpha) 参数

- OSS 路径按渠道隔离

- 新增 pre-build-check.mjs:Git/CHANGELOG/版本/环境变量校验

- 新增 bump-version.mjs:版本号管理 + CHANGELOG 自动生成

- updater.ts 发送 channel/edition/deviceFingerprint

- 新增 src/shared/utils/rollout.ts 灰度测试工具

- 新增 10 条 NPM 脚本

## VCHSM 五层架构迁移

- 7 个业务模块 + ~500 处导入路径同步

## 修复

- MediaCardContextMenu 改用共享 ContextMenu 组件

- ComboBox 尺寸参数显示统一 (size-format.ts)

## 文档

- UpdateA.md / build/README.md / README.md / .env.example 更新

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-29 18:41:07 +08:00
e0d91335c7 chore: 补充遗漏提交 — 日志分类/导航配置/依赖声明/路径别名
- shared/types/logging.ts: 新增 canvas + canvas-window 日志分类
- nav-config.ts: 移除合规素材库导航项 (合规素材库页面待后续实现)
- package.json + lock: 补充 zustand 依赖声明
- tsconfig.node.json: 补充 @shared/* 路径别名 (主进程导入)
- .gitignore: 排除 infinite_canvas_140 目录

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 20:08:28 +08:00
b04b5a0390 feat: 无限画布 UI 重构 + 预览键盘控制 + 拖拽体验优化 (0.0.27)
- CanvasPage: AntD Layout 双栏布局 (Sider+Content),预览状态提升解耦 DOM
- CanvasSidebar (新): 项目/集数选择器 + 镜头搜索 + 列表 + 固定刷新按钮
- CategoryTabs (新): 双模式分类标签 (Assets/审核) + 数量徽标 + 媒体细分
- CanvasViewport: Ctrl/Cmd 拖拽平移 (pointerEvents:none 防误抓缩略图)
- CanvasViewport: previewActiveRef 双守卫阻止预览穿透缩放/拖拽
- MediaPreview: 键盘控制 Space 暂停/播放、<- -> 快退快进 ±0.5s
- electron/main.ts: local-media:// 协议 handler 转发 Range 头修复 seek
- StageTabs: 无限画布入口按钮 + 全局字号上调

Range seek 调试: 之前 net.fetch 未转发 Range 头 → 浏览器 seek 时
返回 200 全文件而非 206 Partial Content → currentTime 赋值无法定位
→ 播放位置回退到 0。修复: 提取 Range 头单向转发到 file:// handler。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 19:26:08 +08:00
48144ed784 docs: 更新 CHANGELOG 0.0.26 + TODO 标记无限画布完成 2026-06-23 20:15:04 +08:00
4ea414c9c7 feat: 无限画布滚轮缩放 + 项目管理入口 (0.0.26)
画布交互:
- 滚轮缩放视口(2%~1600%),纯滚轮 10%/tick,Ctrl/Cmd+滚轮 3%/tick 精细控制
- 鼠标拖拽平移画布,Ctrl/Cmd+0 重置缩放
- 预览打开时锁定底层视口(previewActiveRef 事件穿透防护)
- 预览内图片独立滚轮缩放 + 拖拽平移 + 缩放指示器

文件拖拽:
- 画布缩略图/预览支持原生文件拖拽到外部应用(startDrag IPC)

Bug 修复:
- local-media:// 协议支持 UNC 网络路径
- passive event listener 报错:window 级原生 wheel listener 替代 React onWheel
- Zustand v5 getState() 批处理旧值:改用 ref(currentZoomRef + setZoomRef)读写

主进程(electron/main.ts):
- 画布窗口 before-input-event 拦截 Chromium 页面缩放
- 新增 canvas-ipc.ts(startDrag、文件扫描等 IPC handler)
2026-06-23 20:11:50 +08:00
dd8f84ae27 feat: 业务错误Modal引导体系 + 左侧栏UI增强 (0.0.25)
业务错误 Modal 引导:
- HTTP 402+code 1001 → 余额不足Modal → 引导去充值
- HTTP 403+code 4040 → 软件到期Modal → 引导购买VIP
- BusinessErrorEvent 事件总线类型,双条件精确匹配避免误拦截
- RequestError.displayed 标记抑制重复 toast

左侧栏 UI:
- 模型列表 emoji 渲染 (ui_config.badge / meta_config.icon)
- 标题栏色条加深 (borderLeft + colorFillSecondary + 图标染色)
- 任务列表分页器固定底部 + 水平滚动条固定
- 统一 ::-webkit-scrollbar 样式 (暗/亮双套)
- 表头取消加粗 + 模型/任务列表间距
2026-06-22 17:01:09 +08:00
8b697e775d feat: 去除构建时版控变量 + 窗口标题动态版控 + 退出登录保留缓存 (0.0.24)
版控体系重构(13 文件):
- 移除 build.mjs --personal/--enterprise 参数和 EDITION 环境变量
- 统一安装包文件名:船长-HeiXiu-{os}-{版本号}-Setup.{ext}
- 简化 package.json 构建脚本,移除 :enterprise 变体
- scripts/*.mjs 不再接受 edition 参数
- 移除 shared/constants/app.ts 的 parseEdition()

窗口标题动态版控:
- 新格式:船长-HeiXiu-{os}-{版本号}(登录前)/ ·{版控} 后缀(登录后)
- 链路:AppProvider(account_type) → useEffect → appRuntime IPC → main process
- 移除 updater.ts 更新检查 API 的 edition 查询参数

本地存储生命周期修正:
- 退出登录不再自动清空缓存(user_id 隔离,重新登录命中缓存)
- clearUserStorage 添加 isOpen() 竞态守卫
- 移除 closeDatabase() 避免退出→重新登录后数据库不可用
- 手动「清理缓存」按钮保留

文档:
- CHANGELOG.md 新增 0.0.24 条目
- TODO.md 新增已完成项
2026-06-22 13:36:42 +08:00
e4d66a2ac0 docs: 更新文档,实现每个文件夹的REDME.md 2026-06-22 09:42:29 +08:00
e4c9ae8fc6 feat: 三栏比例缩放 + SeedanceContent视频上传 + 本地存储基建 (0.0.22)
三栏布局
- ResizeObserver 等比例分配替代固定默认值恢复
- 修复拖拽+窗口调整竞态卡死(needsRecalcRef 延迟补算)
- containerRef.current 动态读取 DOM 替代闭包 el

Seedance2Content
- 从占位文本重构为内容编排控件(文本+图片/视频/音频上传)
- 根据 spec.image_files/video_files/audio_files 动态渲染上传区域
- 值 JSON 序列化 → 提交时转 ContentItem[](text/image_url/video_url/audio_url)

图片上传兼容视频文件
- buildAccept 根据 file_filter 区分 image/video 前缀
- createMediaBeforeUpload 动态放行 video/* MIME
- 公共模块 media-upload-utils.ts

本地存储基建
- sql.js (WASM SQLite) + safeStorage 加密持久化
- 8个IPC文件操作通道 + resolveSafe 路径穿越防护
- <userData>/heixiu-data/ 沙箱 + JWT用户隔离
- 7表DDL + 游标分页 + LRU媒体淘汰

其他
- 修复 errorMessageRef 渲染期写入警告
- 媒体预览回退直接URL(<img>无CORS限制)+ output_type类型补充
- CSP wasm-unsafe-eval + https: CDN媒体源
2026-06-18 19:22:28 +08:00
e1c8eff946 feat: VIP会员开通 + 订单分类 + EnumResolver统一枚举
VIP 会员开通页面(VipPage)
  - 四部分布局:激活码 / 套餐选择(双列:Radio + 权益图库) / 协议确认 / 支付跟踪
  - 支付跟踪:仿照 RechargePage 模式,创建订单后进入待支付阶段,轮询 VipAPI.getVipOrder
  - 页面锁定:待支付中禁止关闭容器(PAGE_LOCK),仅可通过 "X" 关闭
  - 权益图库:双列布局右列展示,antd Image 组件支持点击放大预览
  - Bug 修复:取消错误判断 name==='AbortError' → instanceof RequestError && type===CANCELLED

  订单明细分类(OrdersPage)
  - 新增订单类型切换(充值订单 / VIP订单),各自独立数据源与列定义
  - startVipPolling:VIP 订单轮询(VipAPI.getVipOrder),与充值轮询对称
  - handleContinuePay 统一:根据 orderType 分发到对应轮询函数
  - Bug 修复:同 VipPage 的取消错误判断

  EnumResolver 统一枚举(enum-resolver.ts)
  - 新增 payments 模块:status.label/color + method.label
  - 扩展 vip.orderStatus.color
  - VipPage / OrdersPage 移除所有直接 Map 索引和本地映射函数,统一走 EnumResolver

  VipAPI 扩展(vip-api.ts)
  - 新增 getVipOrder(orderId):单订单查询端点 /api/v1/vip/orders/{order_id}

  PageDispatcher
  - /vip 路由注册 VipPage,label="VIP 会员开通",width=560
2026-06-16 18:29:59 +08:00
72a0290693 feat: 订单轮询 + 竞态防护 + 性能优化 (OrdersPage 完善)
- startPolling: 基于 Map<orderId, timer> 的多订单并发轮询
    幂等启动(重复点击防重)+ 局部 state 更新 + 终态全量刷新
  - fetchOrders: AbortController 防竞态,切换筛选/分页时取消旧请求
    AbortError 静默吞掉,不污染 error state
  - columns: useMemo 稳定引用,依赖 [token.colorPrimary, handleContinuePay]
  - 卸载清理: abort + clearTimeout 双重防护
  - 文档同步: CHANGELOG / TODO 更新至 0.0.21
2026-06-15 19:58:25 +08:00
06e6486aa3 feat: 充值页面 + 订单明细页面 + 页面调度器增强 (0.0.20)
新增页面:
  - RechargePage — 积分充值表单(预设金额/自定义金额/支付宝/协议勾选)
    + 支付状态跟踪(form/pending/paid/expired/failed 五阶段)
    + 订单状态轮询(3s 间隔,终态自动停止)
    + PAGE_LOCK 事件(待支付时禁止关闭 Modal)
  - OrdersPage — 订单明细列表(状态筛选 + 分页 + 继续支付)
    + Segmented 状态 Tab(全部/待支付/已支付/已过期/失败)
    + 待支付订单"继续支付"按钮

  事件总线增强:
  - 新增 EVENTS.CLOSE_PAGE — 页面内部关闭容器
  - 新增 EVENTS.PAGE_LOCK — 锁定/解锁容器关闭(防止操作中断)

  PageDispatcher 增强:
  - 注册 RechargePage(/recharge)和 OrdersPage(/orders)
  - 响应 CLOSE_PAGE / PAGE_LOCK 事件
  - Modal/Drawer 锁定态下禁用遮罩关闭

  支付 API 模块:
  - PaymentAPI.createOrder / getOrdersListInfo / getOrderInfo
  - 完整类型定义(请求/响应体、状态常量、中文映射)

  杂项:
  - 导航栏"充值积分"→"充值"(精简)
2026-06-15 15:08:05 +08:00
4472d406d9 docs: 同步文件结构重组至文档(CHANGELOG/PROJECT/TODO)
- CHANGELOG: 新增 0.0.21 条目
- PROJECT: 更新目录结构、状态管理架构描述
- TODO: 移除已修复的 ESLint react-refresh 警告(HomeContext 拆分后自动消除)
2026-06-15 11:22:59 +08:00
0e6d996718 refactor: 重组 components/ 和 pages/ 文件结构,按职责分层
components/ 拆为三层:
  - providers/   — 状态管理(App/Theme/SettingsProvider)
  - shell/       — 应用外壳(Layout/NavBar/PageDispatcher)
  - ui/          — 通用 UI 原子(FloatingPanel/LegalTextModal)

pages/ 重命名 + 重组:
  - headers/ → panels/        NavBar 触发的面板页
  - login/   → auth/          认证模块(含注册/忘记密码)
  - settings/blocks/ → sections/
  - home/components/ → panels/ features/ controls/ hooks/
    按职责分:panels(布局壳) + features(功能面板) + controls(表单控件) + hooks

其他清理:
  - 删除空占位 Test3.tsx
  - HomeContext 从 HomeContent 拆出独立文件
  - uploadValidation 内联到 ImageInput
  - 修复过时注释(useUI → controls)
2026-06-15 11:19:06 +08:00
bc33b81121 docs : 更新文档CLAUDE.md 2026-06-12 19:18:55 +08:00
54d12aaf26 docs: 更新架构文档、版本日志、TODO清单 (0.0.20)
- ARCHITECTURE.md: 新增任务轮询/文件上传/提交防抖/主动刷新定时器/失败分级五节,更新 hooks 目录结构,日期→06-12
- CHANGELOG.md: 新增 0.0.20 版本条目
- TODO.md: 标记公告接入/任务轮询为已完成,新增任务CSV导出/上传端点确认待办
- CLAUDE.md 保持不动(按规则不修改)
2026-06-12 19:14:39 +08:00
9e8c5b7109 feat: 任务轮询 + Token刷新容错 + 提交防抖 + 文件上传Hook (0.0.20)
### 任务状态轮询
- 新增 useTaskPolling hook:批量轮询非终态任务,递归 setTimeout + next_poll_ms 自适应间隔
- TaskHistory 集成批量轮询,OutputPreview 移除独立轮询改为状态转换监听
- batchTaskStatus 修复数组参数序列化(GET + 逗号拼接,避免 bracket 格式)
- 统一 POLLING_STATUSES 常量管理非终态状态

### Token 刷新容错
- 双轨刷新:主动续期(JWT exp 75% 时间点 setTimeout)+ 被动兜底(handle401)
- 刷新失败分级:永久失败(throw RefreshTokenExpiredError)→清token弹登录;临时失败(return null)→仅拒绝当前请求
- AppProvider 登录/自动登录/退出 启停主动刷新定时器

### 提交按钮防抖
- 2s 节流防抖("你点击的太快了")+ 二次确认弹窗("您已提交,确认要再次提交吗?")
- 修复 useAsyncMutation.execute 返回值判空(原 try/catch 死代码)

### 文件上传 Hook
- 新增 useFileUpload hook:封装 request.upload() 为 antd Upload 兼容 customRequest
- ImageInput/ImageListInput/AudioListInput 集成 customRequest
- uploadValidation beforeUpload 返回值修正(false→true)

### 其他
- AnnouncementDrawer 接入公告 API + EnumResolver
- billing/announcement/banner 服务模块完善
- CLAUDE.md 补充架构约定文档
2026-06-12 19:03:36 +08:00
4237dcbaf6 feat: 数据获取架构重构 + Mac指纹采集 + Token刷新容错 + 账单页面 (0.0.19)
## 架构重构
  - useAsyncData 集成 AbortController:cleanup 时真正中断 HTTP 请求,
    不再仅丢弃响应(解决 StrictMode 双重请求问题)
  - API 模块统一 signal?: AbortSignal 参数,透传 axios config
  - use-api.ts 成为数据 Hook 集中管理中心,新增 useAccountInfo、
    useBillingBalance、useBillingLedger
  - 页面组件改为纯渲染:const {data,loading,error,refetch} = useXxx()

  ## 新功能
  - Mac 硬件指纹采集(electron/preload/device.ts):
    ioreg(UUID) + sysctl(CPU) + system_profiler(HDD三级回退)
  - 账单页面 BillingInfo(余额卡片 + 账单表格 + 分页)
  - PageDispatcher 注册 /billing 页面(FloatingPanel 960×680)
  - 共享工具函数 centToYuan / formatDate(src/utils/display.ts)

  ## Bug 修复
  - Token 刷新容错:网络错误时保留 refresh_token,等网络恢复后重试;
    仅 RefreshError(token 过期)时清除(auth-token.ts)
  - AppProvider 自动登录时 nullable 字段默认值(email→''、admin_permissions→[])
  - AccountInfo 重构消除 ~40 行手写状态管理代码

  ## 代码质量
  - AccountInfo 消除 centToYuan/formatDate 重复定义
  - BillingInfo 标题层级精简(移除冗余页面/Card标题)
  - antd Table align 字面量类型修正('left' as const)
  - billing.ts signal 参数位置修复(params→config)
  - modelFirstFetchDone 模块级变量移除(StrictMode 不安全)
2026-06-11 20:08:44 +08:00
272 changed files with 22792 additions and 5348 deletions

View File

@@ -1,6 +1,6 @@
{ {
"pid": 844000, "pid": 1493920,
"version": "0.9.9", "version": "0.9.9",
"socketPath": "\\\\.\\pipe\\codegraph-aef859f0205bacea", "socketPath": "\\\\.\\pipe\\codegraph-aef859f0205bacea",
"startedAt": 1781055291389 "startedAt": 1782701380684
} }

View File

@@ -9,9 +9,5 @@ VITE_API_BASE_URL=https://www.heixiu.net
# 主进程 — 更新检查 API指向本地测试服务器 # 主进程 — 更新检查 API指向本地测试服务器
UPDATE_API_URL=http://127.0.0.1:8000 UPDATE_API_URL=http://127.0.0.1:8000
# 版本类型
VITE_EDITION=personal
EDITION=personal
# 应用标题后缀 # 应用标题后缀
VITE_APP_TITLE=船长·HeiXiu VITE_APP_TITLE=船长·HeiXiu

View File

@@ -25,11 +25,18 @@ UPDATE_API_URL=https://www.heixiu.com
# OSS_ACCESS_KEY_ID=your-access-key-id # OSS_ACCESS_KEY_ID=your-access-key-id
# OSS_ACCESS_KEY_SECRET=your-access-key-secret # OSS_ACCESS_KEY_SECRET=your-access-key-secret
# OSS 内基路径(默认 releases
# OSS_BASE_PATH=releases
# 预判 URL 模式时的下载基地址generate-update-info.mjs 使用) # 预判 URL 模式时的下载基地址generate-update-info.mjs 使用)
# UPDATE_DOWNLOAD_BASE=https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases # UPDATE_DOWNLOAD_BASE=https://heixiu.oss-cn-hangzhou.aliyuncs.com
# -------- 构建配置 --------
# 版本类型personal / enterprise
# HEIXIU_EDITION=personal
# 发布渠道stable / beta / alpha
# HEIXIU_CHANNEL=stable
# electron-builder 输出目录名(默认与版本号相同)
# HEIXIU_RELEASE_DIR_NAME=0.1.0
# -------- 代码签名 -------- # -------- 代码签名 --------
# 证书文件路径(.pfx / .p12 # 证书文件路径(.pfx / .p12

View File

@@ -10,9 +10,5 @@ VITE_API_BASE_URL=https://www.heixiu.net
# 主进程 — 更新检查 API生产服务器 # 主进程 — 更新检查 API生产服务器
UPDATE_API_URL=https://www.heixiu.net UPDATE_API_URL=https://www.heixiu.net
# 版本类型
VITE_EDITION=personal
EDITION=personal
# 应用标题后缀 # 应用标题后缀
VITE_APP_TITLE=船长·HeiXiu VITE_APP_TITLE=船长·HeiXiu

9
.gitignore vendored
View File

@@ -6,7 +6,7 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
.codegraph/*
node_modules node_modules
# vite打包输出 # vite打包输出
dist dist
@@ -39,3 +39,10 @@ WORKLOG.md
*open*.json *open*.json
response_*.json response_*.json
test-server/__pycache__/ test-server/__pycache__/
/src/vite-env.d.ts.tmp.444812.865037551ae0
# 自动生成的文件夹 README 文档(根目录 README.md 保留)
/**/README.md
!/README.md
infinite_canvas_140/**/*.*

View File

@@ -1,6 +1,6 @@
# 船长 · HeiXiu — 前端架构文档 # 船长 · HeiXiu — 前端架构文档
> 最后更新2026-06-09 > 最后更新2026-06-12
## 目录结构 ## 目录结构
@@ -14,6 +14,8 @@ src/
├── contexts/ # React Context 定义 + Consumer Hook ├── contexts/ # React Context 定义 + Consumer Hook
│ └── app-context.ts # AppContextValue 类型 + useAppContext() │ └── app-context.ts # AppContextValue 类型 + useAppContext()
├── hooks/ # 通用 Hook ├── hooks/ # 通用 Hook
│ ├── use-async.ts # 通用异步数据 HookuseAsyncData / useAsyncMutation
│ ├── use-api.ts # 业务 API Hook 集中管理Banner/Model/Task/Auth/Billing
│ ├── use-theme.ts # 主题切换 │ ├── use-theme.ts # 主题切换
│ └── use-updater.ts # 版本更新 │ └── use-updater.ts # 版本更新
├── pages/ # 页面 ├── pages/ # 页面
@@ -82,6 +84,72 @@ src/
--- ---
## 数据获取架构
### 三层模式API 模块 → use-api Hook → 页面组件
```
Step 1 — API 模块 Step 2 — use-api.ts Step 3 — 页面组件
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ BillingAPI │ → │ useBillingBalance │ → │ BillingInfo.tsx │
│ .getBalance(signal) │ │ useAsyncData( │ │ const {data,loading,│
│ .getLedger(param,s) │ │ (signal)=> │ │ error,refetch}= │
│ │ │ BillingAPI... │ │ useBillingBalance() │
│ signal?: AbortSignal │ │ ,[deps], │ │ // 纯渲染,不调 API │
│ 透传 axios config │ │ {enabled:isLoggedIn} │ │ │
│ │ │ ) │ │ │
└──────────────────────┘ └──────────────────────┘ └──────────────────────┘
```
### 原则
- **API 模块**`services/modules/*.ts``signal?: AbortSignal` 可选参数,透传给 axios config向后兼容
- **use-api.ts**:所有业务数据 Hook 集中于此,组件只 import Hook 不 import API 类
- **页面组件**:纯渲染,通过 `{ data, loading, error, errorMessage, refetch }` 消费数据,不手写 `useState`/`useEffect`/`useRef` 状态管理
- **`useAsyncData`**自动处理竞态条件raceId、AbortController 取消、错误日志
### signal 参数规范
```
// API 层 — signal 在 config 位置(第三个参数),不是 params
static getBalance(signal?: AbortSignal): Promise<BalanceResponseBody> {
return get<BalanceResponseBody>(url, undefined, { signal });
}
// Hook 层 — signal 由 useAsyncData 自动注入
export function useBillingBalance() {
return useAsyncData<BalanceResponseBody>(
(signal) => BillingAPI.getBalance(signal),
[],
{ enabled: isLoggedIn, label: 'billing-balance' },
);
}
```
### AbortController 生命周期
- 每次 effect 创建新 `AbortController`
- cleanup 时调用 `abortController.abort()` → 真正中断飞行中的 HTTP 请求(不仅仅是丢弃响应)
- `signal.aborted` 检查:取消的请求静默忽略,不设置 error 状态
- 解决 React StrictMode 开发模式下组件双重挂载导致的重复请求问题
### 新增 Hook 规范
`use-api.ts` 中添加新 Hook 只需三行:
```typescript
export function useXxx() {
const { isLoggedIn } = useAppContext();
return useAsyncData<XxxResponse>(
(signal) => XxxAPI.getXxx(signal),
[], // deps — 为空表示只请求一次refetch 手动触发
{ enabled: isLoggedIn, label: 'xxx' },
);
}
```
---
## 核心流程 ## 核心流程
### 1. 登录 ### 1. 登录
@@ -134,6 +202,60 @@ App 启动 → AppProvider useEffect
请求 A 收到 401 → 开始 refresh 请求 A 收到 401 → 开始 refresh
请求 B 同时收到 401 → 发现 isRefreshing=true → 加入队列等待 请求 B 同时收到 401 → 发现 isRefreshing=true → 加入队列等待
refresh 完成 → 新 token 广播给所有排队请求 → 全部重试 refresh 完成 → 新 token 广播给所有排队请求 → 全部重试
### 3.1 Token 刷新错误分类auth-token.ts
| 错误类型 | 行为 | 原因 |
|---------|------|------|
| `RefreshError`token 过期) | 清除 refresh_token | 无法再续期 |
| `NETWORK` / `TIMEOUT` | **保留** refresh_token | 网络恢复后可重试 |
| HTTP 5xx / Business 错误 | 清除 refresh_token | 保守处理 |
> 网络错误时不清除 refresh_token 是关键——否则网络短暂中断会导致用户被迫手动重新登录。
> 只有 RefreshErrorrefresh 接口返回 401才确认 token 真的过期。
```
### 3.2 主动刷新定时器v0.0.20
除被动 401 刷新外,系统通过 JWT `exp` 声明主动续期,避免长时间空闲后 access_token 过期:
```
登录/刷新成功
→ scheduleProactiveRefresh(access_token)
→ 解码 JWT exp 声明
→ setTimeout(delay = max(60s, remaining * 0.75))
→ 到期触发 → getStoredRefreshToken()
→ AuthAPI.refreshToken()
✅ 成功 → 更新双 token → 递归安排下一次刷新
❌ 失败 → 静默(等下次 401 走被动刷新)
```
- 主动刷新用 JWT 解码(`atob(token.split('.')[1])`)获取 `exp`,不依赖服务端 `expires_in`
- 在 75% 过期时间点触发,确保在被动 401 之前完成续期
- 至少间隔 60s防止 token 极短时频繁刷新)
- 退出登录时 `clearProactiveRefresh()` 清除定时器
### 3.3 刷新失败分级处理v0.0.20
`refreshHandler` 通过 Promise 状态区分失败级别,`handle401` 据此决定是否登出:
| 失败类型 | refreshHandler 行为 | handle401 响应 |
|---------|-------------------|---------------|
| refresh_token 过期/无效 | **throw** `RefreshTokenExpiredError` | 清 token + emit AUTH_REQUIRED → 弹登录 |
| 网络超时/无连接 | **return null** | 仅拒绝当前请求,保留 token → 下次再试 |
**关键改进**:临时网络故障不再误判为"需要重新登录"。用户在网络恢复后重试操作即可,无需手动登录。
```
handle401 内部:
refreshHandler()
.then(newToken =>
newToken → ✅ 用新 token 重试
null → ⚠️ 临时失败拒绝但不登出processQueue 失败,但 clearToken 不调用)
)
.catch(_err →
❌ 永久失败 → clearToken + emit(AUTH_REQUIRED) + 弹登录
)
``` ```
### 4. 退出登录 ### 4. 退出登录
@@ -187,6 +309,37 @@ NavBar / 设置页 → AppProvider.logout()
- SettingsPage — Modal事件总线 `OPEN_SETTINGS` 触发 - SettingsPage — Modal事件总线 `OPEN_SETTINGS` 触发
- AnnouncementDrawer — DrawerNavBar 内部 state 控制 - AnnouncementDrawer — DrawerNavBar 内部 state 控制
**PageDispatcher 页面注册**
```typescript
const PAGE_MAP: Record<string, PageConfig> = {
'/account': {
component: AccountInfo, // 页面组件(懒加载用 React.lazy
label: '账户', // 容器标题栏显示
width: () => Math.min(window.outerHeight * 0.65, 720), // 动态宽度
height: () => Math.min(window.outerHeight * 0.65, 680), // 动态高度
},
'/billing': {
component: BillingInfo, // 账单页面(余额卡片 + 账单表格)
label: '账单记录', // FloatingPanel 标题栏显示
width: 960, // 固定宽度(适配账单表格)
height: 680, // 固定高度
},
'/vip': { label: '开会员/激活' }, // 占位页:无 component → 自动渲染 "页面开发中"
};
```
**容器类型选择**
| 类型 | 组件 | 场景 |
|------|------|------|
| `modal` | antd Modal | 居中模态阻断交互登录、VIP 开通) |
| `drawer` | antd Drawer | 侧滑抽屉(设置、公告) |
| `floating` | FloatingPanel | 非模态浮动面板,可拖拽(账户、账单) |
- **全局互斥**:同一时刻只有一个面板打开,重复点击不替换
- **FloatingPanel** 自带 `padding: 16` 和标题栏,内部组件避免过度内边距叠加
**反模式警告**:叠加层绝不应通过路由跳转实现。`navigate('/xxx')` + `useLocation` 判断显隐会导致原有页面卸载,丢失所有组件状态。 **反模式警告**:叠加层绝不应通过路由跳转实现。`navigate('/xxx')` + `useLocation` 判断显隐会导致原有页面卸载,丢失所有组件状态。
### API 模块规范 ### API 模块规范
@@ -252,3 +405,142 @@ export class AuthAPI {
|------------------|-------------------------|--------------------------|--------------------------| |------------------|-------------------------|--------------------------|--------------------------|
| `UPDATE_API_URL` | `http://127.0.0.1:8000` | `https://www.heixiu.com` | `https://www.heixiu.com` | | `UPDATE_API_URL` | `http://127.0.0.1:8000` | `https://www.heixiu.com` | `https://www.heixiu.com` |
| `EDITION` | `personal` | `personal` | `personal` | | `EDITION` | `personal` | `personal` | `personal` |
---
## 平台兼容
### 设备指纹electron/preload/device.ts
| 平台 | 采集方式 |
|------|---------|
| Windows | WMIC / PowerShell — UUID、CPU、HDD 序列号 |
| macOS | `ioreg -d2 -c IOPlatformExpertDevice`UUID`sysctl -n hw.model`CPU 型号)、`system_profiler`HDD 三级回退) |
**Mac HDD 三级回退策略**
1. `system_profiler SPNVMeDataType` → NVMe 磁盘序列号Apple Silicon / 新款 Intel
2. `system_profiler SPSerialATADataType` → SATA 磁盘序列号(老款 Intel Mac
3. `system_profiler SPHardwareDataType` → 系统序列号(最终兜底)
- 通过 `platform() === 'darwin'` / `platform() === 'win32'` 区分平台
- Mac 命令执行:`spawnSync` + 5s 超时,`ioreg`/`sysctl` 始终可用
- 共享同一套 `SHA256(normalizeIdentifier(combined))` 管线
- `getMachineCode()` 跨平台统一接口,自动选择最优采集方式
---
## 任务状态轮询v0.0.20
### 架构:两层轮询 + Context 桥接
```
TaskHistory (列表页)
├─ useTaskPolling(tasks, onTerminal) ← 批量轮询 batchTaskStatus
│ └─ 仅轮询 POLLING_STATUSES 中的非终态任务
│ └─ 轮询结果合并到 polledTasks → dataSource
│ └─ 某任务变为终态 → onTerminal → setRefreshTick(+1) → 完整列表刷新
└─ useEffect: polledTasks 变化 → 同步 selectedTask 到 Context
OutputPreview (详情面板)
└─ useEffect: 监听 selectedTask.status 变化
└─ 非终态→终态 → useTaskDetail.refetch() ← 仅触发详情刷新,不独立轮询
```
**设计原则**
- 批量轮询负责广度(列表视图状态更新),详情刷新负责深度(终态后获取 output_assets
- OutputPreview **不独立调用** `getTaskStatus`,避免与批量轮询重复请求
- 两者通过 `HomeContext.selectedTask` 桥接,不直接通信
### 轮询实现细节
| 特性 | 实现 |
|------|------|
| 调度方式 | 递归 `setTimeout`(非 `setInterval`),避免请求堆积 |
| 间隔控制 | 服务端 `next_poll_ms`,本地 clamp 2s~30s |
| 错误重试 | 请求失败后 10s 重试 |
| 自动停止 | 所有非终态任务变为终态后停止轮询 |
| 闭包安全 | `mergedRef.current` 确保递归回调读到最新任务列表 |
| 非终态常量 | `POLLING_STATUSES = ['pending', 'submitted', 'processing']` 统一管理 |
### GET 数组参数序列化
axios 默认 `paramsSerializer` 对数组使用 `key[]=v1&key[]=v2`PHP/Rack bracket 风格),后端不识别。修复方案:
```typescript
// ❌ 错误axios 默认序列化为 task_ids[]=v1&task_ids[]=v2
get(url, { task_ids: ['id1', 'id2'] })
// ✅ 正确:逗号拼接为 task_ids=id1,id2跨框架兼容
get(url, { task_ids: params.task_ids.join(',') })
```
---
## 文件上传v0.0.20
### customRequest 集成模式
antd Upload 组件通过 `action` 属性使用自有的 XMLHttpRequest不走 axios 拦截器,导致 Token 缺失。解决方案:
```
useFileUpload() hook
└─ 返回 { customRequest } — 兼容 antd Upload.customRequest 签名
└─ 内部调用 request.upload(url, formData, onProgress)
└─ 复用 axios 实例Token 注入、超时、错误处理、业务码校验
```
```
ImageInput / ImageListInput / AudioListInput
└─ const { customRequest } = useFileUpload();
└─ <Upload customRequest={customRequest} beforeUpload={...} />
└─ beforeUpload 校验通过 → 返回 true → 放行至 customRequest
└─ customRequest 调用 request.upload() → 上传到 /api/v1/upload
└─ 成功后 antd 将 response 写入 file.response
└─ ModelInputForm 提交时读取 f.response?.url
```
### beforeUpload 语义
| 返回值 | 含义 |
|-------|------|
| `true` | 校验通过,交由 customRequest 实际上传 |
| `false` | 阻止上传(文件仅加入列表,不发起请求) |
| `Upload.LIST_IGNORE` | 校验失败,文件不加入列表 |
---
## 表单提交防抖v0.0.20
### 两级防护
```
handleSubmit()
├─ 1. 防抖节流2s
│ └─ lastClickTimeRef: now - last < 2s → "你点击的太快了"
├─ 2. 二次确认
│ └─ submitCountRef > 0 → Modal.confirm("您已提交,确认要再次提交吗?")
│ └─ 确认 → lastClickTimeRef = 0重置防抖→ doSubmit()
└─ 3. 执行提交
└─ doSubmit(): form.validateFields() → submitTask() → 检查返回值
```
### useRef 而非 useState
`lastClickTimeRef``submitCountRef` 使用 `useRef` 而非 `useState`,避免每次点击触发不必要的组件重渲染。
### useAsyncMutation 返回值判空
`useAsyncMutation.execute()` 内部 catch 所有错误并返回 `undefined`(不 throw。调用方应检查返回值而非 `try/catch`
```typescript
const result = await submitTask({...});
if (result !== undefined) {
submitCountRef.current += 1; // 成功
} else {
message.error(errorMessageRef.current || '任务提交失败');
}
```

View File

@@ -3,6 +3,372 @@
> 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。 > 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。
--- ---
## 0.0.292026-06-29
**IPC/Preload/Services 模块化拆分 — 单一职责 + 向后兼容**
### 模块化拆分
- **shared/constants/ipc**`ipc-channels.ts` (105行) → `ipc/` 模块 (base/storage/canvas/updater)
- **electron/preload**`preload.ts` (229行) → `preload/` 模块 (base/safe-storage/file-storage/canvas/app-runtime)
- **electron/main/ipc**
- `canvas-ipc.ts` (705行) → `canvas/` 模块 (types/walk/sidecar/mime/thumbnail/scan-roots)
- `storage-ipc.ts` (357行) → 提取 `utils/` (path/download/thumbnail)
- **electron/main/updater**`updater.ts` (479行) → `updater/` 模块 (provider/platform-updaters)
- **src/services/request**`request.ts` (479行) → `request/` 模块 (token/interceptors/methods)
- **src/shared/utils/pricing**`pricing.ts` (488行) → `pricing/` 模块 (types/helpers/format/calculate/fields)
- **ModelInputForm.tsx**:提取 `utils.ts` + `DependentFormItem.tsx` 子组件
### 架构优化
- 所有拆分均保持向后兼容(旧导入路径仍可用)
- 每个模块单一职责,平均 ~130 行
- TypeScript 编译零错误
---
## 0.0.282026-06-29
**项目架构重构VCHSM 五层模块化)+ 公告 Badge 提醒**
### 架构重构
- **VCHSM 模块化**`pages/` + `services/modules/` 分散模式 → 统一 `modules/` 五层架构
- **V**iews — 页面组件(纯 JSX 结构)
- **C**ontrollers — 控制器(交互逻辑 + API 调用)
- **H**ooks — 自定义 Hook组件间复用逻辑
- **S**ettings — 配置层(常量映射、字段配置、注册表)
- **M**odel — 数据模型(纯 TS 类型/接口)
- **7 个业务模块**navbar / announcement / auth / home / account / canvas / settings
- **Home 模块三栏嵌套**left输入/ center预览/ right模型/任务),每栏独立 VCHSM
- **Account 模块子功能分目录**info / vip / recharge / billing / orders / wechat-work
- **应用壳抽离**`src/app/` 目录(入口 + Provider + 布局 + 路由分发)
- **跨模块共享**`src/shared/` 目录components / hooks / services / utils / infrastructure / theme
- **导入路径全面更新**~500+ 处引用同步TypeScript 编译零错误
### 公告
- **Badge 角标提醒**antd `Badge` 组件包裹 NavItem未读公告数量实时显示
- **缓存与轮询**`useAnnouncements` Hook — localStorage 缓存已读 ID + 每 15 分钟轮询对比
- **标记已读**:打开公告抽屉时自动将当前公告 ID 写入 localStorage角标消除
### 日志系统
- **全量审计修复**~60+ 处 try/catch 补充 `logger.warn()` 记录
- **存储路径可配置**`setLogDirResolver()` / `setDataDirResolver()` 函数注入模式
- **15 个日志分类**request / auth / updater / event / app / ui / ipc / safe-storage / storage / canvas / canvas-window / …
---
## 0.0.272026-06-24
**无限画布 UI 重构 + 预览键盘控制 + 拖拽体验优化**
### 无限画布
- **双栏布局重构**AntD LayoutSider + Content左侧导航 + 右侧画布,页面级别渲染预览组件解耦 DOM
- **CanvasSidebar**:项目/集数选择器 + 镜头搜索 + 镜头列表(滚动条 + 底部固定刷新按钮)+ 工作区切换
- **CategoryTabs 双模式**Assets 阶段 → 全部/角色/道具/场景/收藏;其他阶段 → 所有[阶段名]/收藏/待审核/通过/返修/已弃用
- **分类数量徽标**:每个标签显示数据计数,"收藏"标签额外显示媒体类型细分(视频/图片/音频)
- **Ctrl/Cmd + 拖拽平移**:替代 Space 键修饰,释放 Space 给媒体控制;`pointerEvents: none` 防止拖拽时误抓缩略图
- **画布锁**:预览打开时 `previewActiveRef` 双守卫阻止穿透缩放/拖拽
- **无限画布入口按钮**StageTabs 右侧按钮,调用 `OPEN_CANVAS_WINDOW` IPC
### 预览键盘控制
- **Space 暂停/播放**capture 阶段拦截 + `stopPropagation`,避免与原生 `<video controls>` 双重触发
- **← → 快退/快进**capture 阶段直接设置 `currentTime`,步长 ±0.5 秒
- **↑ ↓ 音量**:透传给原生 controls 处理
### 主进程
- **local-media:// Range 头转发**:协议 handler 只提取并转发 `Range` 请求头到 `net.fetch`,确保音视频 seek 返回正确的 206 Partial Content
### UI 调整
- 全局字号上调(侧栏、工具栏、标签、状态栏等)
- CanvasSidebar 滚动条定制 + 主题色适配
- ProjectSelector / EpisodeSelector 宽度自适应
---
## 0.0.262026-06-23
**无限画布滚轮缩放 + 项目管理入口 + 文件拖拽**
### 无限画布(企业版项目管理)
- **滚轮缩放视口**:纯滚轮 10%/tick 快速缩放Ctrl/Cmd+滚轮 3%/tick 精细控制,范围 2%~1600%
- **鼠标拖拽平移** + **Ctrl/Cmd+0 重置缩放** + **ZoomOverlay 百分比指示器**
- **预览事件穿透防护**:预览打开时锁定底层视口(`previewActiveRef` 双守卫)
- **预览内独立缩放**:图片灯箱支持滚轮缩放(2%~800%) + 拖拽平移 + 百分比指示器
- **原生文件拖拽**:缩略图/预览 `webContents.startDrag()` → Photoshop/资源管理器等外部应用
- **local-media:// 协议**:支持 UNC 网络路径 + 本地路径安全加载
### 主进程
- **画布窗口 `before-input-event`**:拦截 Chromium 页面级缩放,滚轮事件完整到达渲染层
- **`canvas-ipc.ts`**:文件扫描/缩略图生成/文件操作/sidecar 读写/startDrag 等 15+ IPC handler
- **数据源抽象**`ICanvasDataSource` 接口,离线 `LocalFileDataSource` + 未来在线模式平滑切换
### Bug 修复
- **passive event listener**React `onWheel` → window 级原生 `{ passive: false }` listener
- **Zustand v5 `getState()` 批处理旧值**:双 `ref``currentZoomRef` + `setZoomRef`)读写,绕过原生事件中的批处理快照
---
## 0.0.252026-06-22
**业务错误 Modal 引导 + 左侧栏 UI 增强**
### 业务错误 Modal 引导体系
- **HTTP 402 + code 1001 → 余额不足 Modal**`request.ts` 拦截器双条件精确匹配,`App.tsx` 弹 Modal 引导"去充值"
- **HTTP 403 + code 4040 → 软件到期 Modal**:拦截 403+4040弹 Modal 引导"去购买 VIP"
- **`BusinessErrorEvent` 事件总线类型**:标准化 `{ code, message }` 负载,新增 `BALANCE_INSUFFICIENT``SUBSCRIPTION_EXPIRED` 事件
- **`RequestError.displayed` 标记**Modal 已展示的错误标记 `displayed=true``ModelInputForm.doSubmit` 读取标记跳过 `message.error` 重复 toast
- **防御性兜底**HTTP 200 + code 1001 也入拦截器(兼容后端行为变体)
### 左侧栏 UI 增强
- **模型列表 emoji 渲染**:从后端 `ui_config.badge` / `meta_config.icon` 提取 emoji 显示在模型名称前
- **标题栏色条加深**:模型选择器 + 任务记录标题栏加 `borderLeft: 3px` 色条 + `colorFillSecondary` 背景 + 图标染色
- **任务列表分页器固定**`Pagination` 独立到表格外部,`pagination={false}` on Table分页器始终固定在底部
- **水平滚动条固定**:独立 `div` + 双向 `scroll` 事件同步 + MutationObserver不随垂直滚动移动
- **统一滚动条样式**`::-webkit-scrollbar` 暗色/亮色双套,纵向 6px、横向 6px风格统一
- **表头取消加粗**`.ant-table-thead > tr > th { font-weight: normal }`
- **模型列表与任务列表间距**`paddingBottom: 4` / `paddingTop: 4`
---
## 0.0.242026-06-22
**去除构建时版本变量 + 窗口标题动态版控 + 退出登录保留缓存**
### 版控体系重构
- **去除构建时 `EDITION` 环境变量**`--personal`/`--enterprise` 参数从 `build.mjs` 移除,`vite.config.ts` 不再注入 `process.env.EDITION``.env.*` 清理相应行
- **统一的安装包文件名**`船长-HeiXiu-{os}-{版本号}-Setup.{ext}`(移除 `${env.EDITION}`
- **`package.json` 简化**:移除 3 条 `:enterprise` 构建脚本
- **`scripts/*.mjs` 统一**`upload-oss`/`generate-update-info`/`publish-release` 不再接受 `edition` 参数
### 窗口标题动态版控
- **新格式**`船长-HeiXiu-{os}-{版本号}`(未登录)/ `船长-HeiXiu-{os}-{版本号}·{版控}`(登录后)
- **运行时链路**`AppProvider` 根据 `user.account_type` 派生 `edition``useEffect``window.appRuntime.setWindowEdition()` → IPC `set-window-title` → 主进程 `buildWindowTitle``mainWindow.setTitle`
- **Edtion 类型守卫**`src/vite-env.d.ts` + `electron/electron-env.d.ts` 双端类型声明
- **更新检查 API** 移除 `edition` 查询参数
### 本地存储生命周期修正
- **退出登录不再自动清缓存**`AppProvider.logout()` 移除 `clearUserStorage()` 调用。数据已按 `user_id` 隔离,同一用户重新登录缓存直接命中
- **`clearUserStorage` 竞态修复**:添加 `isOpen()` 前置守卫,防止数据库未初始化时执行 SQL移除尾部 `closeDatabase()`,确保退出→重新登录周期内数据库持续可用
- **手动清理保留**`DataManageSetting` 的「清理缓存」按钮仍可主动触发
---
## 0.0.232026-06-22
**三栏等比缩放修复 + 代码重组**
### 三栏布局修复
- **约束逻辑修正**:双遍最小值约束改为 clamp + 超限兜底,修复窗口缩小至 1036px 以下时中列被挤出视口
- **双触发器**`ResizeObserver` + `window.resize` 事件双重驱动,防止 React 批量更新吞掉重绘
- **三栏等比缩放**:旧算法只保 left:right 比例,中列被动吸收剩余;新算法通过 `prevWidthRef` 反推中列上次宽度,三栏按各自占比同步缩放——窗口从半屏拉到全屏保持 ≈1:1:1
### 代码重组(第二轮)
- **src/utils/**11 文件精简为 7 文件 —— `env.ts`(platform+device)、`bus.ts`(event-bus+ipc+logger)、`display.ts`(display+logo+type)、`media-upload.ts`
- **controls/**14 文件精简为 6 文件 —— `simple-widgets.tsx`(6合1)、`upload-widgets.tsx`(ImageInput+VideoInput 去重)、`list-upload-widgets.tsx`(ImageListInput+AudioListInput)
- **FormField 提升**:从 `auth/components/` 提升到 `src/components/ui/``FieldConfig` 类型同步移到 `ui/types.ts`
- **data-sync 归位**:从 `settings/sections/` 移到 `infrastructure/storage/`
- **electron/main/ 分组**3 个 IPC handlerlog-ipc/safe-storage-ipc/storage-ipc收拢到 `ipc/` 子目录
- **shell/ → frame/**`src/components/shell/` 重命名为 `frame/`,更直观表达"应用框架"
- **storage/ 分层**15 文件平铺 → `core/`4+ `stores/`5+ `services/`4+ 入口
### 文档
- 37 个文件夹全部生成 README.md不进 Git
- PROJECT.md 目录树同步更新
---
## 0.0.222026-06-18
**三栏布局比例缩放 + SeedanceContent 视频参考上传 + 本地存储基建**
### 三栏布局:比例自适应缩放
- ResizeObserver 改为**等比例分配**策略(替代固定默认值恢复):窗口 Snap/最大化/手动缩放时左-中-右三栏按当前比例同步缩放
- 修复拖拽期间窗口缩放竞态:`handleMouseUp` 即时清除 `draggingRef` + `needsRecalcRef` 延迟补算,解决拖拽中最大化后放开鼠标布局卡死
- 使用 `containerRef.current` 动态读取 DOM替代闭包捕获 mount 时 `el`),避免 Provider 重渲染后读到旧 DOM
### Seedance2Content 内容编排
- `SeedanceContent` 从占位文本重构为完整内容编排控件:文本描述 + 参考图片/视频/音频三条上传通道
- 根据 `param_schema[].spec.image_files / video_files / audio_files` 动态渲染对应上传区域,**不依赖 widget 名称的隐式约定**
- 内部值类型 `{ text, imageUrls[], videoUrls[], audioUrls[] }` → 表单 JSON 字符串 → 提交时序列化为 API `ContentItem[]``text` / `image_url` / `video_url` / `audio_url` 判别联合)
- `useModelUI` 新增 `widgetName` 字段 + `spec` 媒体文件计数传参(`imageFiles`/`videoFiles`/`audioFiles`/`uploadProvider`
- `ModelInputForm.doSubmit` 新增 `seedance2Content` 分支序列化逻辑
### 图片上传兼容视频文件
- `ImageInput` / `ImageListInput``buildAccept` 根据 `file_filter` 自动区分 `image/` vs `video/` 前缀(原硬编码 `image/` 导致视频文件不可选)
- `createMediaBeforeUpload` 根据 `filters` 动态放行 `video/*` MIME
- 公共模块 `media-upload-utils.ts``VIDEO_EXTENSIONS` + `buildAccept()` + `createMediaBeforeUpload()` 三组件共享
### 本地存储基建sql.js + safeStorage
- **数据库**sql.jsWASM SQLite渲染进程内存数据库DDL 7 张表task_records / order_records / param_history / media_cache / sync_cursor / app_settings / _schema_version
- **加密**safeStorageOS DPAPI/Keychain加密持久化base64 编解码管线
- **IPC 文件操作**8 个双向 IPC 通道FILE_WRITE/READ/DELETE/MAKE_DIR/SHOW_ITEM_IN_FOLDER/GET_DATA_DIR/FILE_SIZE/FILE_EXISTS`resolveSafe()` 路径穿越防护
- **数据目录**`<userData>/heixiu-data/`(沙箱内),含 `heixiu.db`(加密镜像)+ `media-cache/<userId>/<date>/`
- **用户隔离**JWT `user_id` 区分数据,`getTodayKey()` 日期分桶
- **同步策略**:本地优先 + 手动触发("加载历史数据"按钮),游标分页(`sync_cursor` 表),每批 100 条
- **设置集成**`DataManageSetting` 卡片(缓存统计 + 加载历史数据 + 保存到磁盘 + 清理缓存)
### 媒体输出预览优化
- `<img>/<video>` 直接加载远程 URL无 CORS 限制),移除阻塞式 axios 下载
- 类型检测URL 扩展名正则 + `output_type` 补充CDN 无扩展名链接)
- `<Image fallback>` 加载失败占位图
### 其他
- 修复 `errorMessageRef` 在渲染期间写入react-hooks/refs 警告)
- CSP 添加 `wasm-unsafe-eval` 支持 WebAssembly + `https:` CDN 媒体源
---
## 0.0.212026-06-15
**文件结构重组components/ pages/ 按职责分层**
### components/ → 三层拆分
- `providers/`状态管理AppProvider / ThemeProvider / SettingsProvider
- `shell/`应用外壳Layout / PageDispatcher / navbar/
- `ui/`通用原子FloatingPanel / LegalTextModal
### pages/ → 重命名消除歧义
- `headers/``panels/`NavBar 面板页,非 HTTP headers
- `login/``auth/`(含登录/注册/忘记密码/修改密码)
- `settings/blocks/``sections/`
- `home/components/useUI/``home/controls/`
### home/ → 四层子结构
- `panels/` — 布局壳LeftPanel / CenterPanel / RightPanel
- `features/` — 功能面板Banner / ModelInput / ModelSelector / Output / TaskHistory
- `controls/` — 表单控件库12 个控件 + Widget 注册表)
- `hooks/` — 纯 HookuseModelUI
### 其他
- HomeContext 从 HomeContent.tsx 拆分独立文件(解决 react-refresh ESLint 警告)
- uploadValidation 内联到 ImageInputcontrols/ 不再混工具函数)
- 删除空占位 Test3.tsx
### 支付系统 & 订单明细
- **RechargePage antd 6.x 适配**`InputNumber.addonAfter``Space.Compact``Space direction``orientation`,消除弃用警告
- **OrdersPage 订单明细页面**`/orders`,方案 B — 独立订单管理页):
- 状态筛选 Segmented全部/待支付/已支付/已过期/支付失败)+ 分页 Table
- "继续支付"操作:打开支付 URL + `startPolling` 单订单轮询
- `pollingRef: Map<orderId, timer>` 管理多订单并发轮询,幂等启动(重复点击防重)
- 局部 state 更新 + 终态全量刷新(`fetchOrders`
- `AbortController` 防竞态:切换筛选/分页时取消旧请求
- `columns` 使用 `useMemo` 稳定引用
- 错误态 Result + 重试按钮
- **PageDispatcher**:注册 `/orders` 路由width: 820, height: 640新增 `CLOSE_PAGE` / `PAGE_LOCK` 事件监听
- **payments.ts**:支付 API 模块类型体系(`CreateOrderResponseBody``OrderListResponseBody` / `OrderInfoResponseBody` 名义类型),`PaymentAPI.getOrdersListInfo` 支持 `AbortSignal`
---
## 0.0.202026-06-12
**任务状态轮询 + Token 刷新容错增强 + 提交防抖 + 文件上传 Hook**
### 任务状态轮询
- 新增 `useTaskPolling` hook批量轮询非终态任务状态递归 `setTimeout` + `next_poll_ms` 自适应间隔
- `TaskHistory` 集成批量轮询,`OutputPreview` 改为监听终态转换Context 桥接)触发 `refetch()`,消除重复请求
- `POLLING_STATUSES` 统一管理非终态状态,所有轮询逻辑引用此常量
- `batchTaskStatus` 使用 GET + 逗号拼接 `task_ids=a,b,c`,修复 axios 默认 bracket 序列化
### Token 刷新容错增强
- **主动刷新**JWT `exp` 解码驱动定时器,在 75% 过期点通过 `setTimeout` 提前续期
- **失败分级**`refreshHandler` 永久失败refresh_token 过期)→ **throw** → 清 token 弹登录;临时失败(网络超时)→ **return null** → 仅拒绝当前请求不登出
- 新增 `RefreshTokenExpiredError` 类区分失败类型
- `AppProvider` 登录/自动登录/退出 启停主动刷新定时器
### 提交按钮防抖
- 两级防护2s 节流防抖("你点击的太快了"+ 二次确认弹窗("您已提交,确认要再次提交吗?"
- `doSubmit` 抽取为 `useCallback``handleSubmit` 负责门控
- 修复 `useAsyncMutation.execute` 返回值判空(原 `try/catch` 为死代码)
### 文件上传 Hook
- 新增 `useFileUpload` hook封装 `request.upload()` 为 antd Upload 兼容 `customRequest`,复用全局拦截器
- `ImageInput` / `ImageListInput` / `AudioListInput` 集成 `customRequest`
- `uploadValidation.beforeUpload` 返回值修正:`false``true`(校验通过后放行至 `customRequest`
### 其他
- `AnnouncementDrawer` 接入公告 API + `EnumResolver`
- billing / announcement / banner 服务模块完善
- 文档ARCHITECTURE.md 新增轮询/Token刷新/上传/防抖章节
---
## 0.0.192026-06-11
**数据获取架构重构 + Mac 指纹采集 + Token 刷新容错 + 账单页面**
### 数据获取架构重构
- `useAsyncData` 集成 AbortControllercleanup 时真正中断 HTTP 请求,不再仅丢弃响应(解决 StrictMode 双重请求)
- fetcher 签名变更:`() => Promise<T>``(signal: AbortSignal) => Promise<T>`
- API 模块统一 `signal?: AbortSignal` 可选参数,透传 axios configbanner/auth/models/task/vip
- `use-api.ts` 成为数据 Hook 集中管理中心:
- 新增 `useAccountInfo``Promise.all` 并行获取用户信息 + VIP 等级
- 新增 `useBillingBalance` — 账户余额
- 新增 `useBillingLedger` — 账单明细(支持分页参数)
- `AccountInfo.tsx` 重构:~40 行手写状态管理(`useRef`+`useEffect`+`cancelled`+`retryTick`)→ 1 行 `useAccountInfo()`
- 共享工具 `src/utils/display.ts``centToYuan` / `formatDate`,消除 AccountInfo/BillingInfo 重复定义
- 移除 `modelFirstFetchDone` 模块级变量StrictMode 不安全)
### Mac 硬件指纹采集
- `electron/preload/device.ts` Mac 完整实现:
- UUID`ioreg -d2 -c IOPlatformExpertDevice``IOPlatformUUID`
- CPU`sysctl -n hw.model` → 机型标识
- HDD三级回退 `system_profiler SPNVMeDataType``SPSerialATADataType``SPHardwareDataType`
- `isMacOS()` / `runMacCommand()` / `getMacHddValue()` 辅助函数
- `getComponentId()` / `fingerprintSha256()` Mac 早退分支,与 Windows 共享 SHA256 管线
### 账单页面
- `BillingInfo.tsx` — 余额卡片BalanceCard+ 账单表格LedgerTable+ 分页
- `BillingAPI``getBalance` / `getLedger``signal` 参数透传
- `PageDispatcher` 注册 `/billing` 页面FloatingPanel 960×680
- 修复 `billing.ts` signal 参数位置 bug`get(url, {signal})``get(url, undefined, {signal})`
- BillingInfo 标题精简:移除冗余页面 Title 和 Card title仅保留 FloatingPanel 标题栏
### Bug 修复
- **Token 刷新容错**`auth-token.ts`):网络错误/超时时保留 refresh_token仅 RefreshErrortoken 过期)时清除。
解决网络短暂中断后被迫手动重新登录的问题
- **AppProvider 自动登录**`email`/`admin_permissions` 等 nullable 字段默认值修复(`?? ''` / `?? []`
- **antd Table align**`align: 'left'``align: 'left' as const`,修复 TS2322 类型错误
- **分页器位置**:显式添加 `position: ['bottomRight']`
### 文档
- CLAUDE.md 精简为 AI 行为规则,项目技术内容移入 ARCHITECTURE.md
- ARCHITECTURE.md 新增数据获取架构、页面调度详情、Token 刷新错误分类、设备指纹
---
## 0.0.182026-06-10 ## 0.0.182026-06-10
**前后端联调 — 任务创建 + 账户信息 + 预估花费** **前后端联调 — 任务创建 + 账户信息 + 预估花费**

View File

@@ -1,29 +1,28 @@
# 回答语言 # 基本交代
- 所有交互、解释、代码注释与生成内容均使用简体中文,专有技术名词可保留英文,但需附带中文说明 - 所有交互、解释、代码注释与生成内容均使用简体中文,专有技术名词可保留英文,但需附带中文说明
- 进行任务时都需要考虑安全、性能、主题切换、业务自定义错误、事件总线驱动方式以及日志记录 - 有需要请自行调用MCP服务
- 进行系统级别的操作时需要考虑系统差异并处理兼容性。如WINDOWS、MAC OS。
- 有需要请自己调用MCP服务
# 主题系统架构 # 业务设计思路
## 双轨颜色体系 - 进行任务时都需要考虑安全、性能、主题切换、业务自定义错误、事件总线驱动方式以及日志记录;
- 进行系统级别的操作时需要考虑系统差异并处理兼容性。如WINDOWS、MAC OS
- 处理业务时,需要同时考虑兜底机制和业务自定义错误捕获,比如后端在修改了某些数据的枚举值但没有通知时,需要能够正常转化对用户友好的提示信息;
- 新增文件时,需要注意各个文件夹当前平铺的文件数量,如果过多需要考虑合并文件或者合并为子文件夹;
- 在编写代码时尽量保持JSX文件只有HTML元素结构减少style属性的使用。其他关于js操作应提取为单独的TS模块文件如复杂页面交互逻辑、菜单组件的注册等
- 所有设计思路、代码实现均需要考虑以上五个条件。
1. **自定义 CSS 变量**`globals.css``data-theme="light|dark"``var(--color-bg-base)` 等,用于 body 和自定义组件 # 相关文档说明
2. **antd 令牌**`antd-theme.ts``ConfigProvider theme={algorithm}``useToken()` → inline style用于 antd 组件
## 主题切换时序(关键) - 根目录下的文档包括:
- README.mdgit中的README文档
- WORKLOG.md工作日志文档
- UpdateA.md热更新相关的文档
- TODO.md未完善、未来要做的文档或者是在开发中遇到需要优化的地方
- PROJECT.md项目文档
- CHANGELOG.md版本更新文档
- CLAUDE.md
- 永远不要修改CLAUDE.md文档
- 在各个文档中的更新需要保持精简。且职责划分保持严谨除了架构、项目文档已完成的事需要从TODO.md文档中移除。
- 在没有得到修改指令时,不要修改文档文件。
```
ThemeProvider.useLayoutEffect([isDark])
→ applyHtmlTheme(isDark) ← data-theme 属性变更
(与 ConfigProvider 的 useInsertionEffect 同帧执行)
→ 浏览器绘制 ← 所有颜色变更在同一帧生效
```
## 过渡策略
- **Electron/Chromium**View Transitions API`document.startViewTransition` + `flushSync`GPU 合成器单次 cross-fade
- **其他浏览器**CSS `transition: color/bg/border/shadow 0.15s linear`,精准覆盖 ~80 个 antd 容器类(不覆盖交互组件自有
transition
- **不启用 antd cssVar**:经实测 cssVar 模式下 CSS-in-JS 仍走标签替换路径,过渡无效

View File

@@ -38,7 +38,7 @@
- 事件驱动EDA - 事件驱动EDA
1. 主进程与渲染进程通信基于 Electron IPCipcMain/ipcRenderer定义明确的事件通道与载荷类型。 1. 主进程与渲染进程通信基于 Electron IPCipcMain/ipcRenderer定义明确的事件通道与载荷类型。
2. 渲染进程内跨组件通信优先使用 Context需要解耦的场景使用自定义事件总线src/utils/event-bus.ts 2. 渲染进程内跨组件通信优先使用 Context需要解耦的场景使用自定义事件总线src/shared/utils/bus.ts
所有 emit 调用自动记录到日志文件用于审计追踪。 所有 emit 调用自动记录到日志文件用于审计追踪。
3. 自定义错误体系RequestError / RefreshError通过事件总线解耦请求拦截器抛出自定义错误 → 3. 自定义错误体系RequestError / RefreshError通过事件总线解耦请求拦截器抛出自定义错误 →
@@ -46,82 +46,181 @@
4. 窗口间通信统一通过主进程转发(或使用 MessagePort 等 Electron 支持的机制),保持窗口独立性。 4. 窗口间通信统一通过主进程转发(或使用 MessagePort 等 Electron 支持的机制),保持窗口独立性。
- 路由与导航:主窗口为单页应用,使用 React Router v7+ 管理页面切换。**遵循 QT 多窗口模型映射原则** - 路由与导航:主窗口为单页应用,使用 React Router。所有页面切换通过事件总线 + PageDispatcher 实现
- 叠加层(设置、公告、素材库等)→ Modal/Drawer + 事件总线驱动,主页面不卸载 - 叠加层(设置、公告、素材库等)→ Modal/Drawer + 事件总线驱动,主页面不卸载
- 页面跳转(真正的视图切换)→ React Router需跨页保持的状态放 Provider 或 localStorage - NavBar 导航项点击 → `emit(OPEN_PAGE, { target, modalType })` → PageDispatcher 按 `PAGE_MAP` 注册表渲染对应容器
- 每个页面路径在 `PageDispatcher.PAGE_MAP` 中注册一行target → { component, modalType, width, height }
- 需跨页保持的状态放 Provider 或 localStorage
- 辅助窗口(设置、预览、导入导出等)以 Modal/Drawer 叠加在主窗口之上,不与主窗口共享 DOM但保持在同一个 React 组件树中以确保状态一致性。 - 辅助窗口(设置、预览、导入导出等)以 Modal/Drawer 叠加在主窗口之上,不与主窗口共享 DOM但保持在同一个 React 组件树中以确保状态一致性。
# 目录结构 # 目录结构
> 2026-06-29 重构:业务模块从 `pages/` + `services/modules/` 分散模式统一为 `modules/` 下的
> VCHSM 五层架构Views → Controllers → Hooks → Settings → Model
```md ```md
├── electron/ # Electron 主进程 ├── electron/ # Electron 主进程 + 预加载
│ ├── main.ts # 入口:窗口创建 / IPC 注册 / 生命周期 │ ├── main.ts # 入口编排:窗口创建 / IPC 注册 / 生命周期
│ ├── preload.ts # 预加载脚本contextBridge │ ├── preload.ts # contextBridge向渲染进程暴露安全 API薄入口
── main/ ── electron-env.d.ts # 类型声明APP_ROOT、Window 扩展)
── updater.ts # 自定义更新API 版本检查 + OSS 下载 + NSIS 安装) ── main/ # 主进程核心模块
│ ├── logger.ts # 主进程日志核心JSON Lines 写入 / 轮转 / 清理) │ ├── load-env.ts # .env.{mode} + .env.local 加载器
│ ├── log-ipc.ts # 渲染进程日志 IPC 通道接收 │ ├── logger.ts # 日志核心JSON Lines / 每日轮转 / 批量冲刷
│ ├── net-request.ts # Electron net.request 的 axios 风格封装 │ ├── net-request.ts # Electron net.request 的 axios 风格封装
│ ├── menu/index.ts # 系统菜单 │ ├── updater/ # 自动更新模块
└── utils/ # 平台判断 / 图标路径 │ │ ├── provider.ts # 自定义 Provider + 设备指纹 + 构建配置
├── src/ # 渲染进程React 19 SPA │ │ │ ├── platform-updaters.ts # 平台特定 UpdaterWindows/macOS/Linux
├── main.tsx # React 挂载点 + 全局错误捕获 │ │ └── index.ts # 初始化 + IPC 注册
│ ├── App.tsx # 根组件(路由 + 登录弹窗 │ ├── menu/index.ts # 系统菜单macOS Edit / Win 无菜单
│ ├── components/ # 通用 UI 组件 + Provider │ ├── ipc/ # IPC handler 集合
│ │ ├── AppProvider.tsx # 全局状态platform / edition / login │ │ ├── log-ipc.ts # 渲染进程日志 → 主进程日志桥接
│ │ ├── ThemeProvider.tsx # 主题状态light / dark │ │ ├── safe-storage-ipc.ts # safeStorage 加密/解密 IPC handler
│ │ ├── SettingsProvider.tsx # 设置状态(存储路径等 │ │ ├── storage-ipc.ts # 沙箱文件存储 IPC handlerheixiu-data/
│ │ ├── Layout.tsx # 页面壳NavBar + Outlet │ │ ├── utils/ # 存储通用工具
│ │ └── navbar/ # 导航栏 + 公告抽屉 │ │ │ │ ├── path.ts # 路径安全解析resolveSafe / ensureDir
├── contexts/ # Context 定义app / settings / theme │ │ │ ├── download.ts # Electron net 文件下载(绕过 CORS
├── hooks/ # 自定义 HooksuseUpdater / useTheme │ │ │ └── thumbnail.ts # sharp 缩略图生成(内存优化
├── pages/ # 页面组件 │ │ └── canvas/ # 画布文件操作模块
│ │ ├── home/ # 首页仪表盘 │ │ │ ├── types.ts # 类型定义StageType / ScanEntry / ThumbnailResult
│ │ ├── login/ # 登录 / 注册页 │ │ │ ├── walk.ts # 递归目录遍历
│ │ └── settings/ # 设置面板(主题 / 存储 / 账号安全 / 系统信息 / 更新) │ │ │ ├── sidecar.ts # Sidecar JSON 元数据读写
├── router/index.tsx # HashRouter 路由配置 │ │ ├── mime.ts # MIME 类型推断
├── services/ # HTTP 请求封装 │ │ ├── thumbnail.ts # 画布缩略图生成(缓存优先)
│ │ ├── request.ts # Axios 封装 + 拦截器 + Token 刷新 │ │ │ ├── scan-roots.ts # 扫描根目录计算 + 阶段过滤
│ │ ├── types.ts # 自定义错误类型RequestError / RefreshError │ │ │ └── index.ts # IPC handler 注册
│ │ ── auth-token.ts # refresh_token 管理 │ │ ── utils/ # 平台判断 / 图标路径
│ └── modules/ # API 模块auth / announcement │ └── preload/ # 预加载脚本contextBridge 桥接层
│ ├── theme/ # 主题系统 ├── base.ts # ipcRenderer 基础桥接on/off/send/invoke
├── tokens.ts # 设计令牌 ├── safe-storage.ts # safeStorage 加密/解密 API
│ └── globals.css # Tailwind + 全局样式 ├── file-storage.ts # 文件存储 APIgetDataDir / readFile / writeFile
└── utils/ # 纯函数工具 ├── canvas.ts # 画布 APIopenWindow / scanProject / readSidecar
│ ├── event-bus.ts # 事件总线on / off / emit + 日志钩子 ├── app-runtime.ts # 运行时 APIsetWindowEdition
│ ├── logger.ts # 渲染进程日志器IPC 转发 / DEV 控制台 ├── index.ts # 汇总导出exposeBaseIpc / exposeSafeStorage / ...
│ ├── ipc.ts # 安全 IPC 守卫safeIpcOn / send / invoke ├── device.ts # 硬件指纹采集
├── platform.ts # 平台判定 └── device_service.ts # 设备信息缓存层
└── device.ts # 设备 ID 生成
├── shared/ # 主进程 & 渲染进程共享 ├── src/ # 渲染进程React 19 + Vite + TypeScript
├── types/ # 类型定义API / IPC / update / logging
│ ├── constants/ # 常量IPC 通道 / version / app │ ├── app/ # ═══ 应用壳 ═══
└── utils/ # 共享工具 │ ├── App.tsx # 根组件:叠加层编排(登录/设置/页面调度)
│ ├── index.ts # debounce / throttle / compareVersions │ ├── main.tsx # React 挂载 + Provider 嵌套 + 全局错误捕获
└── sanitize.ts # 日志脱敏工具ID / 邮箱 / 手机 / 名称) │ ├── providers/ # 全局 Context Provider
├── scripts/ # 构建脚本 │ │ │ ├── AppProvider.tsx # 认证状态login/logout/Token 刷新)
│ ├── build.mjs # 构建总管 │ │ │ ├── ThemeProvider.tsx # 亮/暗主题切换 + antd ConfigProvider
├── clean.mjs # 清理产物 │ │ └── SettingsProvider.tsx # 本地持久化设置(存储路径等)
├── upload-oss.mjs # OSS 上传 │ └── layout/ # 布局框架
├── publish-release.mjs # 发布到 API │ ├── Layout.tsx # 全高 flex 列布局壳NavBar + 内容区)
└── generate-update-info.mjs # 生成更新元信息 │ └── PageDispatcher.tsx # 通用页面调度器Modal/Drawer/FloatingPanel
│ │
│ ├── modules/ # ═══ 业务模块VCHSM 五层架构)═══
│ │ │ # V=views C=controllers H=hooks S=settings M=model
│ │ ├── navbar/ V+C+S+M # 导航栏 UI 壳
│ │ │ ├── views/ # NavBar.tsx / NavItem.tsx
│ │ │ ├── settings/ # nav-config.ts左右导航项注册表
│ │ │ └── model/ # 导航类型定义
│ │ ├── announcement/ V+C # 公告Badge 角标 + 15 分钟轮询缓存)
│ │ │ ├── views/ # AnnouncementDrawer.tsx
│ │ │ └── controllers/ # announcement-api.ts
│ │ ├── auth/ V+C+H+S+M # 认证模块14 文件)
│ │ │ ├── views/ # AuthPage.tsx + components/LoginForm/RegisterForm/改密/忘记密码)
│ │ │ ├── controllers/ # auth-api.ts / token-manager.ts / auth-persistence.ts
│ │ │ ├── hooks/ # use-auth-state.ts / use-sms-cooldown.ts
│ │ │ ├── settings/ # login-fields.ts / register-fields.ts / validators.ts
│ │ │ └── model/ # 类型定义
│ │ ├── home/ V+C+H+S+M # 主工作区(三栏布局 → VCHSM 嵌套)
│ │ │ ├── views/
│ │ │ │ ├── HomePage.tsx / HomeContent.tsx
│ │ │ │ ├── left/ # 左侧栏 — 输入控件LeftPanel + controls/
│ │ │ │ ├── center/ # 中央栏 — 预览区CenterPanel
│ │ │ │ └── right/ # 右侧栏 — 模型/任务/输出/Banner
│ │ │ ├── controllers/ # task-api.ts / model-api.ts / banner-api.ts / menus/
│ │ │ ├── hooks/ # useModelUI.ts
│ │ │ ├── stores/ # HomeContext.tsx页面级状态
│ │ │ └── model/ # 类型定义
│ │ ├── account/ V+C # 账户与支付6 子功能独立子目录)
│ │ │ ├── views/ # info/ vip/ recharge/ billing/ orders/ wechat-work/
│ │ │ └── controllers/ # vip-api.ts / payment-api.ts / billing-api.ts
│ │ ├── canvas/ V+C+H+M+S # 无限画布 + 项目管理
│ │ │ ├── views/ # CanvasApp.tsx / CanvasPage.tsx
│ │ │ │ ├── core/ # CanvasViewport / LODImage / MediaCard / MediaPreview
│ │ │ │ ├── navigation/ # CanvasSidebar / ProjectSelector / EpisodeSelector / ShotList
│ │ │ │ └── toolbar/ # CanvasToolbar / CanvasFilterBar / CategoryTabs / StageTabs
│ │ │ ├── controllers/ # menus/(右键菜单注册)
│ │ │ ├── hooks/ # useCanvasInteraction / useDirectoryScan / useSidecarSync
│ │ │ ├── stores/ # useCanvasStore.tsZustand store
│ │ │ └── utils/ # scanner.ts / layout.ts / LocalFileDataSource.ts
│ │ └── settings/ V+C # 应用设置
│ │ ├── views/ # SettingsPage.tsx + sections/6 个设置分区)
│ │ └── controllers/ # settings-actions.ts
│ │
│ └── shared/ # ═══ 跨模块共享 ═══
│ ├── components/ # 通用 UIFloatingPanel / FormField / LegalTextModal / ContextMenu
│ ├── hooks/ # 全局 HooksuseAnnouncements / useApi / useAsync / useUpdater
│ ├── services/ # HTTP 客户端 + API 模块
│ │ ├── request/ # Axios 请求封装
│ │ │ ├── token.ts # Token 管理(加密存储 + 内存缓存)
│ │ │ ├── interceptors.ts # 请求/响应拦截器401 刷新 + 错误处理)
│ │ │ ├── methods.ts # 请求方法get/post/put/del/upload/download
│ │ │ └── index.ts # 主入口 + 导出
│ │ └── modules/ # API 模块auth / home / account / announcement
│ ├── utils/ # 工具函数
│ │ ├── bus.ts # 事件总线emit / on / off / EVENTS
│ │ ├── pricing/ # 定价计算模块
│ │ │ ├── types.ts # 类型定义ModelPricingConfig / ModelDefinition
│ │ │ ├── helpers.ts # 内部工具asFloat / matchDimensions
│ │ │ ├── format.ts # 金额格式化formatYuan / formatCent
│ │ │ ├── calculate.ts # 计价逻辑6 种定价模式)
│ │ │ ├── fields.ts # 依赖字段收集
│ │ │ └── index.ts # 主入口 + 导出
│ │ ├── enum-resolver.ts # 枚举值解析
│ │ ├── display.ts # 显示工具
│ │ └── safe-storage.ts # 安全存储工具
│ ├── infrastructure/storage/ # 本地加密数据库sql.js + safeStorage
│ │ ├── core/ # db-core.ts / db-encrypt.ts / schema.ts
│ │ ├── stores/ # task-store / order-store / media-store / settings-store
│ │ └── services/ # media-loader / sync-manager / data-sync / user-scope
│ └── theme/ # 主题系统tokens / antd-theme / CSS 过渡)
├── shared/ # 主进程 & 渲染进程共享(根级 @shared 别名)
│ ├── constants/ # 应用常量
│ │ ├── app.ts # 应用名称 / 窗口标题
│ │ ├── version.ts # 版本号
│ │ ├── keyboard.ts # 快捷键映射
│ │ └── ipc/ # IPC 通道常量(模块化)
│ │ ├── base.ts # 基础通道(窗口/日志/主题/配置)
│ │ ├── storage.ts # 存储通道(文件/加密/下载/缩略图)
│ │ ├── canvas.ts # 画布通道(扫描/sidecar/拖拽)
│ │ ├── updater.ts # 更新通道(检查/下载/安装)
│ │ └── index.ts # 汇总导出 + 兼容性 BIDIRECTIONAL
│ ├── types/ # index.tsPlatform/Theme/Edition/ logging.ts
│ └── utils/ # sanitize.ts日志脱敏+ index.ts通用工具
├── scripts/ # 构建与发布脚本
├── vite.config.ts # Vite 构建配置
├── package.json # 含 electron-builder 打包配置 ├── package.json # 含 electron-builder 打包配置
── vite.config.ts ── pnpm-workspace.yaml
├── tsconfig.json
└── tailwind.config.ts
``` ```
### VCHSM 五层架构说明
| 层 | 目录 | 职责 | 示例 |
|----|------|------|------|
| **V** views | `views/` | 页面组件,纯 JSX 结构 | `NavBar.tsx`, `VipPage.tsx`, `CanvasViewport.tsx` |
| **C** controllers | `controllers/` | 交互逻辑 + API 请求 + 菜单注册 | `auth-api.ts`, `task-api.ts`, `menus/` |
| **H** hooks | `hooks/` | 组件间复用逻辑(状态/副作用/数据流) | `use-auth-state.ts`, `useCanvasInteraction.ts` |
| **S** settings | `settings/` | 配置常量、字段映射、验证规则、注册表 | `nav-config.ts`, `login-fields.ts` |
| **M** model | `model/` | 纯 TS 类型/接口/枚举(编译时) | `types.ts` |
模块按需建层navbar 只用 V+S+Mauth 用 V+C+H+S+Mcanvas 额外有 stores/ + utils/。
# 多窗口与平台判定 # 多窗口与平台判定
- windowManager.ts 负责统一创建 BrowserWindow根据环境变量/参数加载不同入口(如 src/main/index.html 或 - 窗口管理由 `electron/main.ts` 统一负责:`createMainWindow()` 创建主窗口,`BrowserWindow` 通过 `contextIsolation: true` + `nodeIntegration: false` 运行沙箱化渲染进程。辅助窗口通过 `RENDERER_TO_MAIN.OPEN_SUB_WINDOW` IPC 通道按需创建。
src/sub/settings/index.html
- src/utils/platform.ts 提供 isMacOS、isWindows 等函数(基于 navigator.userAgent 或 preload 暴露的 process.platform。在主进程的 - 平台判定双端各有一份:`electron/main/utils/platform.ts`(主进程,直接使用 `process.platform`)和 `src/shared/utils/env.ts`(渲染进程,优先 `process.platform`,降级 `navigator.userAgent`)。
electron/main/utils/platform.ts 中直接使用 Node.js API。
- 打包配置 (package.json build 字段) 须针对平台设置不同的图标、签名策略、安装包格式。 - 打包配置 (`package.json` build 字段) 须针对平台设置不同的图标、签名策略、安装包格式。
# 代码风格 # 代码风格
@@ -151,16 +250,18 @@
## 状态管理 ## 状态管理
- 使用 React Context + Provider 模式管理全局状态按领域拆分AppContext平台/登录、ThemeContext主题 - 使用 React Context + Provider 模式管理全局状态按领域拆分AppContext平台/登录、ThemeContext主题
SettingsContext设置项。避免引入 Redux 等重量级方案。 SettingsContext设置项各 Context 定义与 Provider 合并在 `app/providers/` 目录下,避免引入 Redux 等重量级方案。
- Provider 组件在 main.tsx 中按层级嵌套挂载: - Provider 在 main.tsx 中按层级嵌套挂载:
AppProvider → ThemeProvider → SettingsProvider → AntdApp → App AppProvider → ThemeProvider → SettingsProvider → AntdApp → App
- 各模块通过自定义 Hook 消费useAppContext / useTheme / useSettingsHook 内部做 null 检查。 - 各模块通过自定义 Hook 消费useAppContext / useTheme / useSettingsHook 内部做 null 检查并抛出明确错误信息
- 页面级状态(如首页的 selectedModel / selectedTask / 分页)放在 HomeContext 中,由 HomeContent 提供,遵循与全局 Provider 相同的 Context + Provider + Hook 模式。
## 日志系统 ## 日志系统
- 双端日志主进程直接写文件electron/main/logger.ts渲染进程通过 IPC send 上报src/utils/logger.ts - 双端日志主进程直接写文件electron/main/logger.ts渲染进程通过 IPC send 上报src/shared/utils/bus.ts
两端暴露一致的 API`logger.debug / info / warn / error(category, message, error?, context?)` 两端暴露一致的 API`logger.debug / info / warn / error(category, message, error?, context?)`
- 文件存储JSON Lines 格式(每行一个 JSON路径 `{userData}/logs/app-YYYY-MM-DD.log` - 文件存储JSON Lines 格式(每行一个 JSON路径 `{userData}/logs/app-YYYY-MM-DD.log`
@@ -186,9 +287,9 @@
- Linux → libsecret - Linux → libsecret
- 实现文件: - 实现文件:
- `electron/main/safe-storage-ipc.ts` — 主进程加密/解密 IPC 处理(`BIDIRECTIONAL.SAFESTORAGE_ENCRYPT / DECRYPT` - `electron/main/safe-storage-ipc.ts` — 主进程加密/解密 IPC 处理(`BIDIRECTIONAL.SAFESTORAGE_ENCRYPT / DECRYPT`
- `src/utils/safe-storage.ts` — 渲染进程桥接层(`initSafeStore / getSafeStore / setSafeStore / clearSafeStore` - `src/shared/utils/safe-storage.ts` — 渲染进程桥接层(`initSafeStore / getSafeStore / setSafeStore / clearSafeStore`
- `src/services/request.ts` — token 存取通过 `getSafeStore / setSafeStore`,无需感知加密细节 - `src/shared/services/http.ts` — token 存取通过 `getSafeStore / setSafeStore`,无需感知加密细节
- `src/services/auth-token.ts` — refresh_token 同理通过 safeStorage 存取 - `src/modules/auth/controllers/token-manager.ts` — refresh_token 同理通过 safeStorage 存取
## 双 Token 机制 ## 双 Token 机制
@@ -204,16 +305,15 @@
- `shared/utils/sanitize.ts` 在日志写入前自动脱敏处理 - `shared/utils/sanitize.ts` 在日志写入前自动脱敏处理
- 覆盖字段:用户 IDSHA256 哈希)、邮箱(`***@domain`、手机号掩码、用户名称、Token截断 - 覆盖字段:用户 IDSHA256 哈希)、邮箱(`***@domain`、手机号掩码、用户名称、Token截断
- 主进程 `electron/main/logger.ts` 和渲染进程 `src/utils/logger.ts` 均写入前调用 `sanitizeLogEntry()` - 主进程 `electron/main/logger.ts` 和渲染进程 `src/shared/utils/bus.ts` 均写入前调用 `sanitizeLogEntry()`
# 样式与主题 # 样式与主题
- 优先使用 Tailwind 工具类 完成布局与常规样式自定义设计令牌应映射为 Tailwind 扩展(在 tailwind.config.ts 中配置 - 优先使用 Tailwind 工具类完成布局与常规样式自定义设计令牌`src/shared/theme/tokens.css``@theme` 块中定义Tailwind v4 语法),自动生成 `bg-primary-500``text-secondary` 等工具类。
theme.extend
- Ant Design 组件样式通过 ConfigProvider 的 theme 属性覆盖,确保与 Tailwind 主题令牌同步。 - Ant Design 组件样式通过 ConfigProvider 的 theme 属性覆盖,确保与 Tailwind 主题令牌同步。
`ConfigProvider` 同时配置 `locale={zhCN}` 实现全局中文(分页器"条/页"等无需逐个组件设置)。 `ConfigProvider` 同时配置 `locale={zhCN}` 实现全局中文(分页器"条/页"等无需逐个组件设置)。
在 src/theme/ 中创建 antd-theme.ts导出主题配置对象引用 tokens.ts 中的设计变量。 在 src/shared/theme/ 中创建 antd-theme.ts导出主题配置对象引用 tokens.ts 中的设计变量。
组件级 token如 Pagination.itemActiveColor / itemActiveBg在 sharedComponents 中统一管理, 组件级 token如 Pagination.itemActiveColor / itemActiveBg在 sharedComponents 中统一管理,
亮色/暗色主题共享,避免 CSS !important hack。 亮色/暗色主题共享,避免 CSS !important hack。
@@ -226,7 +326,7 @@
- 所有 IPC 通道名称定义在 shared/constants/ipc-channels.ts 中,分为三类: - 所有 IPC 通道名称定义在 shared/constants/ipc-channels.ts 中,分为三类:
MAIN_TO_RENDERER / RENDERER_TO_MAIN / BIDIRECTIONAL。 MAIN_TO_RENDERER / RENDERER_TO_MAIN / BIDIRECTIONAL。
- 渲染进程通过 src/utils/ipc.ts 中的安全守卫访问 IPChasIpc 检查 → safeIpcOn / send / invoke - 渲染进程通过 src/shared/utils/bus.ts 中的安全守卫访问 IPChasIpc 检查 → safeIpcOn / send / invoke
非 Electron 环境下静默跳过,保证浏览器预览不崩溃。 非 Electron 环境下静默跳过,保证浏览器预览不崩溃。
- 主进程事件处理函数需做好错误捕获与日志记录,渲染进程调用 IPC 时使用 try-catch 并处理超时。 - 主进程事件处理函数需做好错误捕获与日志记录,渲染进程调用 IPC 时使用 try-catch 并处理超时。
@@ -267,8 +367,8 @@
| `checkForUpdates` | `electron/main/updater.ts` | 预检版本 → 无更新直发 IPC / 有更新交 electron-updater | | `checkForUpdates` | `electron/main/updater.ts` | 预检版本 → 无更新直发 IPC / 有更新交 electron-updater |
| `downloadUpdate` | `electron/main/updater.ts` | 用户确认后触发下载(从 checkForUpdates 分离) | | `downloadUpdate` | `electron/main/updater.ts` | 用户确认后触发下载(从 checkForUpdates 分离) |
| `netRequest` | `electron/main/net-request.ts` | Electron net.request 的 axios 风格封装 | | `netRequest` | `electron/main/net-request.ts` | Electron net.request 的 axios 风格封装 |
| `useUpdater` | `src/hooks/use-updater.ts` | 渲染进程更新状态 Hook单例 IPC 监听) | | `useUpdater` | `src/shared/hooks/use-updater.ts` | 渲染进程更新状态 Hook单例 IPC 监听) |
| `UpdateSetting` | `src/pages/settings/blocks/UpdateSetting.tsx` | 设置页更新区块(含"查看详情"弹窗) | | `UpdateSetting` | `src/modules/settings/views/sections/UpdateSetting.tsx` | 设置页更新区块(含"查看详情"弹窗) |
| `server.py` | `test-server/server.py` | 本地测试服务器,扫描 release/ 目录,语义版本排序 | | `server.py` | `test-server/server.py` | 本地测试服务器,扫描 release/ 目录,语义版本排序 |
## 增量更新(差分下载) ## 增量更新(差分下载)
@@ -329,4 +429,4 @@ set UPDATE_API_URL=http://127.0.0.1:8000 && .\release\0.0.3\win-unpacked\船长
- 新增窗口时,需同步说明窗口入口、预加载脚本、主进程创建逻辑及 IPC 通道注册。 - 新增窗口时,需同步说明窗口入口、预加载脚本、主进程创建逻辑及 IPC 通道注册。
- 修改主题令牌时,需同步更新 tokens.ts、tailwind.config.ts 及 antd-theme.ts确保三处一致。 - 修改主题令牌时,需同步更新 tokens.ts、tokens.css 及 antd-theme.ts确保三处一致。

136
README.md
View File

@@ -37,52 +37,60 @@ npm run build:win
### 打包 ### 打包
```bash ```bash
npm run build:win # Windows 个人版 npm run build:win # Windows 个人版 Stable
npm run build:win:enterprise # Windows 企业版 npm run build:win:enterprise # Windows 企业版
npm run build:win:beta # Windows Beta 渠道(灰度测试)
npm run build:win:alpha # Windows Alpha 渠道(内部测试)
npm run build:mac # macOS 个人版 npm run build:mac # macOS 个人版
npm run build:linux # Linux 个人版 npm run build:linux # Linux 个人版
``` ```
### 构建总管 `build.mjs` ### 构建总管 `build/build.mjs`
所有打包命令最终调用 `build.mjs`,支持灵活组合操作: 所有打包命令最终调用 `build/build.mjs`,支持灵活组合操作:
```bash ```bash
# 查看帮助 # 查看帮助
node build.mjs --help node build/build.mjs --help
# 仅打包 # 仅打包
node build.mjs --win --personal node build/build.mjs --win
# 打包 + 上传 OSS + 发布到 API # 打包 + 上传 OSS + 发布到 API
node build.mjs --win --personal --upload --publish node build/build.mjs --win --upload --publish
# 全流程(清理 → 构建 → 签名 → 上传 → 发布) # Beta 渠道全流程(清理 → 构建 → 签名 → 上传 → 发布)
node build.mjs --win --enterprise --all node build/build.mjs --win --channel beta --all
# 带签名打包 # 带签名打包
node build.mjs --win --personal --sign --cert ./cert.pfx --cert-password xxx node build/build.mjs --win --sign --cert ./cert.pfx --cert-password xxx
# 先清理再构建
node build.mjs --win --personal --clean --build
``` ```
| 操作 | 说明 | | 操作 | 说明 |
|-------------|---------------------------------------------------------| |-------------|------------------------------------------------------------|
| `--build` | 构建(默认:tsc + vite + electron-builder + update-info.json | | `--build` | 构建(校验 → tsc vite electron-builder update-info.json |
| `--clean` | 构建前清理 release/ dist/ dist-electron/ | | `--clean` | 构建前清理 release/ dist/ dist-electron/ |
| `--sign` | 代码签名(需配合 `--cert``CSC_LINK` 环境变量) | | `--sign` | 代码签名(需配合 `--cert``CSC_LINK` 环境变量) |
| `--upload` | 上传安装包到 OSS | | `--upload` | 上传安装包到 OSS |
| `--publish` | POST 版本信息到后端 API | | `--publish` | POST 版本信息到后端 API |
| `--all` | 全流程 | | `--all` | 全流程 |
| `--edition` | 版本类型personal默认/ enterprise |
| `--channel` | 发布渠道stable默认/ beta / alpha |
### 版本管理
```bash
npm run version:patch # 0.0.28 → 0.0.29
npm run version:pre-beta # 0.0.29 → 0.0.30-beta.1
```
### 工具命令 ### 工具命令
```bash ```bash
npm run build:clean # 清理所有构建产物 npm run build:clean # 清理所有构建产物
node scripts/upload-oss.mjs win32 personal 0.1.0 # 手动上传 OSS node build/scripts/upload-oss.mjs win32 0.1.0 # 手动上传 OSS
node scripts/publish-release.mjs win32 personal 0.1.0 # 手动发布到 API node build/scripts/publish-release.mjs win32 0.1.0 # 手动发布到 API
node scripts/generate-update-info.mjs win32 personal 0.1.0 # 重新生成 info node build/scripts/generate-update-info.mjs win32 0.1.0 # 重新生成 info
``` ```
--- ---
@@ -91,52 +99,58 @@ node scripts/generate-update-info.mjs win32 personal 0.1.0 # 重新生成 info
``` ```
ele-heixiu/ ele-heixiu/
├ build.mjs # 构建总管 ── build/ # 构建、打包与分发系统
├ CHANGELOG.md # 更新日志 │ ├── build.mjs # 构建总管
├ UpdateA.md # 发布手册(后端 API / 签名 / 发版流程) │ ├── electron-builder.js # electron-builder 配置
├ package.json # 项目配置 │ └── scripts/ # 构建辅助脚本6 个)
electron-builder.json5 # 打包配置 ── CHANGELOG.md # 更新日志
electron/ # Electron 主进程 ── UpdateA.md # 发布手册
│ ├ main.ts # 入口 ├── package.json # 项目配置
├ preload.ts # 预加载脚本
│ └ main/ ├── electron/ # Electron 主进程
├ updater.ts # 自动更新模块 ├── main.ts # 入口:窗口创建 / IPC 注册
├ menu/ # 菜单 ├── preload.ts # 预加载脚本
└ utils/ # 工具函数 └── main/
├ src/ # 渲染进程 ├── logger.ts # 日志核心
├ App.tsx # 根组件 ├── updater.ts # 自动更新
├ main.tsx # 渲染入口 ├── net-request.ts # HTTP 请求封装
├ components/ # 组件 ├── menu/ # 系统菜单
│ ├ navbar/ # 自定义导航栏 ├── ipc/ # IPC handlerlog/safe-storage/storage/canvas
│ └ LegalTextModal.tsx # 协议弹窗 └── utils/ # 平台判断 / 图标
├ pages/
└ login/ # 登录 / 注册 ├── src/ # 渲染进程React 19 + Vite
├ hooks/ # Hooks ├── app/ # 应用壳
│ ├ use-theme.tsx # 主题切换 │ ├── App.tsx # 根组件
│ ├ use-updater.ts # 更新状态 │ ├── main.tsx # 渲染入口
│ │ └ ... ├── providers/ # 全局 ProviderApp/Theme/Settings
├ theme/ # 主题配置 │ └── layout/ # Layout.tsx + PageDispatcher.tsx
│ ├ tokens.ts # 设计令牌 ├── modules/ # 7 个业务模块VCHSM 五层架构)
│ ├ globals.css # Tailwind + CSS 变量 │ ├── navbar/ # 导航栏 UI 壳
│ │ └ adapters/ # Ant Design 适配 ├── announcement/ # 公告Badge + 轮询缓存)
├ services/ # API 服务axios │ ├── auth/ # 认证(登录/注册/密码/Token
├ utils/ # 工具函数 │ ├── home/ # 主工作区(左/中/右 三栏)
└ assets/ # 静态资源 │ ├── account/ # 账户与支付6 子功能)
├ shared/ # 主进程 & 渲染进程共享 │ │ ├── canvas/ # 无限画布 + 项目管理
├ types/ # 类型定义 │ └── settings/ # 应用设置
├ constants/ # 常量 └── shared/ # 跨模块共享
└ utils/ # 通用工具 ├── components/ # 通用 UI
├ scripts/ # 构建辅助脚本 │ ├── hooks/ # 全局 Hooks
├ clean.mjs # 清理产物 ├── services/ # HTTP 客户端
├ generate-update-info.mjs # 生成 API 信息 JSON ├── utils/ # 工具函数bus/env/...
├ upload-oss.mjs # OSS 上传 ├── infrastructure/ # 本地加密数据库
└ publish-release.mjs # 发布到 API └── theme/ # 主题系统
└ release/ # 构建产物
└ {version}/ ├── shared/ # 主进程 & 渲染进程共享(@shared 别名)
├ xxx-Setup.exe │ ├── constants/ # IPC 通道 / 应用常量
└ xxx-update-info.json ├── types/ # Platform / Theme / Logging
│ └── utils/ # 脱敏 / 工具函数
├── release/ # 构建产物
└── build/ # 构建系统(见上)
``` ```
> 详见 [PROJECT.md](./PROJECT.md) — 完整架构文档、VCHSM 五层说明及各模块文件清单。
--- ---
## 环境变量 ## 环境变量

63
TODO.md
View File

@@ -1,6 +1,22 @@
# TODO — 船长·HeiXiu 待办与优化清单 # TODO — 船长·HeiXiu 待办与优化清单
> 最后更新2026-06-09 > 最后更新2026-06-29
---
## ✅ 近期完成
- **VCHSM 五层模块化架构重构**`pages/` + `services/modules/` 分散模式 → `modules/` 统一7 个模块,每个独立 views/controllers/hooks/settings/model
- **公告 Badge 角标 + 轮询缓存**antd Badge + useAnnouncements Hook15 分钟轮询 + localStorage 已读缓存)
- **日志全量审计**~60+ 处 try/catch 补充 logger.warn3 个 IPC handler try/catch 包裹
- **存储路径可配置**`setLogDirResolver()` / `setDataDirResolver()` 函数注入
- **构建时 EDITION 变量已移除**:版控全部收敛到后端 `account_type` 运行时判断,不再区分个人版/企业版构建产物
- **窗口标题动态版控**:登录后通过 IPC 追加 `·个人版`/`·企业版` 后缀
- **退出登录不再自动清空本地缓存**`user_id` 隔离 + 手动清理按钮
- **无限画布(企业版项目管理)**:独立 BrowserWindow 画布窗口,项目/集数/镜头导航、阶段切换、网格/单行布局、滚轮缩放、拖拽平移、全屏灯箱预览、原生文件拖拽
- **无限画布 UI 重构**AntD Layout 双栏布局Sider+Content、CanvasSidebar 导航、CategoryTabs 双模式分类、Ctrl/Cmd+拖拽平移
- **预览键盘控制**Space 暂停/播放、← → 快退快进 ±0.5s、↑ ↓ 音量透传原生 controls
- **local-media:// Range 请求支持**:协议头转发修复音视频 seek 失败问题
--- ---
@@ -15,9 +31,21 @@
### 路由与页面 ### 路由与页面
- [x] 账户信息页面(`/account``AccountInfo.tsx`AuthAPI.getUserInfo() 驱动 - [x] 账户信息页面(`/account``AccountInfo.tsx`AuthAPI.getUserInfo() 驱动
- [x] 充值页面(`/recharge``RechargePage.tsx`,创建订单 → 支付状态跟踪
- [x] 订单明细页面(`/orders``OrdersPage.tsx`,状态筛选 + 分页 + 继续支付 + 轮询
- [ ] 合规素材库页面(`/compliance-assets` - [ ] 合规素材库页面(`/compliance-assets`
- [ ] 项目管理页面(`/projects`企业版) - [x] 项目管理页面(企业版,独立画布窗口 `#/canvas`
- [ ] 会员/充值/消费记录/订单明细页面 - [ ] 会员页面`/vip`
- [ ] 消费记录页面
- [x] 本地数据管理(加载历史/保存到磁盘/清理缓存) — `DataManageSetting` + sql.js 存储
### 媒体与文件
- [ ] 媒体文件本地下载+缓存(主进程 HTTP 无 CORS 限制,替代渲染进程 axios
- [ ] CDN 签名过期检测(主进程发 HEAD 请求读 `SignatureExpired` 响应体)
- [ ] 列表页"下一页"懒加载集成TaskHistory / OrdersPage 调用 `fetchAndSync*`
- [ ] 参数回填 UI选择模型时显示历史参数下拉
- [ ] 右键"在文件夹中查看"功能(媒体预览右键菜单)
### 导航栏 ### 导航栏
@@ -26,9 +54,16 @@
### 公告 ### 公告
- [ ] 公告已读/未读状态 - [x] 公告数据获取与展示AnnouncementDrawer + fetchAnnouncements API
- [ ] 公告红点/角标提醒(有新公告时导航栏图标变化 - [x] 公告已读/未读状态localStorage 缓存已读 ID Set
- [ ] 公告缓存(减少重复请求 - [x] 公告 Badge 角标提醒antd Badge + NavItem badgeCount prop
- [x] 公告缓存 + 轮询useAnnouncements Hook15 分钟 setInterval 对比)
### 任务
- [x] 任务状态轮询(批量轮询 + Context 桥接 + 自适应间隔)
- [ ] 任务列表导出 CSV 功能完善
- [ ] 任务重试/重新提交
--- ---
@@ -52,6 +87,7 @@
### 文档 ### 文档
- [x] 文件夹 README 文档37 个文件夹全部覆盖,不进 Git
- [ ] API 接口文档Swagger / 手动维护) - [ ] API 接口文档Swagger / 手动维护)
- [ ] 组件文档Storybook - [ ] 组件文档Storybook
- [ ] 用户操作手册 - [ ] 用户操作手册
@@ -61,7 +97,7 @@
## ⚠️ 已知技术债务 ## ⚠️ 已知技术债务
| 问题 | 说明 | 影响 | | 问题 | 说明 | 影响 |
|-----------------|----------------------------------------------------------------------------------------------------------------------|----------------| |-----------------|---------------------------------------|----------------|
| localStorage 依赖 | 所有持久化数据依赖 localStorage | 清理浏览器数据会丢失所有配置 | | localStorage 依赖 | 所有持久化数据依赖 localStorage | 清理浏览器数据会丢失所有配置 |
| IPC 监听器 | useUpdater 已改为单例,其他 IPC hook 可能也有类似问题 | 长期运行可能内存泄漏 | | IPC 监听器 | useUpdater 已改为单例,其他 IPC hook 可能也有类似问题 | 长期运行可能内存泄漏 |
| `test.py` | 仓库根目录残留测试文件 | 无实际作用,应清理 | | `test.py` | 仓库根目录残留测试文件 | 无实际作用,应清理 |
@@ -71,8 +107,11 @@
## 📋 下次开发计划 ## 📋 下次开发计划
1. **合规素材库页面**(功能待完善 — 按 QT 原版设计,以 Modal/Drawer 叠加而非路由跳转实现 1. **任务列表导出 CSV**`task.ts` 已定义 `exportTaskCSV` API前端未对接下载按钮
2. **各页面 nav 导航补齐**(后续页面优先使用叠加模式,遵循 QT 多窗口模型 → 见 WORKLOG 2026-06-07 2. **文件上传端点确认**`/api/v1/upload` 待后端确认,当前 useFileUpload 已完成前端集成
3. **leftWidth / rightWidth / taskPage 加 localStorage 持久化**UX 优化:刷新页面后保持用户偏好;已通过路由去路由化解决组件卸载问题,但 localStorage 持久层仍未实现 3. **各页面 nav 导航补齐**(后续页面优先使用叠加模式,遵循 QT 多窗口模型 → 见 WORKLOG 2026-06-07
4. **轮播图关闭按钮 localStorage 持久化**关闭按钮已实现待做localStorage 记录关闭时间戳,超过 N 天再显示) 4. **leftWidth / rightWidth / taskPage 加 localStorage 持久化**UX 优化:刷新页面后保持用户偏好;已通过路由去路由化解决组件卸载问题,但
5. **短信发送接入人机验证**(后期优化,需后端配合:`sendSms` 接口增加 `captcha_token` 参数 + 前端滑块/图形验证组件。当前仅靠 60s 冷却按钮 + 后端限流,桌面端 ASAR 可解包 + DevTools 网络面板 + 代理抓包均能直接暴露 API纯前端限制对脚本攻击无效 localStorage 持久层仍未实现)
5. **轮播图关闭按钮 localStorage 持久化**关闭按钮已实现待做localStorage 记录关闭时间戳,超过 N 天再显示)
6. **短信发送接入人机验证**(后期优化,需后端配合:`sendSms` 接口增加 `captcha_token` 参数 + 前端滑块/图形验证组件。当前仅靠
60s 冷却按钮 + 后端限流,桌面端 ASAR 可解包 + DevTools 网络面板 + 代理抓包均能直接暴露 API纯前端限制对脚本攻击无效

View File

@@ -38,63 +38,107 @@
## 二、项目脚本速查 ## 二、项目脚本速查
### 总入口:`build.mjs` ### 总入口:`build/build.mjs`
```bash ```bash
node build.mjs --help # 查看完整帮助 node build/build.mjs --help # 查看完整帮助
npm run build:win # 打包 Windows 个人版(快捷命令) npm run build:win # 打包 Windows 个人版 Stable(快捷命令)
``` ```
### npm 命令速查 ### npm 命令速查
```bash ```bash
# 构建(仅打包,不上传/发布) # 构建(仅打包,不上传/发布)
npm run build:win # Windows 个人版 npm run build:win # Windows 个人版 Stable
npm run build:win:enterprise # Windows 企业版 npm run build:win:enterprise # Windows 企业版 Stable
npm run build:win:beta # Windows 个人版 Beta 渠道
npm run build:win:alpha # Windows 个人版 Alpha 渠道
npm run build:mac # macOS 个人版 npm run build:mac # macOS 个人版
npm run build:mac:enterprise # macOS 企业版
npm run build:linux # Linux 个人版 npm run build:linux # Linux 个人版
npm run build:linux:enterprise # Linux 企业版
# 版本管理
npm run version:patch # 0.0.28 → 0.0.29
npm run version:minor # 0.0.28 → 0.1.0
npm run version:pre-beta # 0.0.29 → 0.0.30-beta.1
npm run version:pre-alpha # 0.0.29 → 0.0.30-alpha.1
# 工具 # 工具
npm run build:clean # 清理 release/ + dist/ + dist-electron/ npm run build:clean # 清理 release/ + dist/ + dist-electron/
node build.mjs --win --personal --help # 查看完整 CLI 指南
# 独立脚本(需传参数) # 独立脚本(需传参数)
node scripts/generate-update-info.mjs <平台> <版本类型> [版本号] [--url <url>] node build/scripts/generate-update-info.mjs <平台> <版本> [渠道] [版本类型] [ossPrefix]
node scripts/upload-oss.mjs <平台> <版本类型> [版本号] node build/scripts/upload-oss.mjs <平台> <版本> [渠道] [ossPrefix]
node scripts/publish-release.mjs <平台> <版本类型> [版本号] [--url <url>] node build/scripts/publish-release.mjs <平台> <版本> [渠道] [版本类型] [ossPrefix]
``` ```
### `build.mjs` 操作组合 ### `build/build.mjs` 操作组合
| 操作 | 说明 | | 操作 | 说明 |
|-------------|------------------------------------------------------| |-------------|------------------------------------------------------------|
| `--build` | 构建tsc + vite + electron-builder + update-info.json | | `--build` | 构建(校验 → tsc vite electron-builder update-info.json |
| `--clean` | 清理旧产物 | | `--clean` | 清理旧产物 |
| `--sign` | 代码签名(配合 `--cert` 或环境变量) | | `--sign` | 代码签名(配合 `--cert` 或环境变量) |
| `--upload` | 上传 OSS | | `--upload` | 上传 OSS |
| `--publish` | POST 版本信息到后端 API | | `--publish` | POST 版本信息到后端 API |
| `--all` | 全流程 = clean + build + sign + upload + publish | | `--all` | 全流程 = clean + build + sign + upload + publish |
| `--edition` | 版本类型personal默认或 enterprise |
| `--channel` | 发布渠道stable默认、beta 或 alpha |
--- ---
## 三、发版工作流 ## 三、渠道与灰度测试
### 渠道体系
| 渠道 | OSS 路径 | 生命周期 | 适用场景 |
|------|----------|----------|----------|
| `stable` | `releases/{version}/` | 永久 | 正式发布 |
| `beta` | `releases/beta/{version}/` | 可设 OSS 30 天自动过期 | 灰度测试 |
| `alpha` | `releases/{version}/alpha/` | 手动管理 | 内部开发测试 |
### 灰度发布工作流
```bash
# 1. 创建 beta 预发布版本号
npm run version:pre-beta # 0.0.29 → 0.0.30-beta.1
# 2. 写 CHANGELOG.md 更新日志
# 3. 构建并发布到 beta 渠道
node build/build.mjs --win --channel beta --upload --publish
# 4. 后端 API 设置 rolloutPercent: 10 → 30 → 100 逐步放量
# 客户端通过 deviceFingerprint 哈希分组(同一用户始终在同一组)
```
### 灰度原理
1. 客户端 `HeiXiuProvider` 请求 `/api/v1/update/check` 时携带 `channel``edition``deviceFingerprint`
2. 服务端根据 `rolloutPercent` + `deviceFingerprint` 哈希判断是否返回新版本
3. `src/shared/utils/rollout.ts` 提供 `isInRolloutGroup()` / `getRolloutGroup()` 工具函数
---
## 四、发版工作流
### 路径 A快速打包日常开发 / 内部测试) ### 路径 A快速打包日常开发 / 内部测试)
```bash ```bash
# 1. 修改版本号 # 1. 升级版本号
# package.json → "version": "0.1.0" npm run version:patch # package.json + CHANGELOG.md 自动更新
# 2. 写更新日志 # 2. 写更新日志
# CHANGELOG.md → 顶部新增 ## 0.1.02026-xx-xx... # CHANGELOG.md → 编辑自动生成的 ## 0.1.0日期)条目
# 3. 打包 # 3. 打包
npm run build:win && npm run build:win:enterprise npm run build:win
# 4. 产物在 release/0.1.0/ 下: # 4. 产物在 release/0.1.0/ 下:
# - xxx-Setup.exe安装包 # - xxx-Setup.exe安装包
# - xxx-Setup.exe.blockmap块映射增量更新用 # - xxx-Setup.exe.blockmap块映射增量更新用
# - xxx-update-info.json所有 API 字段downloadUrl 为预判 URL # - xxx-update-info.jsonchannel、SHA512、blockmap 等全字段
# 5. 手动上传 exe + .blockmap 到 OSS releases/0.1.0/ # 5. 手动上传 exe + .blockmap 到 OSS releases/0.1.0/
# 6. 将 update-info.json 内容录入数据库 releases 表 # 6. 将 update-info.json 内容录入数据库 releases 表
@@ -107,10 +151,10 @@ npm run build:win && npm run build:win:enterprise
npm run build:win npm run build:win
# 上传到 OSS自动打印实际 URL # 上传到 OSS自动打印实际 URL
node scripts/upload-oss.mjs win32 personal 0.1.0 node build/scripts/upload-oss.mjs win32 0.1.0
# 用实际 URL 重新生成 update-info.json # 用实际 URL 重新生成 update-info.json
node scripts/generate-update-info.mjs win32 personal 0.1.0 \ node build/scripts/generate-update-info.mjs win32 0.1.0 stable personal \
--url "https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases/0.1.0/xxx.exe" --url "https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases/0.1.0/xxx.exe"
# 将 update-info.json 录入数据库 # 将 update-info.json 录入数据库
@@ -125,12 +169,12 @@ OSS_BUCKET=heixiu \
OSS_ACCESS_KEY_ID=xxx \ OSS_ACCESS_KEY_ID=xxx \
OSS_ACCESS_KEY_SECRET=xxx \ OSS_ACCESS_KEY_SECRET=xxx \
UPDATE_API_KEY=xxx \ UPDATE_API_KEY=xxx \
node build.mjs --win --personal --upload --publish node build/build.mjs --win --upload --publish
``` ```
--- ---
## 、代码签名 ## 、代码签名
### 证书获取 ### 证书获取
@@ -145,7 +189,7 @@ node build.mjs --win --personal --upload --publish
```bash ```bash
# 方式 1build.mjs 参数 # 方式 1build.mjs 参数
node build.mjs --win --personal --sign --cert ./cert.pfx --cert-password xxx node build/build.mjs --win --sign --cert ./cert.pfx --cert-password xxx
# 方式 2环境变量CI/CD 友好) # 方式 2环境变量CI/CD 友好)
CSC_LINK=/path/to/cert.pfx CSC_KEY_PASSWORD=xxx npm run build:win CSC_LINK=/path/to/cert.pfx CSC_KEY_PASSWORD=xxx npm run build:win
@@ -155,14 +199,27 @@ CSC_LINK=/path/to/cert.pfx CSC_KEY_PASSWORD=xxx npm run build:win
--- ---
## 、API 接口 ## 、API 接口
客户端调用的两个端点,完整规格见 [test-server/README.md](test-server/README.md)(可直接发给后端程序员) 客户端调用的两个端点,完整规格见 [test-server/README.md](test-server/README.md)。
| 端点 | 用途 | ### GET /api/v1/update/check
|----------------------------|-------------------------------------|
| `GET /api/v1/update/check` | 客户端版本检查,返回最新版本信息(含 blockmap 用于增量更新) | 客户端版本检查,请求参数:
| `POST /api/v1/releases` | 前端构建脚本发布新版本,需 Bearer Token 鉴权 |
| 参数 | 类型 | 说明 |
|------|------|------|
| `platform` | string | `win32` / `darwin` / `linux` |
| `version` | string | 当前客户端版本号 |
| `channel` | string | 客户端渠道:`stable` / `beta` / `alpha` |
| `edition` | string | 版本类型:`personal` / `enterprise` |
| `deviceFingerprint` | string | 设备指纹SHA-256 前 16 位,灰度分组用) |
响应字段含 `channel``rolloutPercent` 等灰度相关字段。
### POST /api/v1/releases
前端构建脚本发布新版本,需 `Authorization: Bearer <UPDATE_API_KEY>` 鉴权。请求体包含 `channel``edition` 等字段。
--- ---
@@ -239,22 +296,28 @@ cd test-server && python server.py --release-dir ../release --port 8000
``` ```
ele-heixiu/ ele-heixiu/
├ build.mjs ← 构建总管npm run build 入口 ├ build/ ← 构建系统2026-06-29 重构
│ ├ build.mjs ← 构建总管npm run build 入口)
│ ├ electron-builder.js ← electron-builder 配置(从 package.json 提取)
│ ├ README.md ← 构建系统文档
│ └ scripts/
│ ├ clean.mjs ← 清理产物
│ ├ pre-build-check.mjs ← 构建前校验Git/CHANGELOG/版本一致性)
│ ├ generate-update-info.mjs← 生成 update-info.jsonSHA512 + blockmap + channel
│ ├ upload-oss.mjs ← 上传 OSS安装包 + blockmap
│ ├ publish-release.mjs ← 发布到 APIaxios
│ └ bump-version.mjs ← 版本号管理major/minor/patch/pre
├ CHANGELOG.md ← 更新日志(打包时自动提取) ├ CHANGELOG.md ← 更新日志(打包时自动提取)
├ UpdateA.md ← 本文档 ├ UpdateA.md ← 本文档
├ package.json ← 仅保留简短的 npm 快捷命令 ├ package.json ← 不含 "build" 字段(已提取到 build/electron-builder.js
scripts/ release/ ← 构建产物目录
│ ├ clean.mjs 清理产物 │ ├ .build-meta.json最近一次构建元数据
├ generate-update-info.mjs ← 生成 API 信息 JSON含 blockmap 信息) └ 0.1.0/ ← Stable 版本
│ ├ upload-oss.mjs ← 上传 OSS安装包 + blockmap
│ └ publish-release.mjs ← 发布到 APIaxios
├ release/ ← 构建产物目录(按版本分)
│ └ 0.1.0/
│ ├ xxx-Setup.exe │ ├ xxx-Setup.exe
│ ├ xxx-Setup.exe.blockmap ← 差分更新块映射文件 │ ├ xxx-Setup.exe.blockmap ← 差分更新块映射文件
│ └ xxx-update-info.json │ └ xxx-update-info.json
├ dist/ ← Vite 前端产物(构建输入) ├ dist/ ← Vite 前端产物
└ dist-electron/ ← Electron 主进程产物(构建输入) └ dist-electron/ ← Electron 主进程产物
``` ```
--- ---

324
build.mjs
View File

@@ -1,324 +0,0 @@
// ============================================================
// build.mjs — 构建总管
//
// 用法node build.mjs [选项]
//
// 平台(必选其一):
// --win Windows
// --mac macOS
// --linux Linux
//
// 版本(可选,默认 personal
// --personal 个人版
// --enterprise 企业版
//
// 操作(可选组合,默认只执行 --build
// --build 仅构建(默认)
// --clean 构建前清理 release/ dist/ dist-electron/
// --sign 代码签名
// --upload 上传安装包到 OSS
// --publish 发布版本信息到后端 API
// --all 全流程clean + build + sign + upload + publish
//
// 签名(仅 --sign 时可用):
// --cert <path> 证书文件路径 (.pfx)
// --cert-password <p> 证书密码
//
// 示例:
// node build.mjs --win --personal # 仅打包
// node build.mjs --win --personal --upload --publish # 打包 + 上传 + 发布
// node build.mjs --win --enterprise --all # 全流程
// node build.mjs --win --personal --sign --cert ./cert.pfx --cert-password xxx
// ============================================================
import { spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = __dirname;
// ANSI 颜色工具(在其它代码之前定义,因为 printHelp() 在参数解析时就会调用)
const c = (code, text) => `\x1b[${code}m${text}\x1b[0m`;
const dim = (t) => c(2, t);
const bold = (t) => c(1, t);
const cyan = (t) => c(36, t);
const green = (t) => c(32, t);
const yellow = (t) => c(33, t);
// ---------- 参数解析 ----------
const args = process.argv.slice(2);
const opts = {
win: false,
mac: false,
linux: false,
personal: false,
enterprise: false,
clean: false,
build: false,
sign: false,
upload: false,
publish: false,
cert: '',
certPassword: '',
};
let i = 0;
while (i < args.length) {
const a = args[i];
switch (a) {
case '--win': opts.win = true; break;
case '--mac': opts.mac = true; break;
case '--linux': opts.linux = true; break;
case '--personal': opts.personal = true; break;
case '--enterprise': opts.enterprise = true; break;
case '--clean': opts.clean = true; break;
case '--build': opts.build = true; break;
case '--sign': opts.sign = true; break;
case '--upload': opts.upload = true; break;
case '--publish': opts.publish = true; break;
case '--all':
opts.clean = opts.build = opts.sign = opts.upload = opts.publish = true;
break;
case '--cert':
opts.cert = args[++i] || '';
break;
case '--cert-password':
opts.certPassword = args[++i] || '';
break;
case '--help': // fallthrough
case '-h':
printHelp();
process.exit(0);
break; // eslint 友好
default:
// 忽略未知参数
break;
}
i++;
}
// ---------- 校验 ----------
const platformCount = [opts.win, opts.mac, opts.linux].filter(Boolean).length;
if (platformCount !== 1) {
console.error('错误: 请指定且仅指定一个平台(--win / --mac / --linux');
process.exit(1);
}
const editionCount = [opts.personal, opts.enterprise].filter(Boolean).length;
if (editionCount === 0) {
opts.personal = true; // 默认个人版
} else if (editionCount > 1) {
console.error('错误: --personal 和 --enterprise 不能同时使用');
process.exit(1);
}
// 默认执行 build
if (!opts.clean && !opts.build && !opts.sign && !opts.upload && !opts.publish) {
opts.build = true;
}
const platform = opts.win ? 'win32' : opts.mac ? 'darwin' : 'linux';
const edition = opts.personal ? 'personal' : 'enterprise';
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
const version = pkg.version;
// ---------- 工具函数 ----------
function run(cmd, args, opts = {}) {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, {
cwd: ROOT,
stdio: 'inherit',
shell: true,
...opts,
env: { ...process.env, ...(opts.env || {}) },
});
child.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`${cmd} ${args.join(' ')} 退出码: ${code}`));
});
child.on('error', reject);
});
}
function runScript(name, ...args) {
const scriptPath = join(ROOT, 'scripts', `${name}.mjs`);
return run('node', [scriptPath, ...args]);
}
function step(title) {
console.log(`\n${'='.repeat(60)}`);
console.log(` ${title}`);
console.log(`${'='.repeat(60)}\n`);
}
// ---------- 主流程 ----------
async function main() {
const electronBuilderPlatform = opts.win ? '--win' : opts.mac ? '--mac' : '--linux';
const editionEnv = { EDITION: edition };
console.log('┌──────────────────────────────────────┐');
console.log(`│ HeiXiu 构建 v${version} ${platform}/${edition}`.padEnd(45) + '│');
console.log('└──────────────────────────────────────┘');
console.log(`操作: ${[
opts.clean && '清理',
opts.build && '构建',
opts.sign && '签名',
opts.upload && '上传',
opts.publish && '发布',
].filter(Boolean).join(' → ')}`);
// ── Step 1: 清理 ──
if (opts.clean) {
step('1/ 清理构建产物');
await runScript('clean');
}
// ── Step 2: 构建 ──
if (opts.build) {
step('2/ 构建应用');
// 签名环境变量electron-builder 原生支持 CSC_LINK / CSC_KEY_PASSWORD
const signEnv = {};
if (opts.sign) {
if (opts.cert) signEnv.CSC_LINK = opts.cert;
if (opts.certPassword) signEnv.CSC_KEY_PASSWORD = opts.certPassword;
console.log(opts.cert ? `签名证书: ${opts.cert}` : '签名: 使用系统证书存储');
}
// 2a. TypeScript 编译
console.log('[tsc] 类型检查...');
await run('npx', ['tsc'], { env: editionEnv });
// 2b. Vite 构建
console.log('[vite] 打包前端 + Electron...');
await run('npx', ['vite', 'build'], { env: editionEnv });
// 2c. electron-builder 打包
console.log(`[electron-builder] ${electronBuilderPlatform} 打包...`);
await run('npx', ['electron-builder', electronBuilderPlatform], {
env: { ...editionEnv, ...signEnv },
});
// 2d. 生成 update-info.json
console.log('[info] 生成 update-info.json...');
await runScript('generate-update-info', platform, edition, version);
}
// ── Step 3: 上传 OSS ──
if (opts.upload) {
step('3/ 上传 OSS');
await runScript('upload-oss', platform, edition, version);
}
// ── Step 4: 发布到 API ──
if (opts.publish) {
step('4/ 发布版本到 API');
await runScript('publish-release', platform, edition, version);
}
console.log('\n✅ 全部完成');
}
function printHelp() {
const W = 62;
const box = (title) => {
const pad = Math.max(0, W - 4 - title.length);
console.log(`\n${cyan('┌')}${cyan('─'.repeat(W - 2))}${cyan('┐')}`);
console.log(`${cyan('│')} ${bold(title)}${' '.repeat(pad)}${cyan('│')}`);
console.log(`${cyan('└')}${cyan('─'.repeat(W - 2))}${cyan('┘')}`);
};
const row = (flag, desc) => {
console.log(` ${green(flag.padEnd(22))}${dim(desc)}`);
};
const hdr = (t) => console.log(`\n ${bold(yellow(t))}`);
console.log('');
console.log(cyan(' ╔') + cyan('═══════════════════════════════════════════════════') + cyan('╗'));
console.log(cyan(' ║') + bold(' 船长·HeiXiu — 构建工具') + ' ' + cyan('║'));
console.log(cyan(' ╚') + cyan('═══════════════════════════════════════════════════') + cyan('╝'));
// 用法
box('用法');
console.log(` ${bold('node build.mjs')} ${dim('<平台> [版本] [操作...]')}`);
console.log(` ${bold('npm run build:win')} ${dim('— 等效快捷命令')}`);
// 平台
hdr('平台(必选其一)');
row('--win', 'Windows');
row('--mac', 'macOS');
row('--linux', 'Linux');
// 版本
hdr('版本(可选,默认 --personal');
row('--personal', '个人版');
row('--enterprise', '企业版');
// 操作
hdr('操作(可选组合,不指定则默认 --build');
row('--build', '构建应用tsc + vite + electron-builder + info');
row('--clean', '构建前清理 release/ dist/ dist-electron/');
row('--sign', '代码签名(需配合 --cert 或环境变量)');
row('--upload', '上传安装包到 OSS');
row('--publish', '发布版本信息到后端 API');
row('--all', '全流程 = clean + build + sign + upload + publish');
// 签名
hdr('签名参数(配合 --sign');
row('--cert <path>', '证书文件路径(.pfx / .p12');
row('--cert-password <p>', '证书密码');
console.log(` ${dim('也可通过 CSC_LINK / CSC_KEY_PASSWORD 环境变量设置')}`);
// 示例
box('示例');
console.log(` ${dim('# 日常开发')}`);
console.log(` ${bold('node build.mjs --win --personal')}`);
console.log(` ${bold('npm run build:win')} ${dim('— 等效')}`);
console.log('');
console.log(` ${dim('# 打包 + 上传 OSS + 发布到 API')}`);
console.log(` ${bold('node build.mjs --win --personal --upload --publish')}`);
console.log('');
console.log(` ${dim('# 全流程(清理 → 构建 → 签名 → 上传 → 发布)')}`);
console.log(` ${bold('node build.mjs --win --enterprise --all --cert ./cert.pfx --cert-password xxx')}`);
console.log('');
console.log(` ${dim('# 仅清理')}`);
console.log(` ${bold('npm run build:clean')}`);
// 环境变量
box('环境变量');
console.log(` ${yellow('UPDATE_API_URL')} ${dim('版本检查 API 基地址')}`);
console.log(` ${yellow('UPDATE_API_KEY')} ${dim('发布 API 鉴权 Key')}`);
console.log(` ${yellow('UPDATE_DOWNLOAD_BASE')} ${dim('OSS 下载基地址(预判 URL 用)')}`);
console.log(` ${yellow('OSS_ENDPOINT')} ${dim('阿里云 OSS endpoint')}`);
console.log(` ${yellow('OSS_BUCKET')} ${dim('阿里云 OSS bucket 名称')}`);
console.log(` ${yellow('OSS_ACCESS_KEY_ID')} ${dim('阿里云 AccessKey ID')}`);
console.log(` ${yellow('OSS_ACCESS_KEY_SECRET')} ${dim('阿里云 AccessKey Secret')}`);
console.log(` ${yellow('CSC_LINK')} ${dim('签名证书路径(与 --cert 等效)')}`);
console.log(` ${yellow('CSC_KEY_PASSWORD')} ${dim('签名证书密码(与 --cert-password 等效)')}`);
// 快捷命令
box('npm 快捷命令');
console.log(` ${bold('npm run build:win')} ${dim('→ node build.mjs --win --personal --build')}`);
console.log(` ${bold('npm run build:win:enterprise')} ${dim('→ node build.mjs --win --enterprise --build')}`);
console.log(` ${bold('npm run build:mac')} ${dim('→ node build.mjs --mac --personal --build')}`);
console.log(` ${bold('npm run build:linux')} ${dim('→ node build.mjs --linux --personal --build')}`);
console.log(` ${bold('npm run build:clean')} ${dim('→ node scripts/clean.mjs')}`);
console.log(` ${bold('npm run upload:oss')} ${dim('→ node scripts/upload-oss.mjs')}`);
console.log(` ${bold('npm run publish:release')} ${dim('→ node scripts/publish-release.mjs')}`);
console.log('');
}
// ---------- 入口 ----------
main().catch((err) => {
console.error(`\n❌ 构建失败: ${err.message}`);
process.exit(1);
});

431
build/build.mjs Normal file
View File

@@ -0,0 +1,431 @@
// ============================================================
// build.mjs — 构建总管
//
// 用法node build/build.mjs [选项]
//
// 平台(必选其一):
// --win Windows
// --mac macOS
// --linux Linux
//
// 版本 & 渠道:
// --edition <e> 版本类型personal默认| enterprise
// --channel <c> 发布渠道stable默认| beta | alpha
// --version <v> 覆盖 package.json 中的版本号
//
// 操作(可选组合,默认只执行 --build
// --build 仅构建(默认)
// --clean 构建前清理 release/ dist/ dist-electron/
// --sign 代码签名
// --upload 上传安装包到 OSS
// --publish 发布版本信息到后端 API
// --all 全流程clean + build + sign + upload + publish
//
// 签名(仅 --sign 时可用):
// --cert <path> 证书文件路径 (.pfx)
// --cert-password <p> 证书密码
//
// OSS 路径规则:
// Stable → releases/{version}/ (向后兼容,无渠道后缀)
// Beta → releases/beta/{version}/ (独立生命周期,可设自动过期策略)
// Alpha → releases/{version}/alpha/ (版本优先)
//
// 示例:
// node build/build.mjs --win
// node build/build.mjs --win --channel beta --upload --publish
// node build/build.mjs --win --edition enterprise --all
// node build/build.mjs --win --sign --cert ./cert.pfx --cert-password xxx
// ============================================================
import { spawn, execSync } from 'node:child_process';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
const SCRIPTS = join(__dirname, 'scripts');
// ANSI 颜色
const c = (code, text) => `\x1b[${code}m${text}\x1b[0m`;
const dim = (t) => c(2, t);
const bold = (t) => c(1, t);
const cyan = (t) => c(36, t);
const green = (t) => c(32, t);
const yellow = (t) => c(33, t);
// ---------- 参数解析 ----------
const args = process.argv.slice(2);
const opts = {
win: false,
mac: false,
linux: false,
clean: false,
build: false,
sign: false,
upload: false,
publish: false,
cert: '',
certPassword: '',
edition: 'personal',
channel: 'stable',
version: '',
};
let i = 0;
while (i < args.length) {
const a = args[i];
switch (a) {
case '--win': opts.win = true; break;
case '--mac': opts.mac = true; break;
case '--linux': opts.linux = true; break;
case '--clean': opts.clean = true; break;
case '--build': opts.build = true; break;
case '--sign': opts.sign = true; break;
case '--upload': opts.upload = true; break;
case '--publish': opts.publish = true; break;
case '--all':
opts.clean = opts.build = opts.sign = opts.upload = opts.publish = true;
break;
case '--cert':
opts.cert = args[++i] || '';
break;
case '--cert-password':
opts.certPassword = args[++i] || '';
break;
case '--edition':
opts.edition = args[++i] || 'personal';
break;
case '--channel':
opts.channel = args[++i] || 'stable';
break;
case '--version':
opts.version = args[++i] || '';
break;
case '--help': // fallthrough
case '-h':
printHelp();
process.exit(0);
break;
default:
break;
}
i++;
}
// ---------- 校验 ----------
const platformCount = [opts.win, opts.mac, opts.linux].filter(Boolean).length;
if (platformCount !== 1) {
console.error('错误: 请指定且仅指定一个平台(--win / --mac / --linux');
process.exit(1);
}
if (!['personal', 'enterprise'].includes(opts.edition)) {
console.error('错误: --edition 必须是 personal 或 enterprise');
process.exit(1);
}
if (!['stable', 'beta', 'alpha'].includes(opts.channel)) {
console.error('错误: --channel 必须是 stable、beta 或 alpha');
process.exit(1);
}
// 默认执行 build
if (!opts.clean && !opts.build && !opts.sign && !opts.upload && !opts.publish) {
opts.build = true;
}
const platform = opts.win ? 'win32' : opts.mac ? 'darwin' : 'linux';
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
const version = opts.version || pkg.version;
// ---------- Git 元数据 ----------
let gitCommit = '';
let gitBranch = '';
try {
gitCommit = execSync('git rev-parse --short HEAD', { cwd: ROOT, encoding: 'utf-8' }).trim();
gitBranch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: ROOT, encoding: 'utf-8' }).trim();
} catch {
// 非 git 环境 — 留空
}
const buildTimestamp = new Date().toISOString();
// ---------- OSS 路径计算 ----------
/**
* OSS 对象路径前缀:
* - Beta: releases/beta/{version}/ (独立生命周期,可设 OSS 自动过期策略)
* - Alpha: releases/{version}/alpha/ (方案 B版本优先
* - Stable: releases/{version}/ (无渠道后缀,向后兼容现有布局)
*/
function getOssPrefix() {
if (opts.channel === 'beta') return `releases/beta/${version}`;
if (opts.channel === 'alpha') return `releases/${version}/alpha`;
return `releases/${version}`;
}
// electron-builder 输出目录名(与 OSS 路径解耦,加渠道后缀防冲突)
function getReleaseDirName() {
if (opts.channel === 'stable') return version;
return `${version}-${opts.channel}`;
}
// ---------- 工具函数 ----------
function run(cmd, args, runOpts = {}) {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, {
cwd: ROOT,
stdio: 'inherit',
shell: true,
...runOpts,
env: { ...process.env, ...(runOpts.env || {}) },
});
child.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`${cmd} ${args.join(' ')} 退出码: ${code}`));
});
child.on('error', reject);
});
}
function runScript(name, ...scriptArgs) {
const scriptPath = join(SCRIPTS, `${name}.mjs`);
return run('node', [scriptPath, ...scriptArgs]);
}
function step(title) {
console.log(`\n${'='.repeat(60)}`);
console.log(` ${title}`);
console.log(`${'='.repeat(60)}\n`);
}
// ---------- 主流程 ----------
async function main() {
const electronBuilderPlatform = opts.win ? '--win' : opts.mac ? '--mac' : '--linux';
const editionTag = opts.edition === 'enterprise' ? '企业版' : '个人版';
const channelTag = opts.channel === 'stable' ? '' : ` · ${opts.channel} 渠道`;
const ossPrefix = getOssPrefix();
const releaseDirName = getReleaseDirName();
console.log('┌──────────────────────────────────────────────┐');
console.log(`│ HeiXiu 构建 v${version} ${platform} ${editionTag}`.padEnd(51) + '│');
if (channelTag) {
console.log(`${channelTag.trim()}`.padEnd(51) + '│');
}
if (gitCommit) {
console.log(`│ git: ${gitBranch}@${gitCommit}`.padEnd(51) + '│');
}
console.log('└──────────────────────────────────────────────┘');
console.log(`操作: ${[
opts.clean && '清理',
opts.build && '构建',
opts.sign && '签名',
opts.upload && '上传',
opts.publish && '发布',
].filter(Boolean).join(' → ')}`);
console.log(`本地产物: release/${releaseDirName}/`);
console.log(`OSS 路径: ${ossPrefix}/`);
// ── Step 0: 构建前校验 ──
if (opts.build) {
step('0/ 构建前校验');
try {
await runScript('pre-build-check', platform, version, opts.channel);
} catch (err) {
console.error(`\n❌ 构建前校验失败: ${err.message}`);
console.error('如需跳过校验,请使用 --skip-check 参数(或注释 build.mjs 中对应行)');
process.exit(1);
}
}
// ── Step 1: 清理 ──
if (opts.clean) {
step('1/ 清理构建产物');
await runScript('clean');
}
// ── Step 2: 构建 ──
if (opts.build) {
step('2/ 构建应用');
// 签名环境变量
const signEnv = {};
if (opts.sign) {
if (opts.cert) signEnv.CSC_LINK = opts.cert;
if (opts.certPassword) signEnv.CSC_KEY_PASSWORD = opts.certPassword;
console.log(opts.cert ? `签名证书: ${opts.cert}` : '签名: 使用系统证书存储');
}
// 写入构建元数据
const metaPath = join(ROOT, 'release', '.build-meta.json');
const metaDir = join(ROOT, 'release');
if (!existsSync(metaDir)) mkdirSync(metaDir, { recursive: true });
writeFileSync(metaPath, JSON.stringify({
version,
platform,
edition: opts.edition,
channel: opts.channel,
gitCommit,
gitBranch,
buildTimestamp,
ossPrefix,
releaseDirName,
}, null, 2), 'utf-8');
// 2a. TypeScript 编译
console.log('[tsc] 类型检查...');
await run('npx', ['tsc']);
// 2b. Vite 构建 + 注入构建元数据
console.log('[vite] 打包前端 + Electron...');
await run('npx', ['vite', 'build'], {
env: {
VITE_BUILD_VERSION: version,
VITE_BUILD_CHANNEL: opts.channel,
VITE_BUILD_EDITION: opts.edition,
VITE_BUILD_COMMIT: gitCommit,
VITE_BUILD_TIMESTAMP: buildTimestamp,
},
});
// 2c. electron-builder 打包
console.log(`[electron-builder] ${electronBuilderPlatform} 打包 (${editionTag}${channelTag})...`);
await run('npx', [
'electron-builder',
electronBuilderPlatform,
'--config', 'build/electron-builder.js',
], {
env: {
...signEnv,
HEIXIU_EDITION: opts.edition,
HEIXIU_CHANNEL: opts.channel,
HEIXIU_RELEASE_DIR_NAME: releaseDirName,
},
});
// 2d. 生成 update-info.json
console.log('[info] 生成 update-info.json...');
await runScript('generate-update-info', platform, version, opts.channel, opts.edition, ossPrefix, releaseDirName);
}
// ── Step 3: 上传 OSS ──
if (opts.upload) {
step('3/ 上传 OSS');
await runScript('upload-oss', platform, version, opts.channel, ossPrefix, releaseDirName);
}
// ── Step 4: 发布到 API ──
if (opts.publish) {
step('4/ 发布版本到 API');
await runScript('publish-release', platform, version, opts.channel, opts.edition, ossPrefix, releaseDirName);
}
console.log('\n✅ 全部完成');
// 构建元数据摘要
console.log(dim(`\n构建摘要:`));
console.log(dim(` 版本: ${version}`));
console.log(dim(` 平台: ${platform}`));
console.log(dim(` 版本类型: ${opts.edition}`));
console.log(dim(` 渠道: ${opts.channel}`));
console.log(dim(` OSS: ${ossPrefix}/`));
if (gitCommit) console.log(dim(` Commit: ${gitCommit} (${gitBranch})`));
console.log(dim(` 时间: ${buildTimestamp}`));
}
// ---------- 帮助 ----------
function printHelp() {
const box = (title) => {
const W = 62;
const pad = Math.max(0, W - 4 - title.length);
console.log(`\n${cyan('┌')}${cyan('─'.repeat(W - 2))}${cyan('┐')}`);
console.log(`${cyan('│')} ${bold(title)}${' '.repeat(pad)}${cyan('│')}`);
console.log(`${cyan('└')}${cyan('─'.repeat(W - 2))}${cyan('┘')}`);
};
const row = (flag, desc) => {
console.log(` ${green(flag.padEnd(24))}${dim(desc)}`);
};
const hdr = (t) => console.log(`\n ${bold(yellow(t))}`);
console.log('');
console.log(cyan(' ╔') + cyan('═══════════════════════════════════════════════════') + cyan('╗'));
console.log(cyan(' ║') + bold(' 船长·HeiXiu — 构建工具') + ' ' + cyan('║'));
console.log(cyan(' ╚') + cyan('═══════════════════════════════════════════════════') + cyan('╝'));
box('用法');
console.log(` ${bold('node build/build.mjs')} ${dim('<平台> [选项...]')}`);
console.log(` ${bold('npm run build:win')} ${dim('— 等效快捷命令')}`);
hdr('平台(必选其一)');
row('--win', 'Windows');
row('--mac', 'macOS');
row('--linux', 'Linux');
hdr('版本 & 渠道');
row('--edition <personal|enterprise>', '版本类型(默认 personal');
row('--channel <stable|beta|alpha>', '发布渠道(默认 stable');
row('--version <x.y.z>', '覆盖 package.json 版本号');
hdr('操作(可选组合,不指定则默认 --build');
row('--build', '构建应用(校验 → tsc → vite → electron-builder → info');
row('--clean', '构建前清理 release/ dist/ dist-electron/');
row('--sign', '代码签名(需配合 --cert 或环境变量)');
row('--upload', '上传安装包 + blockmap 到 OSS');
row('--publish', 'POST 版本信息到后端 API');
row('--all', '全流程 = clean + build + sign + upload + publish');
hdr('签名参数(配合 --sign');
row('--cert <path>', '证书文件路径(.pfx / .p12');
row('--cert-password <p>', '证书密码');
console.log(` ${dim('也可通过 CSC_LINK / CSC_KEY_PASSWORD 环境变量设置')}`);
hdr('灰度发布示例');
console.log(` ${dim('# Beta 渠道打包 + 上传 + 发布')}`);
console.log(` ${bold('node build/build.mjs --win --channel beta --upload --publish')}`);
console.log(` ${dim('# Alpha 渠道仅打包测试')}`);
console.log(` ${bold('node build/build.mjs --win --channel alpha')}`);
console.log(` ${dim('# 企业版全流程')}`);
console.log(` ${bold('node build/build.mjs --win --edition enterprise --all')}`);
box('OSS 路径规则');
console.log(` ${yellow('Stable')} ${dim('→ releases/{version}/ (向后兼容)')}`);
console.log(` ${yellow('Beta')} ${dim('→ releases/beta/{version}/ (独立生命周期)')}`);
console.log(` ${yellow('Alpha')} ${dim('→ releases/{version}/alpha/ (版本优先)')}`);
box('npm 快捷命令');
console.log(` ${bold('npm run build:win')} ${dim('→ node build/build.mjs --win')}`);
console.log(` ${bold('npm run build:win:enterprise')} ${dim('→ node build/build.mjs --win --edition enterprise')}`);
console.log(` ${bold('npm run build:win:beta')} ${dim('→ node build/build.mjs --win --channel beta')}`);
console.log(` ${bold('npm run build:mac')} ${dim('→ node build/build.mjs --mac')}`);
console.log(` ${bold('npm run build:linux')} ${dim('→ node build/build.mjs --linux')}`);
console.log(` ${bold('npm run build:clean')} ${dim('→ node build/scripts/clean.mjs')}`);
console.log(` ${bold('npm run version:patch')} ${dim('→ node build/scripts/bump-version.mjs patch')}`);
box('环境变量');
console.log(` ${yellow('UPDATE_API_URL')} ${dim('版本检查 API 基地址')}`);
console.log(` ${yellow('UPDATE_API_KEY')} ${dim('发布 API 鉴权 Key')}`);
console.log(` ${yellow('UPDATE_DOWNLOAD_BASE')} ${dim('OSS 下载基地址')}`);
console.log(` ${yellow('OSS_ENDPOINT')} ${dim('阿里云 OSS endpoint')}`);
console.log(` ${yellow('OSS_BUCKET')} ${dim('阿里云 OSS bucket')}`);
console.log(` ${yellow('OSS_ACCESS_KEY_ID')} ${dim('阿里云 AccessKey ID')}`);
console.log(` ${yellow('OSS_ACCESS_KEY_SECRET')} ${dim('阿里云 AccessKey Secret')}`);
console.log(` ${yellow('CSC_LINK')} ${dim('签名证书路径')}`);
console.log(` ${yellow('CSC_KEY_PASSWORD')} ${dim('签名证书密码')}`);
console.log('');
}
// ---------- 入口 ----------
main().catch((err) => {
console.error(`\n❌ 构建失败: ${err.message}`);
process.exit(1);
});

78
build/electron-builder.js Normal file
View File

@@ -0,0 +1,78 @@
// ============================================================
// electron-builder 配置
//
// 从 package.json "build" 字段提取,通过环境变量动态注入参数。
// build.mjs 负责设置 HEIXIU_* 环境变量后调用 electron-builder。
//
// 用法:
// HEIXIU_EDITION=enterprise HEIXIU_CHANNEL=beta npx electron-builder --win --config build/electron-builder.js
// ============================================================
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
const edition = process.env.HEIXIU_EDITION || 'personal';
const channel = process.env.HEIXIU_CHANNEL || 'stable';
const releaseDirName = process.env.HEIXIU_RELEASE_DIR_NAME || pkg.version;
const editionLabel = edition === 'enterprise' ? '企业版' : '';
const productName = editionLabel
? `船长·HeiXiu·${editionLabel}`
: '船长·HeiXiu';
/** @type {import('electron-builder').Configuration} */
const config = {
appId: edition === 'enterprise'
? 'com.heixiu.enterprise'
: 'com.heixiu.electron',
productName,
asar: true,
directories: {
output: `release/${releaseDirName}`,
},
files: [
'dist',
'dist-electron',
],
electronDownload: {
mirror: 'https://npmmirror.com/mirrors/electron/',
},
// generic provider 仅作为 electron-updater 的 fallback
// 实际版本检查通过自定义 HeiXiuProvider → API
publish: {
provider: 'generic',
url: 'https://update.heixiu.com',
},
win: {
icon: 'src/assets/logo/heixiu.ico',
target: [{ target: 'NSIS', arch: ['x64'] }],
artifactName: '${productName}-Windows-${version}-Setup.${ext}',
},
mac: {
icon: 'src/assets/logo/heixiu.icns',
target: ['dmg'],
artifactName: '${productName}-Mac-${version}-Installer.${ext}',
},
linux: {
icon: 'src/assets/logo/HX.png',
target: ['AppImage'],
artifactName: '${productName}-Linux-${version}.${ext}',
},
nsis: {
oneClick: false,
perMachine: false,
allowToChangeInstallationDirectory: true,
deleteAppDataOnUninstall: false,
differentialPackage: true,
installerIcon: 'src/assets/logo/heixiu.ico',
uninstallerIcon: 'src/assets/logo/heixiu.ico',
},
};
export default config;

View File

@@ -0,0 +1,153 @@
// ============================================================
// bump-version.mjs — 版本号管理与 CHANGELOG 模板生成
//
// 用法:
// node build/scripts/bump-version.mjs <major|minor|patch> [--no-changelog]
// node build/scripts/bump-version.mjs set <x.y.z>
// node build/scripts/bump-version.mjs pre <beta|alpha>
//
// 示例:
// node build/scripts/bump-version.mjs patch # 0.0.28 → 0.0.29
// node build/scripts/bump-version.mjs minor # 0.0.28 → 0.1.0
// node build/scripts/bump-version.mjs set 1.0.0 # 直接设置版本
// node build/scripts/bump-version.mjs pre beta # 0.0.29 → 0.0.29-beta.1
// ============================================================
import { readFileSync, writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..', '..');
const args = process.argv.slice(2);
const command = args[0];
const arg1 = args[1];
const noChangelog = args.includes('--no-changelog');
if (!command) {
console.error('用法: node build/scripts/bump-version.mjs <major|minor|patch|set|pre> [value]');
console.error('示例: node build/scripts/bump-version.mjs patch');
console.error(' node build/scripts/bump-version.mjs set 1.0.0');
console.error(' node build/scripts/bump-version.mjs pre beta');
process.exit(1);
}
const pkgPath = join(ROOT, 'package.json');
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
const oldVersion = pkg.version;
// ---------- 版本计算 ----------
function parseSemver(v) {
const match = v.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/);
if (!match) throw new Error(`无效的版本号格式: ${v}`);
return {
major: parseInt(match[1]),
minor: parseInt(match[2]),
patch: parseInt(match[3]),
pre: match[4] || null,
};
}
let newVersion;
switch (command) {
case 'major':
case 'minor':
case 'patch': {
const sv = parseSemver(oldVersion);
if (command === 'major') {
sv.major++;
sv.minor = 0;
sv.patch = 0;
} else if (command === 'minor') {
sv.minor++;
sv.patch = 0;
} else {
sv.patch++;
}
sv.pre = null;
newVersion = `${sv.major}.${sv.minor}.${sv.patch}`;
break;
}
case 'set': {
if (!arg1) {
console.error('用法: node build/scripts/bump-version.mjs set <x.y.z>');
process.exit(1);
}
// 基本格式校验
if (!/^\d+\.\d+\.\d+(-.+)?$/.test(arg1)) {
console.error(`无效的版本号: ${arg1}`);
process.exit(1);
}
newVersion = arg1;
break;
}
case 'pre': {
if (!arg1 || !['beta', 'alpha'].includes(arg1)) {
console.error('用法: node build/scripts/bump-version.mjs pre <beta|alpha>');
process.exit(1);
}
const sv = parseSemver(oldVersion);
// 如果当前版本已有预发布后缀,递增进位号
if (sv.pre) {
const match = sv.pre.match(new RegExp(`^${arg1}\\.(\\d+)$`));
if (match) {
const num = parseInt(match[1]) + 1;
newVersion = `${sv.major}.${sv.minor}.${sv.patch}-${arg1}.${num}`;
} else {
// 不同预发布类型,重新开始
newVersion = `${sv.major}.${sv.minor}.${sv.patch}-${arg1}.1`;
}
} else {
// 纯 semver → 首次预发布:自动升 patch 再加后缀
sv.patch++;
newVersion = `${sv.major}.${sv.minor}.${sv.patch}-${arg1}.1`;
}
break;
}
default:
console.error(`未知命令: ${command}。支持: major | minor | patch | set <ver> | pre <beta|alpha>`);
process.exit(1);
}
// ---------- 更新 package.json ----------
pkg.version = newVersion;
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
console.log(`✅ package.json: ${oldVersion}${newVersion}`);
// ---------- 更新 shared/constants/version.ts由 import pkg 动态读取,无需修改) ----------
console.log(' shared/constants/version.ts 自动读取 package.json无需手动同步');
// ---------- 更新 CHANGELOG.md ----------
if (!noChangelog) {
const changelogPath = join(ROOT, 'CHANGELOG.md');
try {
let changelog = readFileSync(changelogPath, 'utf-8');
const today = new Date().toISOString().slice(0, 10);
const entry = `## ${newVersion}${today}\n\n**更新内容待补充**\n\n---\n\n`;
// 在第一个 ## 之前插入新版本条目
const firstHeaderMatch = changelog.match(/^##\s/m);
if (firstHeaderMatch) {
const insertAt = firstHeaderMatch.index;
changelog = changelog.slice(0, insertAt) + entry + changelog.slice(insertAt);
writeFileSync(changelogPath, changelog, 'utf-8');
console.log(`✅ CHANGELOG.md: 已添加 v${newVersion} 模板条目`);
} else {
console.warn('⚠️ CHANGELOG.md 格式异常,未找到 ## 版本标题');
}
} catch {
console.warn('⚠️ CHANGELOG.md 不存在,跳过更新');
}
}
console.log(`\n版本已更新: ${oldVersion}${newVersion}`);

View File

@@ -8,7 +8,7 @@ import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..'); const ROOT = join(__dirname, '..', '..');
const dirs = ['release', 'dist', 'dist-electron']; const dirs = ['release', 'dist', 'dist-electron'];

View File

@@ -1,18 +1,14 @@
// ============================================================ // ============================================================
// 打包后自动生成 update-info.json // 打包后生成 update-info.json(含渠道/版本类型/SHA512/blockmap
// //
// 用法: // 用法(由 build.mjs 调用)
// node scripts/generate-update-info.mjs <platform> <edition> [version] [--url <url>] // node build/scripts/generate-update-info.mjs <platform> <version> <channel> <edition> <ossPrefix> <releaseDirName>
// //
// 两种用法 // 也支持手动调用
// 方式 A预判 URL打包后自动拼接 URL → 适用于 OSS 路径可预测时 // node build/scripts/generate-update-info.mjs <platform> <version> [channel] [edition] [ossPrefix] [releaseDirName] [--url <url>] [--blockmap-url <url>]
// node scripts/generate-update-info.mjs win32 personal
//
// 方式 B指定 URL上传后用实际 URL 重新生成 → 适用于上传后才知道 URL 时
// node scripts/generate-update-info.mjs win32 personal 0.1.0 --url "https://oss.../xxx.exe"
// //
// 环境变量(可选): // 环境变量(可选):
// UPDATE_DOWNLOAD_BASE — 方式 A 的 URL 基地址,默认 https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases // UPDATE_DOWNLOAD_BASE — OSS 下载基地址
// UPDATE_FORCE — 是否强制更新,默认 false // UPDATE_FORCE — 是否强制更新,默认 false
// UPDATE_MIN_VERSION — 最低兼容版本,默认 0.0.0 // UPDATE_MIN_VERSION — 最低兼容版本,默认 0.0.0
// UPDATE_CHANGELOG — 覆盖 CHANGELOG.md 中的日志内容 // UPDATE_CHANGELOG — 覆盖 CHANGELOG.md 中的日志内容
@@ -24,13 +20,12 @@ import { fileURLToPath } from 'node:url';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..'); const ROOT = join(__dirname, '..', '..');
// ---------- 参数解析 ---------- // ---------- 参数解析 ----------
const args = process.argv.slice(2); const args = process.argv.slice(2);
// 提取 --url 和 --blockmap-url 参数
let urlOverride = ''; let urlOverride = '';
let blockmapUrlOverride = ''; let blockmapUrlOverride = '';
const positional = []; const positional = [];
@@ -45,27 +40,26 @@ for (let i = 0; i < args.length; i++) {
} }
const platform = positional[0]; const platform = positional[0];
const edition = positional[1]; const version = positional[1];
const channel = positional[2] || 'stable';
const edition = positional[3] || 'personal';
const ossPrefix = positional[4] || `releases/${version}`;
const releaseDirName = positional[5] || version;
if (!platform || !edition) { if (!platform || !version) {
console.error('用法: node scripts/generate-update-info.mjs <platform> <edition> [version] [--url <url>]'); console.error('用法: node build/scripts/generate-update-info.mjs <platform> <version> [channel] [edition] [ossPrefix] [releaseDirName]');
console.error('示例: node scripts/generate-update-info.mjs win32 personal');
console.error(' node scripts/generate-update-info.mjs win32 personal 0.1.0 --url "https://..."');
process.exit(1); process.exit(1);
} }
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')); const productName = '船长·HeiXiu';
const version = positional[2] || pkg.version;
const productName = pkg.build?.productName || '船长·HeiXiu';
// ---------- 查找安装包 ---------- // ---------- 查找安装包 ----------
const releaseDir = join(ROOT, 'release', version); const releaseDir = join(ROOT, 'release', releaseDirName);
let installerPath; let installerPath;
try { try {
const files = readdirSync(releaseDir); const files = readdirSync(releaseDir);
// 按扩展名找安装包
const exts = platform === 'win32' ? ['.exe'] : platform === 'darwin' ? ['.dmg'] : ['.AppImage']; const exts = platform === 'win32' ? ['.exe'] : platform === 'darwin' ? ['.dmg'] : ['.AppImage'];
const match = files.find((f) => exts.some((ext) => f.endsWith(ext))); const match = files.find((f) => exts.some((ext) => f.endsWith(ext)));
if (!match) { if (!match) {
@@ -84,7 +78,6 @@ try {
const fileBuffer = readFileSync(installerPath); const fileBuffer = readFileSync(installerPath);
const stats = statSync(installerPath); const stats = statSync(installerPath);
// SHA512Base64 编码,与 certutil 输出一致)
const sha512 = createHash('sha512').update(fileBuffer).digest('base64').replace(/\r?\n/g, ''); const sha512 = createHash('sha512').update(fileBuffer).digest('base64').replace(/\r?\n/g, '');
const fileSize = stats.size; const fileSize = stats.size;
@@ -104,19 +97,16 @@ try {
blockMapSize = blockmapStats.size; blockMapSize = blockmapStats.size;
if (blockmapUrlOverride) { if (blockmapUrlOverride) {
// 方式 B使用指定的 blockmap URL
blockMapUrl = blockmapUrlOverride; blockMapUrl = blockmapUrlOverride;
} else { } else {
// 方式 A根据基地址 + 版本号 + 文件名拼接 const downloadBase = process.env.UPDATE_DOWNLOAD_BASE || 'https://heixiu.oss-cn-hangzhou.aliyuncs.com';
const downloadBase = process.env.UPDATE_DOWNLOAD_BASE || 'https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases'; blockMapUrl = `${downloadBase.replace(/\/+$/, '')}/${ossPrefix}/${encodeURI(blockmapFile)}`;
blockMapUrl = `${downloadBase.replace(/\/+$/, '')}/${version}/${encodeURI(blockmapFile)}`;
} }
console.log(`Blockmap: ${blockmapFile} (${(blockMapSize / 1024).toFixed(2)} KB)`); console.log(`Blockmap: ${blockmapFile} (${(blockMapSize / 1024).toFixed(2)} KB)`);
console.log(`Blockmap URL: ${blockMapUrl}`); console.log(`Blockmap URL: ${blockMapUrl}`);
} }
} catch { } catch {
// blockmap 不存在不影响主流程
blockMapUrl = undefined; blockMapUrl = undefined;
blockMapSize = undefined; blockMapSize = undefined;
} }
@@ -127,13 +117,11 @@ const fileName = installerPath.split(/[/\\]/).pop();
let downloadUrl; let downloadUrl;
if (urlOverride) { if (urlOverride) {
// 方式 B使用指定的 URL
downloadUrl = urlOverride; downloadUrl = urlOverride;
console.log(`下载 URL手动指定: ${downloadUrl}`); console.log(`下载 URL手动指定: ${downloadUrl}`);
} else { } else {
// 方式 A根据基地址 + 版本号 + 文件名拼接(预判 URL const downloadBase = process.env.UPDATE_DOWNLOAD_BASE || 'https://heixiu.oss-cn-hangzhou.aliyuncs.com';
const downloadBase = process.env.UPDATE_DOWNLOAD_BASE || 'https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases'; downloadUrl = `${downloadBase.replace(/\/+$/, '')}/${ossPrefix}/${encodeURI(fileName)}`;
downloadUrl = `${downloadBase.replace(/\/+$/, '')}/${version}/${encodeURI(fileName)}`;
console.log(`下载 URL自动拼接: ${downloadUrl}`); console.log(`下载 URL自动拼接: ${downloadUrl}`);
} }
@@ -141,23 +129,23 @@ if (urlOverride) {
let changelog; let changelog;
if (process.env.UPDATE_CHANGELOG) { if (process.env.UPDATE_CHANGELOG) {
// 环境变量覆盖(适用于 CI/CD 流水线)
changelog = process.env.UPDATE_CHANGELOG; changelog = process.env.UPDATE_CHANGELOG;
} else { } else {
// 从 CHANGELOG.md 提取最新版本的日志
const changelogPath = join(ROOT, 'CHANGELOG.md'); const changelogPath = join(ROOT, 'CHANGELOG.md');
try { try {
const changelogContent = readFileSync(changelogPath, 'utf-8'); const changelogContent = readFileSync(changelogPath, 'utf-8');
// 匹配最新版本块:## x.y.z日期 ... ## 或 EOF // 精确匹配当前版本块
const escapedVersion = version.replace(/\./g, '\\.');
const versionSection = changelogContent.match( const versionSection = changelogContent.match(
/^##\s+(\d+\.\d+\.\d+)[^\n]*\n([\s\S]*?)(?=^##\s|\Z)/m, new RegExp(`^##\\s+${escapedVersion}[^\\n]*\\n([\\s\\S]*?)(?=^##\\s|\\Z)`, 'm'),
); );
if (versionSection) { if (versionSection) {
changelog = versionSection[0].trim(); changelog = versionSection[0].trim();
console.log(`更新日志: 已从 CHANGELOG.md 提取 v${version} 条目`);
} else { } else {
changelog = `## ${version}\n\n更新内容待补充`; changelog = `## ${version}\n\n更新内容待补充`;
console.warn(`⚠️ CHANGELOG.md 中未找到 v${version} 条目,使用占位内容`);
} }
console.log(`更新日志: 已从 CHANGELOG.md 提取`);
} catch { } catch {
changelog = `## ${version}\n\n更新内容待补充`; changelog = `## ${version}\n\n更新内容待补充`;
} }
@@ -183,15 +171,21 @@ const updateInfo = {
forceUpdate, forceUpdate,
minimumVersion, minimumVersion,
// --- 渠道与版本类型(灰度测试关键字段)---
channel,
edition,
// --- 增量更新 --- // --- 增量更新 ---
...(blockMapUrl && { blockMapUrl }), ...(blockMapUrl && { blockMapUrl }),
...(blockMapSize != null && { blockMapSize }), ...(blockMapSize != null && { blockMapSize }),
// --- 附加:方便后端录入 --- // --- 附加元数据 ---
_meta: { _meta: {
platform, platform,
channel,
edition, edition,
fileName, fileName,
ossPrefix,
blockmap: blockMapUrl ? { url: blockMapUrl, size: blockMapSize } : null, blockmap: blockMapUrl ? { url: blockMapUrl, size: blockMapSize } : null,
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
}, },
@@ -199,7 +193,7 @@ const updateInfo = {
const outputPath = join( const outputPath = join(
releaseDir, releaseDir,
`${productName}-${platform}-${edition}-v${version}-update-info.json`, `${productName}-${platform}-v${version}-update-info.json`,
); );
writeFileSync(outputPath, JSON.stringify(updateInfo, null, 2), 'utf-8'); writeFileSync(outputPath, JSON.stringify(updateInfo, null, 2), 'utf-8');

View File

@@ -0,0 +1,135 @@
// ============================================================
// pre-build-check.mjs — 构建前校验
//
// 用法:
// node build/scripts/pre-build-check.mjs <platform> <version> <channel>
//
// 检查项:
// 1. Git 工作区是否干净(有未提交变更时警告,非阻断)
// 2. CHANGELOG.md 是否包含当前版本条目
// 3. package.json 版本号与传入版本号一致
// 4. 渠道版本号格式校验beta/alpha 必须含预发布后缀)
// ============================================================
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execSync } from 'node:child_process';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..', '..');
const platform = process.argv[2];
const version = process.argv[3];
const channel = process.argv[4] || 'stable';
if (!platform || !version) {
console.error('用法: node build/scripts/pre-build-check.mjs <platform> <version> [channel]');
process.exit(1);
}
let hasError = false;
// ---------- 1. Git 工作区检查 ----------
console.log('▸ 检查 Git 工作区状态...');
try {
const status = execSync('git status --porcelain', { cwd: ROOT, encoding: 'utf-8' }).trim();
if (status) {
console.warn('⚠️ 警告: Git 工作区有未提交的变更:');
const lines = status.split('\n').slice(0, 10);
lines.forEach((l) => console.warn(` ${l}`));
if (status.split('\n').length > 10) {
console.warn(` ... 还有 ${status.split('\n').length - 10} 个文件`);
}
console.warn(' (不阻断构建,但建议先提交)\n');
} else {
console.log(' ✅ Git 工作区干净\n');
}
} catch {
console.warn(' ⚠️ 非 Git 环境,跳过工作区检查\n');
}
// ---------- 2. CHANGELOG 版本条目检查 ----------
console.log('▸ 检查 CHANGELOG.md 版本条目...');
const changelogPath = join(ROOT, 'CHANGELOG.md');
try {
const changelogContent = readFileSync(changelogPath, 'utf-8');
const versionPattern = new RegExp(
`^##\\s+${version.replace(/\./g, '\\.')}\\b`,
'm',
);
if (versionPattern.test(changelogContent)) {
console.log(` ✅ CHANGELOG.md 已包含 v${version} 条目\n`);
} else {
console.error(` ❌ CHANGELOG.md 中未找到 v${version} 的更新日志条目`);
console.error(` 请在 CHANGELOG.md 顶部添加: ## ${version}(日期)\n`);
hasError = true;
}
} catch {
console.warn(' ⚠️ CHANGELOG.md 不存在,跳过版本条目检查\n');
}
// ---------- 3. package.json 版本号一致性 ----------
console.log('▸ 检查 package.json 版本一致性...');
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
if (pkg.version === version) {
console.log(` ✅ package.json 版本号一致: ${version}\n`);
} else {
console.error(` ❌ 版本号不一致: 传入 ${version}package.json 为 ${pkg.version}`);
console.error(' 请先执行: node build/scripts/bump-version.mjs <major|minor|patch>\n');
hasError = true;
}
// ---------- 4. 渠道版本号格式校验 ----------
console.log('▸ 检查渠道版本号格式...');
if (channel === 'stable') {
// stable 版本号必须是纯 semver如 0.0.28
if (/^\d+\.\d+\.\d+$/.test(version)) {
console.log(' ✅ Stable 渠道版本号格式正确\n');
} else {
console.error(` ❌ Stable 渠道版本号必须为纯 semver 格式 (x.y.z),当前: ${version}\n`);
hasError = true;
}
} else {
// beta/alpha 版本号必须含预发布后缀(如 0.0.29-beta.1
if (/-/.test(version)) {
console.log(`${channel} 渠道版本号含预发布后缀\n`);
} else {
console.warn(` ⚠️ ${channel} 渠道建议使用预发布版本号(如 ${version}-${channel}.1`);
console.warn(' 当前版本号不含预发布后缀,可能覆盖 stable 版本的 OSS 文件\n');
}
}
// ---------- 5. 环境变量检查(仅 upload/publish 时有效,此处仅提示) ----------
console.log('▸ 环境变量就绪检查...');
const uploadVars = ['OSS_ENDPOINT', 'OSS_BUCKET', 'OSS_ACCESS_KEY_ID', 'OSS_ACCESS_KEY_SECRET'];
const missingUpload = uploadVars.filter((v) => !process.env[v]);
if (missingUpload.length > 0) {
console.warn(` ⚠️ OSS 相关环境变量未设置: ${missingUpload.join(', ')}`);
console.warn(' 如需上传 OSS请配置这些环境变量或在 .env.local 中设置)');
} else {
console.log(' ✅ OSS 环境变量已配置');
}
if (!process.env.UPDATE_API_KEY) {
console.warn(' ⚠️ UPDATE_API_KEY 未设置,发布到 API 可能失败');
} else {
console.log(' ✅ UPDATE_API_KEY 已配置');
}
console.log('');
// ---------- 结果 ----------
if (hasError) {
console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.error('❌ 构建前校验未通过,请修复以上问题后重试');
console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
process.exit(1);
}
console.log('✅ 构建前校验全部通过');

View File

@@ -1,8 +1,11 @@
// ============================================================ // ============================================================
// 发布版本信息到后端 API // 发布版本信息到后端 API
// //
// 用法: // 用法(由 build.mjs 调用)
// node scripts/publish-release.mjs <platform> <edition> [version] [--url <url>] // node build/scripts/publish-release.mjs <platform> <version> <channel> <edition> <ossPrefix> <releaseDirName>
//
// 也支持手动调用:
// node build/scripts/publish-release.mjs <platform> <version> [channel] [edition] [ossPrefix] [--url <url>] [--json <json>]
// //
// 环境变量: // 环境变量:
// UPDATE_API_URL — API 基地址(默认 https://www.heixiu.com // UPDATE_API_URL — API 基地址(默认 https://www.heixiu.com
@@ -15,7 +18,7 @@ import { fileURLToPath } from 'node:url';
import axios from 'axios'; import axios from 'axios';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..'); const ROOT = join(__dirname, '..', '..');
// ---------- 参数 ---------- // ---------- 参数 ----------
@@ -35,17 +38,17 @@ for (let i = 0; i < args.length; i++) {
} }
const platform = positional[0]; const platform = positional[0];
const edition = positional[1]; const version = positional[1];
const channel = positional[2] || 'stable';
const edition = positional[3] || 'personal';
const ossPrefix = positional[4] || `releases/${version}`;
const releaseDirName = positional[5] || version;
if (!platform || !edition) { if (!platform || !version) {
console.error('用法: node scripts/publish-release.mjs <platform> <edition> [version] [--url <url>]'); console.error('用法: node build/scripts/publish-release.mjs <platform> <version> [channel] [edition] [ossPrefix] [releaseDirName]');
console.error('示例: node scripts/publish-release.mjs win32 personal 0.1.0');
process.exit(1); process.exit(1);
} }
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
const version = positional[2] || pkg.version;
// ---------- 读取 update-info.json ---------- // ---------- 读取 update-info.json ----------
let updateInfo; let updateInfo;
@@ -58,14 +61,17 @@ if (jsonOverride) {
process.exit(1); process.exit(1);
} }
} else { } else {
const releaseDir = join(ROOT, 'release', version); const releaseDir = join(ROOT, 'release', releaseDirName);
let infoFile; let infoFile;
try { try {
const all = readdirSync(releaseDir); const all = readdirSync(releaseDir);
infoFile = all.find((f) => infoFile = all.find((f) =>
f.includes(platform) && f.includes(edition) && f.endsWith('-update-info.json'), f.includes(platform) && f.endsWith('-update-info.json'),
); );
if (!infoFile) console.error(`未找到 ${platform}+${edition} 的 update-info.json`); if (!infoFile) {
console.error(`未找到 ${platform} 的 update-info.json${releaseDir} 中)`);
process.exit(1);
}
} catch (err) { } catch (err) {
console.error(`错误: ${releaseDir}${err.message}`); console.error(`错误: ${releaseDir}${err.message}`);
process.exit(1); process.exit(1);
@@ -81,6 +87,7 @@ const apiKey = process.env.UPDATE_API_KEY || '';
const payload = { const payload = {
platform, platform,
channel,
edition, edition,
latestVersion: updateInfo.latestVersion || version, latestVersion: updateInfo.latestVersion || version,
downloadUrl: updateInfo.downloadUrl, downloadUrl: updateInfo.downloadUrl,
@@ -90,6 +97,15 @@ const payload = {
releaseDate: updateInfo.releaseDate, releaseDate: updateInfo.releaseDate,
forceUpdate: updateInfo.forceUpdate || false, forceUpdate: updateInfo.forceUpdate || false,
minimumVersion: updateInfo.minimumVersion || '0.0.0', minimumVersion: updateInfo.minimumVersion || '0.0.0',
// 增量更新
...(updateInfo.blockMapUrl && { blockMapUrl: updateInfo.blockMapUrl }),
...(updateInfo.blockMapSize != null && { blockMapSize: updateInfo.blockMapSize }),
// 元数据
_meta: {
ossPrefix,
releaseDirName,
publishedAt: new Date().toISOString(),
},
}; };
const endpoint = `${apiBase}/api/v1/releases`; const endpoint = `${apiBase}/api/v1/releases`;
@@ -98,7 +114,10 @@ console.log('━━━━━━━━━━━━━━━━━━━━━━
console.log('📡 发布版本到 API'); console.log('📡 发布版本到 API');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log(`POST ${endpoint}`); console.log(`POST ${endpoint}`);
console.log(`版本: ${version} (${platform}/${edition})`); console.log(`版本: ${version}`);
console.log(`平台: ${platform}`);
console.log(`渠道: ${channel}`);
console.log(`版本类型: ${edition}`);
console.log(`URL: ${payload.downloadUrl}\n`); console.log(`URL: ${payload.downloadUrl}\n`);
try { try {

View File

@@ -0,0 +1,242 @@
// ============================================================
// 上传安装包 + blockmap 文件到阿里云 OSS
//
// 用法(由 build.mjs 调用):
// node build/scripts/upload-oss.mjs <platform> <version> <channel> <ossPrefix> <releaseDirName>
//
// 也支持手动调用:
// node build/scripts/upload-oss.mjs <platform> <version> [channel] [ossPrefix]
//
// 环境变量(使用 Node.js 直传时需要):
// OSS_ENDPOINT — OSS endpoint如 oss-cn-hangzhou.aliyuncs.com
// OSS_BUCKET — Bucket 名称
// OSS_ACCESS_KEY_ID — AccessKey ID
// OSS_ACCESS_KEY_SECRET — AccessKey Secret
//
// 不传 OSS 环境变量时 → 打印手动上传命令
// ============================================================
import { readFileSync, readdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createHmac } from 'node:crypto';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..', '..');
// ---------- 参数 ----------
const platform = process.argv[2];
const version = process.argv[3];
const channel = process.argv[4] || 'stable';
const ossPrefix = process.argv[5] || `releases/${version}`;
const releaseDirName = process.argv[6] || version;
if (!platform || !version) {
console.error('用法: node build/scripts/upload-oss.mjs <platform> <version> [channel] [ossPrefix] [releaseDirName]');
process.exit(1);
}
// ---------- 查找安装包 ----------
const releaseDir = join(ROOT, 'release', releaseDirName);
let installerName;
try {
const all = readdirSync(releaseDir);
const exts = platform === 'win32' ? ['.exe'] : platform === 'darwin' ? ['.dmg'] : ['.AppImage'];
installerName = all.find((f) => exts.some((e) => f.endsWith(e)));
if (!installerName) {
console.error(`错误: ${releaseDir} — 未找到安装包`);
process.exit(1);
}
} catch (err) {
console.error(`错误: ${releaseDir}${err.message}`);
process.exit(1);
}
const installerPath = join(releaseDir, installerName);
// ---------- 查找 blockmap 文件 ----------
let blockmapName = null;
let blockmapPath = null;
try {
const all = readdirSync(releaseDir);
blockmapName = all.find((f) => f.endsWith('.blockmap'));
if (blockmapName) {
blockmapPath = join(releaseDir, blockmapName);
}
} catch {
// blockmap 不存在不影响主流程
}
// ---------- OSS 配置 ----------
const {
OSS_ENDPOINT,
OSS_BUCKET,
OSS_ACCESS_KEY_ID,
OSS_ACCESS_KEY_SECRET,
} = process.env;
const installerObjectKey = `${ossPrefix}/${installerName}`;
// ============================================================
// 模式 A无 OSS 配置 → 打印手动命令
// ============================================================
if (!OSS_ENDPOINT || !OSS_BUCKET || !OSS_ACCESS_KEY_ID) {
const exampleBucket = OSS_BUCKET || '<bucket>';
const exampleEndpoint = OSS_ENDPOINT || '<endpoint>';
const downloadUrl = `https://${exampleBucket}.${exampleEndpoint}/${installerObjectKey}`;
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('📦 上传安装包 + blockmap手动');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log(`渠道: ${channel}`);
console.log(`OSS 路径: ${ossPrefix}/`);
console.log(`文件: ${installerPath}`);
console.log(`大小: ${(readFileSync(installerPath).length / 1024 / 1024).toFixed(2)} MB\n`);
if (blockmapPath) {
console.log(`Blockmap: ${blockmapPath}`);
console.log(`Blockmap 大小: ${(readFileSync(blockmapPath).length / 1024).toFixed(2)} KB\n`);
}
console.log('方式 1 — ossutil CLI推荐');
console.log(` ossutil cp "${installerPath}" oss://${exampleBucket}/${installerObjectKey}`);
if (blockmapPath) {
const blockmapKey = `${ossPrefix}/${blockmapName}`;
console.log(` ossutil cp "${blockmapPath}" oss://${exampleBucket}/${blockmapKey}`);
}
console.log('');
console.log('方式 2 — 阿里云控制台:');
console.log(` 登录 OSS 控制台 → ${exampleBucket} → 上传 → 目标路径: ${installerObjectKey}`);
if (blockmapPath) {
console.log(` 同时上传 ${blockmapName} 到相同目录`);
}
console.log('');
const blockmapUrl = blockmapPath
? `https://${exampleBucket}.${exampleEndpoint}/${ossPrefix}/${blockmapName}`
: '';
console.log('上传后执行以下命令,用实际 URL 重新生成 info');
if (blockmapUrl) {
console.log(` node build/scripts/generate-update-info.mjs ${platform} ${version} ${channel} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`);
console.log('');
console.log('或直接发布到后端 API');
console.log(` node build/scripts/publish-release.mjs ${platform} ${version} ${channel} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`);
} else {
console.log(` node build/scripts/generate-update-info.mjs ${platform} ${version} ${channel} --url "${downloadUrl}"`);
console.log('');
console.log('或直接发布到后端 API');
console.log(` node build/scripts/publish-release.mjs ${platform} ${version} ${channel} --url "${downloadUrl}"`);
}
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
process.exit(0);
}
// ============================================================
// 模式 BNode.js 直传 OSS安装包 + blockmap
// ============================================================
const fileBuffer = readFileSync(installerPath);
const contentType = platform === 'win32'
? 'application/vnd.microsoft.portable-executable'
: 'application/octet-stream';
// 上传安装包
const date = new Date().toUTCString();
const stringToSign = [
'PUT', '', contentType, date, `/${OSS_BUCKET}/${installerObjectKey}`,
].join('\n');
const signature = createHmac('sha1', OSS_ACCESS_KEY_SECRET)
.update(stringToSign)
.digest('base64');
const authHeader = `OSS ${OSS_ACCESS_KEY_ID}:${signature}`;
const downloadUrl = `https://${OSS_BUCKET}.${OSS_ENDPOINT}/${installerObjectKey}`;
console.log(`上传中: ${installerName} (${(fileBuffer.length / 1024 / 1024).toFixed(2)} MB)`);
console.log(`渠道: ${channel}`);
console.log(`目标: oss://${OSS_BUCKET}/${installerObjectKey}`);
try {
const response = await fetch(downloadUrl, {
method: 'PUT',
headers: {
'Content-Type': contentType,
'Content-Length': String(fileBuffer.length),
'Date': date,
'Authorization': authHeader,
},
body: fileBuffer,
});
if (!response.ok) {
const body = await response.text();
console.error(`❌ 安装包上传失败 HTTP ${response.status}: ${body}`);
process.exit(1);
}
console.log('✅ 安装包上传成功');
console.log(`下载 URL: ${downloadUrl}`);
} catch (err) {
console.error(`❌ 安装包上传失败: ${err.message}`);
process.exit(1);
}
// 上传 blockmap 文件
let blockmapUrl = null;
if (blockmapPath) {
const blockmapBuffer = readFileSync(blockmapPath);
const blockmapKey = `${ossPrefix}/${blockmapName}`;
blockmapUrl = `https://${OSS_BUCKET}.${OSS_ENDPOINT}/${blockmapKey}`;
const bmDate = new Date().toUTCString();
const bmStringToSign = [
'PUT', '', 'application/octet-stream', bmDate, `/${OSS_BUCKET}/${blockmapKey}`,
].join('\n');
const bmSignature = createHmac('sha1', OSS_ACCESS_KEY_SECRET)
.update(bmStringToSign)
.digest('base64');
console.log(`\n上传 blockmap: ${blockmapName} (${(blockmapBuffer.length / 1024).toFixed(2)} KB)`);
try {
const bmResponse = await fetch(blockmapUrl, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
'Content-Length': String(blockmapBuffer.length),
'Date': bmDate,
'Authorization': `OSS ${OSS_ACCESS_KEY_ID}:${bmSignature}`,
},
body: blockmapBuffer,
});
if (bmResponse.ok) {
console.log('✅ Blockmap 上传成功');
console.log(`Blockmap URL: ${blockmapUrl}`);
} else {
console.warn(`⚠️ Blockmap 上传失败不影响主流程HTTP ${bmResponse.status}`);
blockmapUrl = null;
}
} catch (err) {
console.warn(`⚠️ Blockmap 上传失败(不影响主流程): ${err.message}`);
blockmapUrl = null;
}
}
// 后续步骤提示
console.log('\n接下来 — 重新生成 update-info.json');
if (blockmapUrl) {
console.log(` node build/scripts/generate-update-info.mjs ${platform} ${version} ${channel} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`);
console.log('\n或直接发布到后端 API');
console.log(` node build/scripts/publish-release.mjs ${platform} ${version} ${channel} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`);
} else {
console.log(` node build/scripts/generate-update-info.mjs ${platform} ${version} ${channel} --url "${downloadUrl}"`);
console.log('\n或直接发布到后端 API');
console.log(` node build/scripts/publish-release.mjs ${platform} ${version} ${channel} --url "${downloadUrl}"`);
}

View File

@@ -21,6 +21,18 @@ declare namespace NodeJS {
} }
} }
/** sharp 图片处理库(可选依赖,运行时动态加载) */
declare module 'sharp' {
interface SharpInstance {
metadata(): Promise<{ width?: number; height?: number; format?: string }>;
resize(width?: number, height?: number, options?: { fit?: string; withoutEnlargement?: boolean }): SharpInstance;
jpeg(options?: { quality?: number }): SharpInstance;
toBuffer(): Promise<Buffer>;
}
function sharp(input: string | Buffer): SharpInstance;
export = sharp;
}
// Used in Renderer process, expose in `preload.ts` // Used in Renderer process, expose in `preload.ts`
declare interface Window { declare interface Window {
ipcRenderer: import('electron').IpcRenderer; ipcRenderer: import('electron').IpcRenderer;
@@ -29,4 +41,46 @@ declare interface Window {
encrypt(plaintext: string): Promise<string | null>; encrypt(plaintext: string): Promise<string | null>;
decrypt(encryptedBase64: string): Promise<string | null>; decrypt(encryptedBase64: string): Promise<string | null>;
}; };
/** 应用运行时 API */
appRuntime: {
/** 登录/登出后更新窗口标题中的版控后缀 */
setWindowEdition(edition: string | null): void;
};
/** 无限画布 API由 preload 注入) */
canvas: {
/** 打开画布窗口 */
openWindow(projectPath?: string): void;
/** 扫描目录 */
scanDirectory(params: {
roots: string[];
extensions: string[];
recursive: boolean;
}): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }>;
/** 读取 sidecar 元数据 */
readSidecar(params: { filePath: string }): Promise<{ success: boolean; data: object | null }>;
/** 写入 sidecar 元数据 */
writeSidecar(params: { filePath: string; metadata: object }): Promise<{ success: boolean; error?: string }>;
/** 生成缩略图 */
generateThumbnail(params: { filePath: string; size: number }): Promise<{
success: boolean;
data: { data: string; mimeType: string; width: number; height: number };
error?: string;
}>;
/** 扫描项目 */
scanProject(params: { projectPath: string; stage: string; assetTypeFilter: string; shotQuery: string }): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }>;
/** 复制文件 */
copyFile(params: { src: string; destDir: string; fileName: string }): Promise<{ success: boolean; data: string; error?: string }>;
/** 列出目录下的子文件夹(非递归,排除隐藏) */
listSubdirs(params: { parentPath: string }): Promise<{ success: boolean; data: string[]; error?: string }>;
/** 扫描阶段文件(单个 episode/shot/stage 组合) */
scanStageFiles(params: { projectPath: string; episode: string; shot: string; stage: string }): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }>;
/** 扫描资产文件(深度递归扫描 assets/ 目录) */
scanAssetFiles(params: { projectPath: string; assetTypeFilter: string }): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }>;
/** 创建目录 */
createDirectory(params: { parentPath: string; dirName: string }): Promise<{ success: boolean; data: string; error?: string }>;
/** 将本地文件路径转换为 local-media:// 协议 URL */
getMediaUrl(filePath: string): string;
/** 启动原生文件拖拽(从缩略图拖放媒体文件到外部应用) */
startDrag(params: { filePath: string; fileName: string }): Promise<{ success: boolean; error?: string }>;
};
} }

View File

@@ -1,18 +1,20 @@
import {app, BrowserWindow, ipcMain, dialog, session} from 'electron'; import {app, BrowserWindow, ipcMain, dialog, session, protocol, net} from 'electron';
import {createRequire} from 'node:module'; import {createRequire} from 'node:module';
import {fileURLToPath} from 'node:url'; import {fileURLToPath} from 'node:url';
import path from 'node:path'; import path from 'node:path';
import {getPlatform, isMacOS} from './main/utils/platform'; import {getPlatform, isMacOS} from './main/utils/platform';
import {getWindowIconPath} from './main/utils/logo'; import {getWindowIconPath} from './main/utils/logo';
import {buildWindowTitle, parseEdition} from '../shared/constants/app'; import {buildWindowTitle} from '../shared/constants/app';
import {loadEnvFile} from './main/load-env'; import {loadEnvFile} from './main/load-env';
import {initUpdater, registerUpdateIpcHandlers} from './main/updater'; import {initUpdater, registerUpdateIpcHandlers} from './main/updater';
import {setupAppMenu} from './main/menu'; import {setupAppMenu} from './main/menu';
import {initLogger, flushLogger, logger} from './main/logger'; import {initLogger, flushLogger, logger, setLogDirResolver} from './main/logger';
import {registerLogIpcHandlers} from './main/log-ipc'; import {registerLogIpcHandlers} from './main/ipc/log-ipc';
import {registerSafeStorageIpcHandlers} from './main/safe-storage-ipc'; import {registerSafeStorageIpcHandlers} from './main/ipc/safe-storage-ipc';
import {BIDIRECTIONAL} from '../shared/constants/ipc-channels'; import {registerStorageIpcHandlers, setDataDirResolver} from './main/ipc/storage-ipc';
import {registerCanvasIpcHandlers} from './main/ipc/canvas';
import {BIDIRECTIONAL, RENDERER_TO_MAIN, MAIN_TO_RENDERER} from '../shared/constants/ipc';
createRequire(import.meta.url); createRequire(import.meta.url);
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -33,8 +35,10 @@ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
// ---------- 平台 & 版本信息 ---------- // ---------- 平台 & 版本信息 ----------
const currentPlatform = getPlatform(); const currentPlatform = getPlatform();
const currentEdition = parseEdition(process.env.EDITION); /** 当前版控后缀(登录后通过 IPC 更新) */
const WINDOW_TITLE = buildWindowTitle(currentPlatform, currentEdition); let currentEdition: string | null = null;
/** 基础标题(无版控后缀),登录后通过 IPC 追加 */
const WINDOW_TITLE = buildWindowTitle(currentPlatform);
// ============================================================ // ============================================================
// 全局未捕获异常(主进程兜底记录) // 全局未捕获异常(主进程兜底记录)
@@ -62,6 +66,7 @@ process.on('unhandledRejection', (reason) => {
// ============================================================ // ============================================================
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
let canvasWindow: BrowserWindow | null = null;
// ============================================================ // ============================================================
// 主窗口 // 主窗口
@@ -124,6 +129,85 @@ function createMainWindow(): BrowserWindow {
return mainWindow; return mainWindow;
} }
// ============================================================
// 画布窗口
// ============================================================
function createCanvasWindow(): BrowserWindow {
const iconPath = getWindowIconPath(app.getAppPath());
logger.info('canvas-window', '创建画布窗口', {
isDev: !!VITE_DEV_SERVER_URL,
url: VITE_DEV_SERVER_URL
? VITE_DEV_SERVER_URL + '#/canvas'
: path.join(RENDERER_DIST, 'index.html') + '#/canvas',
});
canvasWindow = new BrowserWindow({
title: '无限画布 - HeiXiu',
icon: iconPath,
width: 1400,
height: 900,
minWidth: 800,
minHeight: 600,
show: false,
webPreferences: {
preload: path.join(__dirname, 'preload.mjs'),
contextIsolation: true,
nodeIntegration: false,
},
});
if (VITE_DEV_SERVER_URL) {
canvasWindow.loadURL(VITE_DEV_SERVER_URL + '#/canvas');
canvasWindow.webContents.openDevTools();
logger.info('canvas-window', '画布窗口 DevTools 已自动打开(开发模式)');
} else {
canvasWindow.loadFile(path.join(RENDERER_DIST, 'index.html'), {
hash: '/canvas',
});
}
// 禁用浏览器默认页面缩放Ctrl/Cmd+滚轮 和 触控板捏合)。
// 画布使用 CSS transform 实现独立缩放,不允许 Chromium 页面级缩放干扰。
// before-input-event 在 Chromium 处理输入之前触发,比 JS event listener 更早。
canvasWindow.webContents.on('before-input-event', (event, input) => {
if (input.type === 'mouseWheel' && (input.control || input.meta)) {
event.preventDefault();
}
});
canvasWindow.once('ready-to-show', () => {
logger.info('canvas-window', '画布窗口 ready-to-show即将显示');
canvasWindow?.show();
});
canvasWindow.on('closed', () => {
logger.info('canvas-window', '画布窗口已关闭');
canvasWindow = null;
});
return canvasWindow;
}
// ============================================================
// 自定义协议local-media — 安全加载本地文件
// 必须在 app.whenReady 之前注册为特权 scheme
// ============================================================
protocol.registerSchemesAsPrivileged([
{
scheme: 'local-media',
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
bypassCSP: true,
stream: true,
},
},
]);
// ============================================================ // ============================================================
// 应用生命周期 // 应用生命周期
// ============================================================ // ============================================================
@@ -145,9 +229,47 @@ app.on('before-quit', () => {
}); });
app.whenReady().then(() => { app.whenReady().then(() => {
// ── 存储路径配置 ──
// 修改以下两行即可将日志/数据存储到任意位置
setLogDirResolver(() => path.join(app.getPath('userData'), 'logs'));
setDataDirResolver(() => path.join(app.getPath('userData'), 'heixiu-data'));
initLogger(); initLogger();
registerLogIpcHandlers(); registerLogIpcHandlers();
registerSafeStorageIpcHandlers(); registerSafeStorageIpcHandlers();
registerStorageIpcHandlers();
registerCanvasIpcHandlers();
protocol.handle('local-media', (request) => {
const urlObj = new URL(request.url);
let filePath: string;
if (urlObj.hostname) {
// UNC 路径local-media://server/share/path → \\server\share\path
filePath = '\\\\' + urlObj.hostname + decodeURIComponent(urlObj.pathname).replace(/\//g, '\\');
} else {
// 本地路径local-media:///C:/path/to/file → C:/path/to/file
filePath = decodeURIComponent(urlObj.pathname.slice(1));
}
const fwd = filePath.replace(/\\/g, '/');
let fileUrl: string;
if (fwd.startsWith('//')) {
fileUrl = 'file:' + fwd;
} else {
fileUrl = 'file:///' + fwd;
}
// 只转发 Range 头,确保音视频 seek 时返回 206 Partial Content。
// ⚠️ 不能转发所有 headersOrigin/Referer/Sec-Fetch-* 等),
// 否则会干扰 Chromium 内部的 file:// 协议处理,导致请求失败。
const rangeHeader = request.headers.get('Range');
if (rangeHeader) {
return net.fetch(fileUrl, {headers: {Range: rangeHeader}});
}
return net.fetch(fileUrl);
});
setupAppMenu(); setupAppMenu();
app.setName(WINDOW_TITLE); app.setName(WINDOW_TITLE);
registerUpdateIpcHandlers(); registerUpdateIpcHandlers();
@@ -164,12 +286,65 @@ app.whenReady().then(() => {
// 文件对话框 IPC 处理(供渲染进程选择文件夹) // 文件对话框 IPC 处理(供渲染进程选择文件夹)
ipcMain.handle(BIDIRECTIONAL.FILE_DIALOG, async (_event, options: Electron.OpenDialogOptions) => { ipcMain.handle(BIDIRECTIONAL.FILE_DIALOG, async (_event, options: Electron.OpenDialogOptions) => {
if (!mainWindow) return {canceled: true, filePaths: []}; try {
return dialog.showOpenDialog(mainWindow, { const parentWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : canvasWindow;
if (!parentWindow) return {canceled: true, filePaths: []};
return dialog.showOpenDialog(parentWindow, {
title: options?.title || '选择文件夹', title: options?.title || '选择文件夹',
defaultPath: options?.defaultPath || app.getPath('home'), defaultPath: options?.defaultPath || app.getPath('home'),
properties: options?.properties || ['openDirectory'], properties: options?.properties || ['openDirectory'],
}); });
} catch (err) {
logger.warn('ipc', '文件对话框打开失败', err instanceof Error ? err : undefined);
return {canceled: true, filePaths: []};
}
});
// 窗口标题版控后缀(渲染进程登录/登出后调用)
ipcMain.on(RENDERER_TO_MAIN.SET_WINDOW_TITLE, (_event, edition: string | null) => {
try {
currentEdition = edition;
if (!mainWindow) return;
const title = edition
? buildWindowTitle(currentPlatform, undefined, edition as 'personal' | 'enterprise')
: buildWindowTitle(currentPlatform);
mainWindow.setTitle(title);
} catch (err) {
logger.warn('ipc', '设置窗口标题失败', err instanceof Error ? err : undefined);
}
});
// 打开无限画布窗口(渲染进程请求 → 主进程创建新 BrowserWindow
ipcMain.on(RENDERER_TO_MAIN.OPEN_CANVAS_WINDOW, (_event, payload?: { projectPath?: string }) => {
try {
const existingAlive = canvasWindow && !canvasWindow.isDestroyed();
logger.info('canvas-window', '收到 OPEN_CANVAS_WINDOW IPC', {
projectPath: payload?.projectPath || '(未指定)',
existingAlive,
});
if (existingAlive) {
logger.info('canvas-window', '画布窗口已存在,聚焦现有窗口');
canvasWindow?.focus();
} else {
logger.info('canvas-window', '画布窗口不存在或已销毁,创建新窗口');
canvasWindow = createCanvasWindow();
}
// 画布页面加载完成后,发送初始化参数
if (payload?.projectPath) {
const readyHandler = () => {
logger.info('canvas-window', '画布页面 did-finish-load发送 CANVAS_READY', {
projectPath: payload.projectPath,
});
canvasWindow?.webContents.send(MAIN_TO_RENDERER.CANVAS_READY, {
projectPath: payload.projectPath,
});
};
canvasWindow?.webContents.on('did-finish-load', readyHandler);
}
} catch (err) {
logger.warn('canvas-window', '打开画布窗口失败', err instanceof Error ? err : undefined);
}
}); });
// 始终打开主窗口 // 始终打开主窗口

View File

@@ -0,0 +1,352 @@
// ============================================================
// canvas/index.ts — 画布 IPC 处理器注册
//
// 职责:
// - 注册所有画布相关的 IPC 处理器
// - 组合使用各工具模块实现业务逻辑
// ============================================================
import { ipcMain } from 'electron';
import fs from 'node:fs';
import path from 'node:path';
import sharp from 'sharp';
import { BIDIRECTIONAL_CANVAS } from '../../../../shared/constants/ipc/canvas';
import { logger } from '../../logger';
import { walkDirectory } from './walk';
import { readSidecarFile, writeSidecarFile } from './sidecar';
import { generateCanvasThumbnail } from './thumbnail';
import { buildScanRoots, getStageExtensions } from './scan-roots';
import type { StageType, AssetTypeFilter } from './types';
/** 注册画布 IPC 处理器 */
export function registerCanvasIpcHandlers(): void {
// ---- 扫描项目(主进程内部 buildScanRoots + walkDirectory ----
ipcMain.handle(
BIDIRECTIONAL_CANVAS.CANVAS_SCAN_PROJECT,
async (
_event,
params: {
projectPath: string;
stage: StageType;
assetTypeFilter: AssetTypeFilter;
shotQuery: string;
},
) => {
try {
logger.info('canvas', '收到 CANVAS_SCAN_PROJECT', params);
const roots = buildScanRoots(params);
const extensions = getStageExtensions(params.stage);
logger.info('canvas', `buildScanRoots 结果:${roots.length} 个扫描根`, {
roots: roots.length <= 10 ? roots : roots.slice(0, 10).concat(`... 共 ${roots.length}`),
extensions,
stage: params.stage,
});
if (roots.length === 0) {
logger.warn('canvas', '扫描根目录为空', undefined, {
projectPath: params.projectPath,
stage: params.stage,
shotQuery: params.shotQuery || '(无)',
});
return { success: true, data: [] };
}
const allResults: Array<{
path: string;
fileName: string;
ext: string;
fileSize: number;
modifiedAt: string;
}> = [];
for (const root of roots) {
const results = walkDirectory(root, extensions, true);
if (results.length > 0) {
logger.info('canvas', ` 📁 ${root}${results.length} 个文件`);
}
allResults.push(...results);
}
logger.info(
'canvas',
`扫描项目完成:${allResults.length} 个文件(${roots.length} 个扫描根目录,阶段:${params.stage}`,
);
return { success: true, data: allResults };
} catch (err) {
const msg = (err as Error).message;
logger.warn('canvas', '项目扫描失败', err instanceof Error ? err : undefined);
return { success: false, error: msg };
}
},
);
// ---- 目录扫描(保留低级 API外部提供扫描根 ----
ipcMain.handle(
BIDIRECTIONAL_CANVAS.CANVAS_SCAN_DIRECTORY,
async (
_event,
params: { roots: string[]; extensions: string[]; recursive: boolean },
) => {
try {
const allResults: Array<{
path: string;
fileName: string;
ext: string;
fileSize: number;
modifiedAt: string;
}> = [];
for (const root of params.roots) {
if (!fs.existsSync(root)) {
logger.warn('canvas', `扫描根目录不存在,跳过:${root}`);
continue;
}
const results = walkDirectory(root, params.extensions, params.recursive);
allResults.push(...results);
}
logger.info(
'canvas',
`扫描完成:${allResults.length} 个文件(${params.roots.length} 个根目录)`,
);
return { success: true, data: allResults };
} catch (err) {
const msg = (err as Error).message;
logger.warn('canvas', '目录扫描失败', err instanceof Error ? err : undefined);
return { success: false, error: msg };
}
},
);
// ---- 读取 sidecar ----
ipcMain.handle(
BIDIRECTIONAL_CANVAS.CANVAS_READ_SIDECAR,
async (_event, params: { filePath: string }) => {
try {
const data = readSidecarFile(params.filePath);
return { success: true, data };
} catch (err) {
logger.warn('canvas', '读取 sidecar 失败', err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
},
);
// ---- 写入 sidecar ----
ipcMain.handle(
BIDIRECTIONAL_CANVAS.CANVAS_WRITE_SIDECAR,
async (_event, params: { filePath: string; metadata: object }) => {
try {
writeSidecarFile(params.filePath, params.metadata);
return { success: true };
} catch (err) {
logger.warn('canvas', '写入 sidecar 失败', err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
},
);
// ---- 生成缩略图 ----
ipcMain.handle(
BIDIRECTIONAL_CANVAS.CANVAS_GENERATE_THUMBNAIL,
async (_event, params: { filePath: string; size: number }) => {
try {
const result = await generateCanvasThumbnail(params.filePath, params.size);
return { success: true, data: result };
} catch (err) {
logger.warn('canvas', '生成缩略图失败', err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
},
);
// ---- 复制文件 ----
ipcMain.handle(
BIDIRECTIONAL_CANVAS.CANVAS_COPY_FILE,
async (_event, params: { src: string; destDir: string; fileName: string }) => {
try {
if (!fs.existsSync(params.destDir)) {
fs.mkdirSync(params.destDir, { recursive: true });
}
const dest = path.join(params.destDir, params.fileName);
fs.copyFileSync(params.src, dest);
return { success: true, data: dest };
} catch (err) {
logger.warn('canvas', '复制文件失败', err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
},
);
// ---- 获取图片尺寸(使用 sharp ----
ipcMain.handle(
BIDIRECTIONAL_CANVAS.CANVAS_GET_IMAGE_SIZE,
async (_event, params: { filePath: string }) => {
try {
if (sharp) {
const metadata = await sharp(params.filePath).metadata();
return {
success: true,
data: {
width: metadata.width || 0,
height: metadata.height || 0,
},
};
}
return { success: false, error: 'sharp 未安装,无法获取图片尺寸' };
} catch (err) {
logger.warn('canvas', '获取图片尺寸失败', err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
},
);
// ---- 列出子文件夹(非递归,排除隐藏) ----
ipcMain.handle(
BIDIRECTIONAL_CANVAS.CANVAS_LIST_SUBDIRS,
async (_event, params: { parentPath: string }) => {
try {
if (!fs.existsSync(params.parentPath)) {
logger.info('canvas', `listSubdirs: 路径不存在,返回空数组 — ${params.parentPath}`);
return { success: true, data: [] };
}
const entries = fs.readdirSync(params.parentPath, { withFileTypes: true });
const dirs = entries
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
.map((e) => e.name);
logger.info('canvas', `listSubdirs: ${dirs.length} 个子文件夹 — ${params.parentPath}`);
return { success: true, data: dirs };
} catch (err) {
logger.warn('canvas', 'listSubdirs 失败', err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
},
);
// ---- 扫描阶段文件 ----
ipcMain.handle(
BIDIRECTIONAL_CANVAS.CANVAS_SCAN_STAGE_FILES,
async (
_event,
params: {
projectPath: string;
episode: string;
shot: string;
stage: string;
},
) => {
try {
const stageDir = path.join(
params.projectPath,
'shots',
params.episode,
params.shot,
params.stage,
);
logger.info('canvas', `scanStageFiles: ${stageDir}`);
if (!fs.existsSync(stageDir)) {
logger.info('canvas', `scanStageFiles: 阶段目录不存在 — ${stageDir}`);
return { success: true, data: [] };
}
const extensions = getStageExtensions(params.stage as StageType);
const results = walkDirectory(stageDir, extensions, true);
logger.info('canvas', `scanStageFiles: ${results.length} 个文件 — ${stageDir}`);
return { success: true, data: results };
} catch (err) {
logger.warn('canvas', 'scanStageFiles 失败', err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
},
);
// ---- 扫描资产文件(深度递归) ----
ipcMain.handle(
BIDIRECTIONAL_CANVAS.CANVAS_SCAN_ASSET_FILES,
async (
_event,
params: { projectPath: string; assetTypeFilter: string },
) => {
try {
let assetsDir: string;
switch (params.assetTypeFilter) {
case '角色':
assetsDir = path.join(params.projectPath, 'assets', 'chr');
break;
case '道具':
assetsDir = path.join(params.projectPath, 'assets', 'prp');
break;
case '场景':
assetsDir = path.join(params.projectPath, 'assets', 'set');
break;
default:
assetsDir = path.join(params.projectPath, 'assets');
}
logger.info('canvas', `scanAssetFiles: ${assetsDir} (filter=${params.assetTypeFilter})`);
if (!fs.existsSync(assetsDir)) {
logger.info('canvas', `scanAssetFiles: 资产目录不存在 — ${assetsDir}`);
return { success: true, data: [] };
}
// 资产深度递归:图片 + 视频
const extensions = ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'mp4', 'webm'];
const results = walkDirectory(assetsDir, extensions, true);
logger.info('canvas', `scanAssetFiles: ${results.length} 个文件 — ${assetsDir}`);
return { success: true, data: results };
} catch (err) {
logger.warn('canvas', 'scanAssetFiles 失败', err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
},
);
// ---- 创建目录 ----
ipcMain.handle(
BIDIRECTIONAL_CANVAS.CANVAS_CREATE_DIRECTORY,
async (_event, params: { parentPath: string; dirName: string }) => {
try {
// 安全校验:拒绝包含危险字符的名称
const dangerous = /[/\\:*?"<>|]/;
if (!params.dirName || dangerous.test(params.dirName)) {
return {
success: false,
error: `无效的目录名: "${params.dirName}"`,
};
}
const fullPath = path.join(params.parentPath, params.dirName);
if (fs.existsSync(fullPath)) {
return { success: false, error: `目录已存在: ${fullPath}` };
}
fs.mkdirSync(fullPath);
logger.info('canvas', `createDirectory: ${fullPath}`);
return { success: true, data: fullPath };
} catch (err) {
logger.warn('canvas', 'createDirectory 失败', err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
},
);
// ---- 启动原生文件拖拽(拖放到 Photoshop/Explorer 等外部应用) ----
ipcMain.handle(
BIDIRECTIONAL_CANVAS.CANVAS_START_DRAG,
async (_event, params: { filePath: string; fileName: string }) => {
try {
if (!fs.existsSync(params.filePath)) {
logger.warn('canvas', `startDrag: 文件不存在 — ${params.filePath}`);
return { success: false, error: '文件不存在' };
}
_event.sender.startDrag({
file: params.filePath,
icon: params.filePath,
});
logger.info('canvas', `startDrag: 已启动 — ${params.fileName}`);
return { success: true };
} catch (err) {
const msg = (err as Error).message;
logger.warn('canvas', 'startDrag 失败', err instanceof Error ? err : undefined);
return { success: false, error: msg };
}
},
);
logger.info('canvas', '画布 IPC 处理器已注册(完整实现)');
}

View File

@@ -0,0 +1,36 @@
// ============================================================
// canvas/mime.ts — MIME 类型推断
// ============================================================
import path from 'node:path';
/** MIME 类型映射表 */
const MIME_MAP: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.bmp': 'image/bmp',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mkv': 'video/x-matroska',
'.mov': 'video/quicktime',
'.avi': 'video/x-msvideo',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
'.flac': 'audio/flac',
'.aac': 'audio/aac',
'.m4a': 'audio/mp4',
};
/**
* 根据文件扩展名推断 MIME 类型。
* @param filePath - 文件路径
* @returns MIME 类型字符串,未知类型返回 'application/octet-stream'
*/
export function getMimeType(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
return MIME_MAP[ext] || 'application/octet-stream';
}

View File

@@ -0,0 +1,189 @@
// ============================================================
// canvas/scan-roots.ts — 扫描根目录计算
//
// 职责:
// - 根据项目路径、阶段、资产类型计算扫描根目录列表
// - 支持 shots 和 assets 两种目录结构
// ============================================================
import fs from 'node:fs';
import path from 'node:path';
import { logger } from '../../logger';
import type { StageType, AssetTypeFilter } from './types';
/** 阶段 → 允许的扩展名 */
export function getStageExtensions(stage: StageType): string[] {
switch (stage) {
case 'Storyboard':
case 'Keyframe':
return ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif'];
case 'Video':
return ['mp4', 'webm', 'mov', 'avi', 'mkv'];
case 'Audio':
case 'LipSync':
return ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'];
case 'Assets':
return ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'mp4', 'webm'];
}
}
/** 镜头名匹配查询 */
export function shotNameMatchesQuery(shotName: string, query: string): boolean {
if (!query) return true;
const upperShot = shotName.toUpperCase();
const upperQuery = query.toUpperCase();
if (upperShot.includes(upperQuery)) return true;
if (/^\d+$/.test(query)) {
const digits = upperShot.replace(/\D/g, '');
if (digits.includes(query)) return true;
}
return false;
}
/**
* 计算扫描根目录列表。
*
* @param params - 扫描参数
* @returns 扫描根目录路径列表
*/
export function buildScanRoots(params: {
projectPath: string;
stage: StageType;
assetTypeFilter: AssetTypeFilter;
shotQuery: string;
}): string[] {
const { projectPath, stage, assetTypeFilter, shotQuery } = params;
// ── 诊断:检查 projectPath 本身是否存在 ──
const projectExists = fs.existsSync(projectPath);
logger.info('canvas', 'buildScanRoots 诊断 — projectPath', {
projectPath,
exists: projectExists,
});
if (!projectExists) {
logger.warn('canvas', '⚠️ 项目路径不可访问', undefined, { projectPath });
// 尝试列出父目录内容以便排查
const parent = path.dirname(projectPath);
try {
const siblings = fs.readdirSync(parent).filter((n) => !n.startsWith('.'));
logger.info('canvas', `父目录 "${parent}" 内容(前 20 项)`, {
count: siblings.length,
items: siblings.slice(0, 20),
});
} catch {
logger.warn('canvas', `无法读取父目录 "${parent}"`);
}
return [];
}
// ── 诊断:列出项目根目录内容 ──
let projectEntries: string[] = [];
try {
projectEntries = fs.readdirSync(projectPath).filter((n) => !n.startsWith('.'));
} catch {
/* ignore */
}
logger.info('canvas', 'buildScanRoots 诊断 — 项目根目录内容(前 30 项)', {
count: projectEntries.length,
items: projectEntries.slice(0, 30),
});
if (stage === 'Assets') {
const assetsDir = path.join(projectPath, 'assets');
logger.info('canvas', 'buildScanRoots — Assets 模式', {
assetsDir,
exists: fs.existsSync(assetsDir),
assetTypeFilter,
});
switch (assetTypeFilter) {
case '角色': return [path.join(assetsDir, 'chr')];
case '道具': return [path.join(assetsDir, 'prp')];
case '场景': return [path.join(assetsDir, 'set')];
default: return [assetsDir];
}
}
const shotsDir = path.join(projectPath, 'shots');
const shotsExists = fs.existsSync(shotsDir);
logger.info('canvas', 'buildScanRoots 诊断 — shotsDir', {
shotsDir,
exists: shotsExists,
});
if (!shotsExists) {
// 列出可能存在的子目录帮助用户对比
const dirsInRoot = projectEntries.filter((name) => {
try {
return fs.statSync(path.join(projectPath, name)).isDirectory();
} catch {
return false;
}
});
logger.warn('canvas', '⚠️ shots 目录不存在,项目根下的子目录:', undefined, {
subdirs: dirsInRoot,
});
return [];
}
const roots: string[] = [];
let episodes: string[];
try {
episodes = fs.readdirSync(shotsDir).filter((name) => {
const full = path.join(shotsDir, name);
return !name.startsWith('.') && fs.statSync(full).isDirectory();
});
} catch (err) {
logger.warn('canvas', '无法读取 shots 目录', err instanceof Error ? err : undefined, {
error: (err as Error).message,
});
return [];
}
logger.info('canvas', `buildScanRoots — 发现 ${episodes.length} 个剧集目录`, {
episodes,
});
for (const ep of episodes) {
const epDir = path.join(shotsDir, ep);
let shotNames: string[];
try {
shotNames = fs.readdirSync(epDir).filter((name) => {
const full = path.join(epDir, name);
return !name.startsWith('.') && fs.statSync(full).isDirectory();
});
} catch (err) {
logger.warn(
'canvas',
`无法读取剧集目录 "${ep}"`,
err instanceof Error ? err : undefined,
{ error: (err as Error).message },
);
continue;
}
logger.info('canvas', ` 📂 ${ep}${shotNames.length} 个镜头`, {
shots: shotNames.slice(0, 20),
});
for (const shot of shotNames) {
if (shotQuery && !shotNameMatchesQuery(shot, shotQuery)) continue;
const stageDir = path.join(epDir, shot, stage);
const stageExists = fs.existsSync(stageDir);
if (stageExists) {
roots.push(stageDir);
}
// 仅在前几个镜头时记录阶段目录存在性
if (roots.length <= 3 && !stageExists) {
logger.info('canvas', ` 阶段目录不存在: ${stageDir}`);
}
}
}
logger.info('canvas', `buildScanRoots 最终结果:${roots.length} 个扫描根`, {
roots: roots.slice(0, 10),
stage,
});
return roots;
}

View File

@@ -0,0 +1,47 @@
// ============================================================
// canvas/sidecar.ts — Sidecar 元数据操作
//
// 职责:
// - 读取/写入旁加载 JSON 元数据文件
// - sidecar 文件与媒体文件同目录、同名、.json 扩展名
// ============================================================
import fs from 'node:fs';
import path from 'node:path';
/** 生成 sidecar JSON 文件路径:同目录、同名、.json 扩展名 */
export function sidecarPathForMedia(filePath: string): string {
const dir = path.dirname(filePath);
const baseName = path.basename(filePath, path.extname(filePath));
return path.join(dir, `${baseName}.json`);
}
/**
* 读取旁加载元数据。
* @param filePath - 媒体文件路径
* @returns 元数据对象,文件不存在返回 null
*/
export function readSidecarFile(filePath: string): object | null {
const sidecarPath = sidecarPathForMedia(filePath);
if (!fs.existsSync(sidecarPath)) return null;
try {
const content = fs.readFileSync(sidecarPath, 'utf-8');
return JSON.parse(content);
} catch {
return null;
}
}
/**
* 写入旁加载元数据(自动创建目录)。
* @param filePath - 媒体文件路径
* @param metadata - 元数据对象
*/
export function writeSidecarFile(filePath: string, metadata: object): void {
const sidecarPath = sidecarPathForMedia(filePath);
const dir = path.dirname(sidecarPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(sidecarPath, JSON.stringify(metadata, null, 2), 'utf-8');
}

View File

@@ -0,0 +1,84 @@
// ============================================================
// canvas/thumbnail.ts — 画布缩略图生成
//
// 职责:
// - 生成画布专用缩略图(返回 base64 数据)
// - 支持缓存优先策略(.cache 目录)
//
// 优先级:
// 1. .cache/{filename}.webp — 预生成 WEBP 缩略图(最快)
// 2. .cache/{filename} — 预生成 PNG 缩略图
// 3. sharp — 实时缩放(可选依赖)
// 4. 原始文件字节 — 浏览器端缩放回退
// ============================================================
import fs from 'node:fs';
import path from 'node:path';
import sharp from 'sharp';
import { getMimeType } from './mime';
import type { ThumbnailResult } from './types';
/**
* 生成画布缩略图。
*
* @param filePath - 源文件路径
* @param size - 缩略图尺寸
* @returns 缩略图数据base64
*/
export async function generateCanvasThumbnail(
filePath: string,
size: number,
): Promise<ThumbnailResult> {
const dir = path.dirname(filePath);
const baseName = path.basename(filePath);
const cacheDir = path.join(dir, '.cache');
// ── 优先级 1.cache/{filename}.webp ──
const cachedWebp = path.join(cacheDir, `${baseName}.webp`);
if (fs.existsSync(cachedWebp)) {
const buffer = fs.readFileSync(cachedWebp);
return {
data: buffer.toString('base64'),
mimeType: 'image/webp',
width: 0,
height: 0,
};
}
// ── 优先级 2.cache/{filename}PNG 副本) ──
const cachedPng = path.join(cacheDir, baseName);
if (fs.existsSync(cachedPng)) {
const buffer = fs.readFileSync(cachedPng);
return {
data: buffer.toString('base64'),
mimeType: 'image/png',
width: 0,
height: 0,
};
}
if (sharp) {
const image = sharp(filePath);
const metadata = await image.metadata();
const resized = await image
.resize(size, size, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 85 })
.toBuffer();
return {
data: resized.toString('base64'),
mimeType: 'image/jpeg',
width: metadata.width || 0,
height: metadata.height || 0,
};
}
// ── 优先级 4回退原始文件 ──
const buffer = fs.readFileSync(filePath);
const mimeType = getMimeType(filePath);
return {
data: buffer.toString('base64'),
mimeType,
width: 0,
height: 0,
};
}

View File

@@ -0,0 +1,26 @@
// ============================================================
// canvas/types.ts — 画布相关类型定义
// ============================================================
/** 阶段类型 */
export type StageType = 'Storyboard' | 'Keyframe' | 'Video' | 'Audio' | 'LipSync' | 'Assets';
/** 资产类型过滤器 */
export type AssetTypeFilter = '全部' | '角色' | '道具' | '场景';
/** 扫描结果条目 */
export interface ScanEntry {
path: string;
fileName: string;
ext: string;
fileSize: number;
modifiedAt: string;
}
/** 缩略图结果 */
export interface ThumbnailResult {
data: string;
mimeType: string;
width: number;
height: number;
}

View File

@@ -0,0 +1,66 @@
// ============================================================
// canvas/walk.ts — 目录遍历算法
//
// 职责:
// - 递归遍历目录,收集所有匹配扩展名的文件
// - 跳过隐藏文件和文件夹
// ============================================================
import fs from 'node:fs';
import path from 'node:path';
import type { ScanEntry } from './types';
/**
* 递归遍历目录,收集所有匹配扩展名的文件。
*
* @param root - 根目录路径
* @param extensions - 允许的文件扩展名列表(不含点号)
* @param recursive - 是否递归遍历子目录
* @returns 文件列表
*/
export function walkDirectory(
root: string,
extensions: string[],
recursive: boolean,
): ScanEntry[] {
const results: ScanEntry[] = [];
const extSet = new Set(extensions.map((e) => e.toLowerCase()));
function walk(dir: string) {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return; // 权限不足等,跳过该目录
}
for (const entry of entries) {
// 跳过隐藏文件和文件夹
if (entry.name.startsWith('.')) continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (recursive) walk(fullPath);
} else if (entry.isFile()) {
const ext = path.extname(entry.name).slice(1).toLowerCase();
if (extSet.has(ext)) {
try {
const stat = fs.statSync(fullPath);
results.push({
path: fullPath,
fileName: entry.name,
ext,
fileSize: stat.size,
modifiedAt: stat.mtime.toISOString(),
});
} catch {
// 文件不可访问,跳过
}
}
}
}
}
walk(root);
return results;
}

View File

@@ -3,9 +3,9 @@
// ============================================================ // ============================================================
import { ipcMain } from 'electron'; import { ipcMain } from 'electron';
import { RENDERER_TO_MAIN } from '../../shared/constants/ipc-channels'; import { RENDERER_TO_MAIN } from '../../../shared/constants/ipc/base';
import { writeRendererLog } from './logger'; import { writeRendererLog } from '../logger';
import type { LogEntry } from '../../shared/types/logging'; import type { LogEntry } from '../../../shared/types/logging';
/** /**
* IPC * IPC

View File

@@ -16,8 +16,8 @@
// ============================================================ // ============================================================
import { ipcMain, safeStorage } from 'electron'; import { ipcMain, safeStorage } from 'electron';
import { BIDIRECTIONAL } from '../../shared/constants/ipc-channels'; import { BIDIRECTIONAL_STORAGE } from '../../../shared/constants/ipc/storage';
import { logger } from './logger'; import { logger } from '../logger';
/** 检查 safeStorage 是否可用,不可用时记录警告 */ /** 检查 safeStorage 是否可用,不可用时记录警告 */
function checkAvailability(): boolean { function checkAvailability(): boolean {
@@ -34,7 +34,7 @@ function checkAvailability(): boolean {
*/ */
export function registerSafeStorageIpcHandlers(): void { export function registerSafeStorageIpcHandlers(): void {
// ---------- 加密 ---------- // ---------- 加密 ----------
ipcMain.handle(BIDIRECTIONAL.SAFESTORAGE_ENCRYPT, (_event, plaintext: string) => { ipcMain.handle(BIDIRECTIONAL_STORAGE.SAFESTORAGE_ENCRYPT, (_event, plaintext: string) => {
if (typeof plaintext !== 'string' || plaintext.length === 0) { if (typeof plaintext !== 'string' || plaintext.length === 0) {
logger.warn('safe-storage', '加密参数无效'); logger.warn('safe-storage', '加密参数无效');
return { success: false, error: '参数无效:需要非空字符串' }; return { success: false, error: '参数无效:需要非空字符串' };
@@ -56,7 +56,7 @@ export function registerSafeStorageIpcHandlers(): void {
}); });
// ---------- 解密 ---------- // ---------- 解密 ----------
ipcMain.handle(BIDIRECTIONAL.SAFESTORAGE_DECRYPT, (_event, encryptedBase64: string) => { ipcMain.handle(BIDIRECTIONAL_STORAGE.SAFESTORAGE_DECRYPT, (_event, encryptedBase64: string) => {
if (typeof encryptedBase64 !== 'string' || encryptedBase64.length === 0) { if (typeof encryptedBase64 !== 'string' || encryptedBase64.length === 0) {
logger.warn('safe-storage', '解密参数无效'); logger.warn('safe-storage', '解密参数无效');
return { success: false, error: '参数无效:需要非空字符串' }; return { success: false, error: '参数无效:需要非空字符串' };

View File

@@ -0,0 +1,225 @@
// ============================================================
// storage-ipc.ts — 本地存储 IPC 处理器(主进程)
//
// 职责:
// - 文件写入/读取/删除Buffer 基础操作)
// - 递归创建目录
// - shell.showItemInFolder右键"在文件夹中查看"
// - 获取应用数据目录路径
// - 所有操作在 heixiu-data/ 沙箱内进行
//
// 安全:
// - 路径校验:拒绝相对路径中的 ../ 穿越
// - 所有写入操作 base 于 getDataDir()
// ============================================================
import { ipcMain, shell, app } from 'electron';
import fs from 'node:fs';
import path from 'node:path';
import { BIDIRECTIONAL_STORAGE } from '../../../shared/constants/ipc/storage';
import { logger } from '../logger';
import { resolveSafe, ensureDir, downloadToFile, generateThumbnail } from './utils';
// ---------- 内部工具 ----------
let dataDirResolver: (() => string) | null = null;
/** 注入自定义数据目录解析器(在 registerStorageIpcHandlers() 之前调用) */
export function setDataDirResolver(resolver: () => string): void {
dataDirResolver = resolver;
}
/** 获取 heixiu-data 根目录 */
function getDataDir(): string {
return dataDirResolver ? dataDirResolver() : path.join(app.getPath('userData'), 'heixiu-data');
}
// ---------- 注册 IPC 处理器 ----------
export function registerStorageIpcHandlers(): void {
const dataDir = getDataDir();
ensureDir(dataDir);
logger.info('app', `本地存储目录:${dataDir}`);
// ---- 获取数据目录 ----
ipcMain.handle(BIDIRECTIONAL_STORAGE.GET_DATA_DIR, () => {
return { success: true, data: dataDir };
});
// ---- 文件写入 ----
ipcMain.handle(
BIDIRECTIONAL_STORAGE.FILE_WRITE,
async (_event, params: { relativePath: string; data: string; encoding?: 'base64' | 'utf8' }) => {
try {
const filePath = resolveSafe(dataDir, params.relativePath);
ensureDir(path.dirname(filePath));
const buffer = Buffer.from(params.data, params.encoding || 'base64');
fs.writeFileSync(filePath, buffer);
logger.info('storage', `文件写入成功:${filePath}${buffer.length} bytes`);
return { success: true, data: filePath };
} catch (err) {
const msg = (err as Error).message;
logger.warn('storage', `文件写入失败:${params.relativePath}`, err instanceof Error ? err : undefined);
return { success: false, error: msg };
}
},
);
// ---- 文件读取(返回 Base64 ----
ipcMain.handle(BIDIRECTIONAL_STORAGE.FILE_READ, async (_event, params: { relativePath: string }) => {
try {
const filePath = resolveSafe(dataDir, params.relativePath);
if (!fs.existsSync(filePath)) {
return { success: false, error: '文件不存在' };
}
const buffer = fs.readFileSync(filePath);
return { success: true, data: buffer.toString('base64') };
} catch (err) {
logger.warn('storage', `文件读取失败:${params.relativePath}`, err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
});
// ---- 文件删除 ----
ipcMain.handle(BIDIRECTIONAL_STORAGE.FILE_DELETE, async (_event, params: { relativePath: string }) => {
try {
const filePath = resolveSafe(dataDir, params.relativePath);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
return { success: true };
} catch (err) {
logger.warn('storage', `文件删除失败:${params.relativePath}`, err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
});
// ---- 创建目录 ----
ipcMain.handle(BIDIRECTIONAL_STORAGE.MAKE_DIR, async (_event, params: { relativePath: string }) => {
try {
const dirPath = resolveSafe(dataDir, params.relativePath);
ensureDir(dirPath);
return { success: true, data: dirPath };
} catch (err) {
logger.warn('storage', `目录创建失败:${params.relativePath}`, err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
});
// ---- 在文件管理器中显示 ----
ipcMain.handle(BIDIRECTIONAL_STORAGE.SHOW_ITEM_IN_FOLDER, async (_event, params: { filePath: string }) => {
try {
shell.showItemInFolder(params.filePath);
return { success: true };
} catch (err) {
logger.warn('storage', `showItemInFolder 失败:${params.filePath}`, err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
});
// ---- 文件大小 ----
ipcMain.handle(BIDIRECTIONAL_STORAGE.FILE_SIZE, async (_event, params: { relativePath: string }) => {
try {
const filePath = resolveSafe(dataDir, params.relativePath);
if (!fs.existsSync(filePath)) {
return { success: false, error: '文件不存在' };
}
const stat = fs.statSync(filePath);
return { success: true, data: stat.size };
} catch (err) {
logger.warn('storage', '获取文件大小失败', err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
});
// ---- 文件/目录是否存在 ----
ipcMain.handle(BIDIRECTIONAL_STORAGE.FILE_EXISTS, async (_event, params: { relativePath: string }) => {
try {
const filePath = resolveSafe(dataDir, params.relativePath);
return { success: true, data: fs.existsSync(filePath) };
} catch {
return { success: true, data: false };
}
});
// ---- 绝对路径文件写入(绕过沙箱,用于用户自定义输出目录) ----
ipcMain.handle(
BIDIRECTIONAL_STORAGE.FILE_WRITE_ABSOLUTE,
async (_event, params: { absolutePath: string; data: string; encoding?: 'base64' | 'utf8' }) => {
try {
ensureDir(path.dirname(params.absolutePath));
const buffer = Buffer.from(params.data, params.encoding || 'base64');
fs.writeFileSync(params.absolutePath, buffer);
logger.info('storage', `绝对路径写入成功:${params.absolutePath}${buffer.length} bytes`);
return { success: true, data: params.absolutePath };
} catch (err) {
const msg = (err as Error).message;
logger.warn('storage', `绝对路径写入失败:${params.absolutePath}`, err instanceof Error ? err : undefined);
return { success: false, error: msg };
}
},
);
// ---- 绝对路径目录创建(绕过沙箱) ----
ipcMain.handle(BIDIRECTIONAL_STORAGE.MAKE_DIR_ABSOLUTE, async (_event, params: { absolutePath: string }) => {
try {
ensureDir(params.absolutePath);
return { success: true, data: params.absolutePath };
} catch (err) {
logger.warn('storage', `绝对路径目录创建失败:${params.absolutePath}`, err instanceof Error ? err : undefined);
return { success: false, error: (err as Error).message };
}
});
// ---- 获取系统下载目录 ----
ipcMain.handle(BIDIRECTIONAL_STORAGE.GET_DOWNLOADS_PATH, () => {
try {
return { success: true, data: app.getPath('downloads') };
} catch {
return { success: false, error: '无法获取下载目录' };
}
});
// ---- 主进程下载文件到绝对路径(绕过浏览器 CORS ----
ipcMain.handle(
BIDIRECTIONAL_STORAGE.DOWNLOAD_TO_PATH,
async (
_event,
params: { url: string; destPath: string; headers?: Record<string, string> },
): Promise<{ success: boolean; data?: string; error?: string }> => {
const { url, destPath, headers } = params;
try {
await downloadToFile(url, destPath, headers);
logger.info('storage', `主进程下载成功:${destPath}`);
return { success: true, data: destPath };
} catch (err) {
const msg = (err as Error).message || '下载失败';
logger.warn('storage', `主进程下载失败:${url}`, err instanceof Error ? err : undefined);
return { success: false, error: msg };
}
},
);
// ---- 生成图片缩略图sharp 流式管线) ----
ipcMain.handle(
BIDIRECTIONAL_STORAGE.GENERATE_THUMBNAIL,
async (
_event,
params: { srcPath: string; destPath: string; maxWidth?: number; quality?: number },
): Promise<{ success: boolean; data?: string; error?: string }> => {
const { srcPath, destPath, maxWidth = 256, quality = 60 } = params;
try {
await generateThumbnail(srcPath, destPath, maxWidth, quality);
logger.info('storage', `缩略图生成成功:${destPath}`);
return { success: true, data: destPath };
} catch (err) {
const msg = (err as Error).message || '缩略图生成失败';
logger.warn('storage', `缩略图生成失败:${srcPath}`, err instanceof Error ? err : undefined);
return { success: false, error: msg };
}
},
);
logger.info('app', '存储 IPC 处理器已注册');
}

View File

@@ -0,0 +1,97 @@
// ============================================================
// download.ts — 文件下载工具函数
//
// 职责:
// - 通过 Electron net 模块下载文件到磁盘
// - 支持重定向、超时、自定义请求头
//
// 设计:
// - net 基于 Chromium 网络栈,自动携带浏览器标准头,不受 CORS 限制
// - 不能用 netRequest它 chunk.toString() 会损坏二进制数据)
// - 不能用 Node.js https缺少浏览器头导致 CDN 返回 400
// ============================================================
import { net } from 'electron';
import fs from 'node:fs';
import path from 'node:path';
import { ensureDir } from './path';
/**
* 通过 Electron net 模块下载文件到磁盘。
*
* @param url - 下载地址
* @param destPath - 目标文件路径
* @param headers - 可选的自定义请求头
*/
export function downloadToFile(
url: string,
destPath: string,
headers?: Record<string, string>,
): Promise<void> {
return new Promise((resolve, reject) => {
const req = net.request({ method: 'GET', url });
// 自定义请求头
if (headers) {
for (const [key, value] of Object.entries(headers)) {
req.setHeader(key, value);
}
}
// 超时 120s
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
req.abort();
reject(new Error('下载超时120s'));
}, 120_000);
req.on('response', (response) => {
clearTimeout(timer);
const status = response.statusCode;
// 3xx 重定向
if (status >= 300 && status < 400) {
const location = response.headers['location']?.[0];
if (location && location !== url) {
downloadToFile(location, destPath, headers).then(resolve).catch(reject);
} else {
reject(new Error('重定向循环或缺少 Location 头'));
}
response.on('data', () => {}); // 消费响应体
return;
}
if (status >= 400) {
reject(new Error(`HTTP ${status}`));
response.on('data', () => {});
return;
}
// 收集二进制 chunks → 写入文件
const chunks: Buffer[] = [];
response.on('data', (chunk: Buffer) => chunks.push(chunk));
response.on('end', () => {
try {
ensureDir(path.dirname(destPath));
const buffer = Buffer.concat(chunks);
fs.writeFileSync(destPath, buffer);
resolve();
} catch (err) {
reject(err);
}
});
response.on('error', (err) => {
reject(new Error(`响应读取失败: ${err.message}`));
});
});
req.on('error', (err) => {
clearTimeout(timer);
if (timedOut) return;
reject(new Error(`网络请求失败: ${err.message}`));
});
req.end();
});
}

View File

@@ -0,0 +1,7 @@
// ============================================================
// utils/index.ts — 工具函数汇总导出
// ============================================================
export { resolveSafe, ensureDir } from './path';
export { downloadToFile } from './download';
export { generateThumbnail } from './thumbnail';

View File

@@ -0,0 +1,29 @@
// ============================================================
// path.ts — 路径安全工具函数
//
// 职责:
// - 路径穿越防护
// - 目录创建
// ============================================================
import fs from 'node:fs';
import path from 'node:path';
/**
* 将相对路径解析为绝对路径,并校验是否在 dataDir 内。
* 防止路径穿越攻击(../ 穿越)。
*/
export function resolveSafe(dataDir: string, relativePath: string): string {
const resolved = path.resolve(dataDir, relativePath);
if (!resolved.startsWith(dataDir + path.sep) && resolved !== dataDir) {
throw new Error(`路径越界:${relativePath}`);
}
return resolved;
}
/** 确保目录存在(递归创建) */
export function ensureDir(dirPath: string): void {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}

View File

@@ -0,0 +1,60 @@
// ============================================================
// thumbnail.ts — 缩略图生成工具函数
//
// 职责:
// - 使用 sharp 库生成图片缩略图
// - 内存优化配置
//
// 依赖:
// - sharp (libvips) - 可选依赖,用于高效图片处理
// ============================================================
import fs from 'node:fs';
import path from 'node:path';
import sharp from 'sharp';
import { ensureDir } from './path';
// ---------- sharp/libvips 内存优化 ----------
// 单块内存上限 128MB默认 512MB降低以适配 Electron 单进程内存压力)
sharp.cache({ memory: 128, items: 100 });
// libvips 并发数 1减少大图并发处理时的内存峰值
sharp.concurrency(1);
/**
* 生成图片缩略图sharp 流式管线)
*
* 特性:
* - shrinkOnLoadPNG/JPEG/WebP/TIFF 解码器直接降采样8000px 原图解码即降至 ~512px
* - sequentialRead顺序读取减少随机 I/O 内存开销
* - limitInputPixels默认 268MP16384²覆盖 8K+ 素材
*
* @param srcPath - 源文件路径
* @param destPath - 目标文件路径
* @param maxWidth - 最大宽度(默认 256
* @param quality - JPEG 质量(默认 60
* @returns 目标文件路径
*/
export async function generateThumbnail(
srcPath: string,
destPath: string,
maxWidth: number = 256,
quality: number = 60,
): Promise<string> {
if (!fs.existsSync(srcPath)) {
throw new Error('源文件不存在');
}
ensureDir(path.dirname(destPath));
await sharp(srcPath, {
animated: false, // 只取第一帧GIF/APNG 等多帧格式)
sequentialRead: true,
limitInputPixels: 268402689,
})
.resize({ width: maxWidth, withoutEnlargement: true, fit: 'inside' })
.jpeg({ quality, mozjpeg: true }) // mozjpeg 压缩率更优
.toFile(destPath);
return destPath;
}

View File

@@ -28,6 +28,7 @@ import { sanitizeLogEntry } from '../../shared/utils/sanitize';
let writeStream: WriteStream | null = null; let writeStream: WriteStream | null = null;
let currentDate = ''; let currentDate = '';
let logDir = ''; let logDir = '';
let logDirResolver: (() => string) | null = null;
const writeQueue: string[] = []; const writeQueue: string[] = [];
let isWriting = false; let isWriting = false;
const MAX_DAYS = 7; const MAX_DAYS = 7;
@@ -46,7 +47,7 @@ function getLogFilePath(date: string): string {
function ensureDir(): void { function ensureDir(): void {
if (logDir) return; if (logDir) return;
try { try {
logDir = join(app.getPath('userData'), 'logs'); logDir = logDirResolver ? logDirResolver() : join(app.getPath('userData'), 'logs');
} catch { } catch {
// app.getPath 尚不可用app.ready 之前),静默跳过 // app.getPath 尚不可用app.ready 之前),静默跳过
return; return;
@@ -216,6 +217,11 @@ export const logger = {
}, },
}; };
/** 注入自定义日志目录解析器(在 initLogger() 之前调用) */
export function setLogDirResolver(resolver: () => string): void {
logDirResolver = resolver;
}
/** 预热日志模块(在 app.whenReady() 中调用) */ /** 预热日志模块(在 app.whenReady() 中调用) */
export function initLogger(): void { export function initLogger(): void {
ensureDir(); ensureDir();

View File

@@ -16,6 +16,7 @@
// ============================================================ // ============================================================
import { net } from 'electron'; import { net } from 'electron';
import { logger } from './logger';
// ---------- 类型 ---------- // ---------- 类型 ----------
@@ -128,9 +129,7 @@ export async function request<T = unknown>(
try { try {
void new URL(fullUrl); void new URL(fullUrl);
} catch (urlErr) { } catch (urlErr) {
console.error('[net-request] 非法 URL:', JSON.stringify(fullUrl)); logger.warn('request', `非法 URL: ${fullUrl}`, urlErr instanceof Error ? urlErr : undefined);
console.error('[net-request] URL 字符码:', [...fullUrl].map(c => c.charCodeAt(0)).join(','));
console.error('[net-request] config:', JSON.stringify(config));
reject(new RequestError(`非法 URL: ${(urlErr as Error).message}`)); reject(new RequestError(`非法 URL: ${(urlErr as Error).message}`));
return; return;
} }
@@ -213,19 +212,19 @@ export async function request<T = unknown>(
export const netRequest = { export const netRequest = {
request, request,
get<T>(url: string, config?: Omit<RequestConfig, 'method' | 'data'>) { async get<T>(url: string, config?: Omit<RequestConfig, 'method' | 'data'>) {
return request<T>(url, { ...config, method: 'GET' }); return await request<T>(url, { ...config, method: 'GET' });
}, },
post<T>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'data'>) { async post<T>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'data'>) {
return request<T>(url, { ...config, method: 'POST', data }); return request<T>(url, { ...config, method: 'POST', data });
}, },
put<T>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'data'>) { async put<T>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'data'>) {
return request<T>(url, { ...config, method: 'PUT', data }); return request<T>(url, { ...config, method: 'PUT', data });
}, },
del<T>(url: string, config?: Omit<RequestConfig, 'method' | 'data'>) { async del<T>(url: string, config?: Omit<RequestConfig, 'method' | 'data'>) {
return request<T>(url, { ...config, method: 'DELETE' }); return request<T>(url, { ...config, method: 'DELETE' });
}, },
}; };

View File

@@ -1,435 +0,0 @@
// 自动更新 — HeiXiuProvider + electron-updater 增量下载
// 流程图:调用自有 API → 获取版本信息 → electron-updater 差分下载 → SHA512 校验 → 安装
import {BrowserWindow, ipcMain} from 'electron';
import {URL} from 'node:url';
import {
NsisUpdater,
MacUpdater,
AppImageUpdater,
Provider,
} from 'electron-updater';
import type {ProviderRuntimeOptions} from 'electron-updater/out/providers/Provider';
import type {UpdateInfo, UpdateFileInfo} from 'builder-util-runtime';
import type {ResolvedUpdateFileInfo} from 'electron-updater/out/types';
import type {UpdateCheckResult} from '../../shared/types';
import {APP_VERSION} from '../../shared/constants/version';
import {logger} from './logger';
import {netRequest} from './net-request';
// ---------- IPC 通道(对渲染进程暴露,与旧版兼容)----------
export const UPDATE_CHANNELS = {
UPDATE_AVAILABLE: 'update-available',
UPDATE_PROGRESS: 'update-progress',
UPDATE_DOWNLOADED: 'update-downloaded',
UPDATE_ERROR: 'update-error',
UPDATE_NOT_AVAILABLE: 'update-not-available',
INSTALL_UPDATE: 'install-update',
CHECK_FOR_UPDATE: 'check-for-update',
DOWNLOAD_UPDATE: 'download-update',
} as const;
// ---------- 配置 ----------
function getApiBaseUrl(): string {
const url = (process.env.UPDATE_API_URL || 'https://www.heixiu.com').trim();
console.log(`[updater] UPDATE_API_URL=${process.env.UPDATE_API_URL} → 使用: ${url}`);
return url;
}
// ============================================================
// 自定义 Provider
// ============================================================
class HeiXiuProvider extends Provider<UpdateInfo> {
private apiBaseUrl: string;
/** 短时缓存:避免 checkForUpdates 预检 + electron-updater 内部调用导致两次 API 请求 */
private _cachedResult: { data: UpdateInfo; ts: number } | null = null;
constructor(runtimeOptions: ProviderRuntimeOptions) {
super(runtimeOptions);
this.apiBaseUrl = getApiBaseUrl();
}
/**
* 调用自有版本检查 API返回 electron-updater 格式的 UpdateInfo。
* blockMapSize > 0 时 electron-updater 自动启用差分下载。
* 无更新时返回当前版本号APP_VERSION
*/
async getLatestVersion(): Promise<UpdateInfo> {
// 短时缓存500ms预检和 electron-updater 内部调用共享同一结果
if (this._cachedResult && Date.now() - this._cachedResult.ts < 500) {
return this._cachedResult.data;
}
const data = await this._doGetLatestVersion();
this._cachedResult = { data, ts: Date.now() };
return data;
}
private async _doGetLatestVersion(): Promise<UpdateInfo> {
const {data} = await netRequest.get<{ code: number; data?: UpdateCheckResult } & UpdateCheckResult>(
'/api/v1/update/check',
{
baseURL: this.apiBaseUrl,
params: {
platform: process.platform,
version: APP_VERSION,
edition: process.env.EDITION || 'personal',
},
},
);
// 兼容两种 API 响应格式
const result: UpdateCheckResult =
(data as { data?: UpdateCheckResult }).data ?? (data as UpdateCheckResult);
// 无更新 → 返回当前版本号
if (!result.hasUpdate) {
return {
version: APP_VERSION,
files: [],
path: '',
sha512: '',
releaseDate: new Date().toISOString(),
};
}
const file: UpdateFileInfo = {
url: result.downloadUrl,
sha512: result.sha512,
size: result.fileSize,
};
// 后端 API 返回 blockMapSize 时启用增量更新
if (result.blockMapSize) {
file.blockMapSize = result.blockMapSize;
}
logger.info('updater', 'Provider 获取到新版本', {
latestVersion: result.latestVersion,
hasBlockmap: file.blockMapSize != null,
});
return {
version: result.latestVersion,
files: [file],
path: result.downloadUrl,
sha512: result.sha512,
releaseDate: result.releaseDate,
releaseNotes: result.changelog,
};
}
/**
* 将 UpdateInfo 中的相对路径解析为完整的下载 URL。
* electron-updater 在下载前调用此方法。
*/
resolveFiles(updateInfo: UpdateInfo): Array<ResolvedUpdateFileInfo> {
return updateInfo.files.map((f) => {
// 使用 downloadUrl 作为 base URL 来解析文件路径
const baseUrl = new URL(updateInfo.path);
const fileUrl = new URL(f.url, baseUrl.origin);
return {
url: fileUrl,
info: f,
};
});
}
}
// ============================================================
// 平台特定 Updater注入自定义 Provider
// ============================================================
/**
* 覆盖 getUpdateInfoAndProvider() 以注入 HeiXiuProvider。
* 其他行为NSIS 安装、签名校验等)完全由父类处理。
*/
class HeiXiuNsisUpdater extends NsisUpdater {
private heiXiuProvider: HeiXiuProvider;
constructor(heiXiuProvider: HeiXiuProvider) {
// 传 undefined 跳过内置 Provider 创建,由我们自己注入
super(undefined);
this.heiXiuProvider = heiXiuProvider;
this.autoDownload = false; // 由我们手动控制流程,保持 IPC 兼容
this.autoInstallOnAppQuit = false;
this.forceDevUpdateConfig = true; // 绕过 electron-updater 的 dev 模式检查
}
/** @internal — 注入自定义 Provider */
protected override async getUpdateInfoAndProvider(): Promise<{
info: UpdateInfo;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
provider: Provider<any>;
}> {
const info = await this.heiXiuProvider.getLatestVersion();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return {info, provider: this.heiXiuProvider as Provider<any>};
}
}
class HeiXiuMacUpdater extends MacUpdater {
private heiXiuProvider: HeiXiuProvider;
constructor(heiXiuProvider: HeiXiuProvider) {
super(undefined);
this.heiXiuProvider = heiXiuProvider;
this.autoDownload = false;
this.autoInstallOnAppQuit = false;
}
protected override async getUpdateInfoAndProvider(): Promise<{
info: UpdateInfo;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
provider: Provider<any>;
}> {
const info = await this.heiXiuProvider.getLatestVersion();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return {info, provider: this.heiXiuProvider as Provider<any>};
}
}
class HeiXiuAppImageUpdater extends AppImageUpdater {
private heiXiuProvider: HeiXiuProvider;
constructor(heiXiuProvider: HeiXiuProvider) {
super(null);
this.heiXiuProvider = heiXiuProvider;
this.autoDownload = false;
this.autoInstallOnAppQuit = false;
}
protected override async getUpdateInfoAndProvider(): Promise<{
info: UpdateInfo;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
provider: Provider<any>;
}> {
const info = await this.heiXiuProvider.getLatestVersion();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return {info, provider: this.heiXiuProvider as Provider<any>};
}
}
/** 工厂:按平台创建对应的 Updater 实例 */
function createPlatformUpdater(provider: HeiXiuProvider) {
switch (process.platform) {
case 'win32':
return new HeiXiuNsisUpdater(provider);
case 'darwin':
return new HeiXiuMacUpdater(provider);
default:
return new HeiXiuAppImageUpdater(provider);
}
}
// ---------- 状态 ----------
let updateWindow: BrowserWindow | null = null;
let provider: HeiXiuProvider | null = null;
let platformUpdater: NsisUpdater | MacUpdater | AppImageUpdater | null = null;
// ============================================================
// 初始化
// ============================================================
export function initUpdater(mainWindow: BrowserWindow): void {
updateWindow = mainWindow;
console.log('[updater] initUpdater 被调用API URL:', getApiBaseUrl());
// if (import.meta.env.DEV) {
// logger.info('updater', '开发模式,跳过自动检查更新', { apiUrl: getApiBaseUrl() });
// return;
// }
// 创建自定义 Provider
provider = new HeiXiuProvider({
isUseMultipleRangeRequest: true,
platform: process.platform as ProviderRuntimeOptions['platform'],
executor: null as unknown as ProviderRuntimeOptions['executor'],
});
// 创建平台 Updater
platformUpdater = createPlatformUpdater(provider);
// 将 electron-updater 事件桥接到现有 IPC 通道
platformUpdater.on('checking-for-update', () => {
logger.debug('updater', '正在检查更新');
});
platformUpdater.on('update-available', (info: UpdateInfo) => {
// releaseNotes 可能是 string | ReleaseNoteInfo[] | null统一转为 string
let notes = '';
if (typeof info.releaseNotes === 'string') {
notes = info.releaseNotes;
} else if (Array.isArray(info.releaseNotes)) {
notes = info.releaseNotes.map((n) => n.note).filter(Boolean).join('\n');
}
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_AVAILABLE, {
version: info.version,
releaseDate: info.releaseDate,
releaseNotes: notes,
forceUpdate: false,
});
logger.info('updater', '发现新版本', {
version: info.version,
releaseNotes: notes ? `${notes.slice(0, 50)}...` : '(空)',
});
});
platformUpdater.on('update-not-available', (info: UpdateInfo) => {
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_NOT_AVAILABLE);
logger.info('updater', '当前已是最新版本', {currentVersion: info.version});
});
platformUpdater.on('download-progress', (progress: {
percent: number;
bytesPerSecond: number;
transferred: number;
total: number
}) => {
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_PROGRESS, {
percent: Math.round(progress.percent),
bytesPerSecond: progress.bytesPerSecond,
transferred: progress.transferred,
total: progress.total,
});
});
platformUpdater.on('update-downloaded', () => {
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_DOWNLOADED);
logger.info('updater', '更新已下载完成');
});
platformUpdater.on('error', (error: Error) => {
logger.error('updater', '更新流程出错', error);
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
message: error.message || '更新失败',
});
});
// 启动 5 秒后静默检查
setTimeout(() => {
checkForUpdates();
}, 5000);
}
// ============================================================
// 操作方法
// ============================================================
let updateChecked = false;
/** 静默检查更新(自动下载) */
export async function checkForUpdates(): Promise<void> {
if (updateChecked) {
logger.debug('updater', '已检查过更新,跳过');
return;
}
if (!platformUpdater || !provider) return;
try {
updateChecked = true;
// ① 先自行检查有无更新(不经过 electron-updater
const info = await provider.getLatestVersion();
if (info.version === APP_VERSION) {
// 无更新 → 直接通知 UI不触发 electron-updater 的任何事件
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_NOT_AVAILABLE);
logger.debug('updater', '当前已是最新版本', {version: APP_VERSION});
return;
}
// ② 有新版本 → 交给 electron-updater 触发 'update-available' 事件(不自动下载)
await platformUpdater.checkForUpdates();
} catch (error) {
updateChecked = false;
logger.error('updater', '更新流程失败', error as Error, {apiUrl: getApiBaseUrl()});
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
message: (error as Error).message || '更新检查失败',
});
}
}
/** 手动检查更新(用户点击触发) */
export async function checkForUpdatesManual(): Promise<void> {
if (!platformUpdater || !provider) return;
try {
updateChecked = false;
// ① 先自行检查有无更新
const info = await provider.getLatestVersion();
if (info.version === APP_VERSION) {
// 无更新 → 直接通知 UI
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_NOT_AVAILABLE);
logger.debug('updater', '手动检查:当前已是最新版本', {version: APP_VERSION});
return;
}
// ② 有新版本 → 交给 electron-updater 触发 'update-available' 事件(不自动下载)
await platformUpdater.checkForUpdates();
} catch (error) {
logger.error('updater', '手动更新失败', error as Error);
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
message: (error as Error).message || '检查更新失败',
});
}
}
/** 用户确认后开始下载更新 */
export async function downloadUpdate(): Promise<void> {
if (!platformUpdater) {
logger.warn('updater', 'downloadUpdate: Updater 未初始化');
return;
}
try {
await platformUpdater.downloadUpdate();
} catch (error) {
logger.error('updater', '下载更新失败', error as Error);
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
message: (error as Error).message || '下载更新失败',
});
}
}
/** 安装已下载的更新并退出应用 */
export function installUpdateNow(): void {
if (!platformUpdater) {
logger.warn('updater', 'Updater 未初始化');
return;
}
logger.info('updater', '开始安装更新并退出应用');
setImmediate(() => {
platformUpdater!.quitAndInstall(false, true);
});
}
// ============================================================
// IPC 注册
// ============================================================
export function registerUpdateIpcHandlers(): void {
ipcMain.handle(UPDATE_CHANNELS.CHECK_FOR_UPDATE, async () => {
await checkForUpdatesManual();
});
ipcMain.handle(UPDATE_CHANNELS.DOWNLOAD_UPDATE, async () => {
await downloadUpdate();
});
ipcMain.on(UPDATE_CHANNELS.INSTALL_UPDATE, () => {
installUpdateNow();
});
}

View File

@@ -0,0 +1,219 @@
// ============================================================
// updater/index.ts — 自动更新主入口
//
// 流程图:调用自有 API → 获取版本信息 → electron-updater 差分下载 → SHA512 校验 → 安装
// ============================================================
import { BrowserWindow, ipcMain } from 'electron';
import type { NsisUpdater, MacUpdater, AppImageUpdater } from 'electron-updater';
import type { UpdateInfo } from 'builder-util-runtime';
import { APP_VERSION } from '../../../shared/constants/version';
import { MAIN_TO_RENDERER_UPDATER, RENDERER_TO_MAIN_UPDATER } from '../../../shared/constants/ipc/updater';
import { logger } from '../logger';
import { HeiXiuProvider, getApiBaseUrl, getDeviceFingerprint, BUILD_CHANNEL, BUILD_EDITION } from './provider';
import { createPlatformUpdater } from './platform-updaters';
// ---------- 状态 ----------
let updateWindow: BrowserWindow | null = null;
let provider: HeiXiuProvider | null = null;
let platformUpdater: NsisUpdater | MacUpdater | AppImageUpdater | null = null;
// ============================================================
// 初始化
// ============================================================
export function initUpdater(mainWindow: BrowserWindow): void {
updateWindow = mainWindow;
console.log('[updater] initUpdater 被调用');
console.log(`[updater] API URL: ${getApiBaseUrl()}`);
console.log(`[updater] 渠道: ${BUILD_CHANNEL}`);
console.log(`[updater] 版本类型: ${BUILD_EDITION}`);
console.log(`[updater] 当前版本: ${APP_VERSION}`);
console.log(`[updater] 设备指纹: ${getDeviceFingerprint()}`);
// 创建自定义 Provider
provider = new HeiXiuProvider({
isUseMultipleRangeRequest: true,
platform: process.platform as 'win32' | 'darwin' | 'linux',
executor: null as never,
});
// 创建平台 Updater
platformUpdater = createPlatformUpdater(provider);
// 将 electron-updater 事件桥接到现有 IPC 通道
platformUpdater.on('checking-for-update', () => {
logger.debug('updater', '正在检查更新');
});
platformUpdater.on('update-available', (info: UpdateInfo) => {
// releaseNotes 可能是 string | ReleaseNoteInfo[] | null统一转为 string
let notes = '';
if (typeof info.releaseNotes === 'string') {
notes = info.releaseNotes;
} else if (Array.isArray(info.releaseNotes)) {
notes = info.releaseNotes.map((n) => n.note).filter(Boolean).join('\n');
}
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_AVAILABLE, {
version: info.version,
releaseDate: info.releaseDate,
releaseNotes: notes,
forceUpdate: false,
});
logger.info('updater', '发现新版本', {
version: info.version,
releaseNotes: notes ? `${notes.slice(0, 50)}...` : '(空)',
});
});
platformUpdater.on('update-not-available', (info: UpdateInfo) => {
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_NOT_AVAILABLE);
logger.info('updater', '当前已是最新版本', { currentVersion: info.version });
});
platformUpdater.on('download-progress', (progress: {
percent: number;
bytesPerSecond: number;
transferred: number;
total: number;
}) => {
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_PROGRESS, {
percent: Math.round(progress.percent),
bytesPerSecond: progress.bytesPerSecond,
transferred: progress.transferred,
total: progress.total,
});
});
platformUpdater.on('update-downloaded', () => {
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_DOWNLOADED);
logger.info('updater', '更新已下载完成');
});
platformUpdater.on('error', (error: Error) => {
logger.error('updater', '更新流程出错', error);
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_ERROR, {
message: error.message || '更新失败',
});
});
// 启动 5 秒后静默检查
setTimeout(() => {
checkForUpdates();
}, 5000);
}
// ============================================================
// 操作方法
// ============================================================
let updateChecked = false;
/** 静默检查更新(自动下载) */
export async function checkForUpdates(): Promise<void> {
if (updateChecked) {
logger.debug('updater', '已检查过更新,跳过');
return;
}
if (!platformUpdater || !provider) return;
try {
updateChecked = true;
// ① 先自行检查有无更新(不经过 electron-updater
const info = await provider.getLatestVersion();
if (info.version === APP_VERSION) {
// 无更新 → 直接通知 UI不触发 electron-updater 的任何事件
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_NOT_AVAILABLE);
logger.debug('updater', '当前已是最新版本', { version: APP_VERSION });
return;
}
// ② 有新版本 → 交给 electron-updater 触发 'update-available' 事件(不自动下载)
await platformUpdater.checkForUpdates();
} catch (error) {
updateChecked = false;
logger.error('updater', '更新流程失败', error as Error, { apiUrl: getApiBaseUrl() });
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_ERROR, {
message: (error as Error).message || '更新检查失败',
});
}
}
/** 手动检查更新(用户点击触发) */
export async function checkForUpdatesManual(): Promise<void> {
if (!platformUpdater || !provider) return;
try {
updateChecked = false;
// ① 先自行检查有无更新
const info = await provider.getLatestVersion();
if (info.version === APP_VERSION) {
// 无更新 → 直接通知 UI
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_NOT_AVAILABLE);
logger.debug('updater', '手动检查:当前已是最新版本', { version: APP_VERSION });
return;
}
// ② 有新版本 → 交给 electron-updater 触发 'update-available' 事件(不自动下载)
await platformUpdater.checkForUpdates();
} catch (error) {
logger.error('updater', '手动更新失败', error as Error);
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_ERROR, {
message: (error as Error).message || '检查更新失败',
});
}
}
/** 用户确认后开始下载更新 */
export async function downloadUpdate(): Promise<void> {
if (!platformUpdater) {
logger.warn('updater', 'downloadUpdate: Updater 未初始化');
return;
}
try {
await platformUpdater.downloadUpdate();
} catch (error) {
logger.error('updater', '下载更新失败', error as Error);
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_ERROR, {
message: (error as Error).message || '下载更新失败',
});
}
}
/** 安装已下载的更新并退出应用 */
export function installUpdateNow(): void {
if (!platformUpdater) {
logger.warn('updater', 'Updater 未初始化');
return;
}
logger.info('updater', '开始安装更新并退出应用');
setImmediate(() => {
platformUpdater!.quitAndInstall(false, true);
});
}
// ============================================================
// IPC 注册
// ============================================================
export function registerUpdateIpcHandlers(): void {
ipcMain.handle(RENDERER_TO_MAIN_UPDATER.CHECK_FOR_UPDATE, async () => {
await checkForUpdatesManual();
});
ipcMain.handle(RENDERER_TO_MAIN_UPDATER.DOWNLOAD_UPDATE, async () => {
await downloadUpdate();
});
ipcMain.on(RENDERER_TO_MAIN_UPDATER.INSTALL_UPDATE, () => {
installUpdateNow();
});
}

View File

@@ -0,0 +1,102 @@
// ============================================================
// platform-updaters.ts — 平台特定 Updater
//
// 覆盖 getUpdateInfoAndProvider() 以注入 HeiXiuProvider。
// 其他行为NSIS 安装、签名校验等)完全由父类处理。
// ============================================================
import {
NsisUpdater,
MacUpdater,
AppImageUpdater,
Provider,
} from 'electron-updater';
import type { UpdateInfo } from 'builder-util-runtime';
import type { HeiXiuProvider } from './provider';
// ---------- Windows NSIS ----------
export class HeiXiuNsisUpdater extends NsisUpdater {
private heiXiuProvider: HeiXiuProvider;
constructor(heiXiuProvider: HeiXiuProvider) {
// 传 undefined 跳过内置 Provider 创建,由我们自己注入
super(undefined);
this.heiXiuProvider = heiXiuProvider;
this.autoDownload = false; // 由我们手动控制流程,保持 IPC 兼容
this.autoInstallOnAppQuit = false;
this.forceDevUpdateConfig = true; // 绕过 electron-updater 的 dev 模式检查
}
/** @internal — 注入自定义 Provider */
protected override async getUpdateInfoAndProvider(): Promise<{
info: UpdateInfo;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
provider: Provider<any>;
}> {
const info = await this.heiXiuProvider.getLatestVersion();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { info, provider: this.heiXiuProvider as Provider<any> };
}
}
// ---------- macOS ----------
export class HeiXiuMacUpdater extends MacUpdater {
private heiXiuProvider: HeiXiuProvider;
constructor(heiXiuProvider: HeiXiuProvider) {
super(undefined);
this.heiXiuProvider = heiXiuProvider;
this.autoDownload = false;
this.autoInstallOnAppQuit = false;
}
protected override async getUpdateInfoAndProvider(): Promise<{
info: UpdateInfo;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
provider: Provider<any>;
}> {
const info = await this.heiXiuProvider.getLatestVersion();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { info, provider: this.heiXiuProvider as Provider<any> };
}
}
// ---------- Linux AppImage ----------
export class HeiXiuAppImageUpdater extends AppImageUpdater {
private heiXiuProvider: HeiXiuProvider;
constructor(heiXiuProvider: HeiXiuProvider) {
super(null);
this.heiXiuProvider = heiXiuProvider;
this.autoDownload = false;
this.autoInstallOnAppQuit = false;
}
protected override async getUpdateInfoAndProvider(): Promise<{
info: UpdateInfo;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
provider: Provider<any>;
}> {
const info = await this.heiXiuProvider.getLatestVersion();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { info, provider: this.heiXiuProvider as Provider<any> };
}
}
// ---------- 工厂函数 ----------
/** 工厂:按平台创建对应的 Updater 实例 */
export function createPlatformUpdater(provider: HeiXiuProvider) {
switch (process.platform) {
case 'win32':
return new HeiXiuNsisUpdater(provider);
case 'darwin':
return new HeiXiuMacUpdater(provider);
default:
return new HeiXiuAppImageUpdater(provider);
}
}

View File

@@ -0,0 +1,172 @@
// ============================================================
// provider.ts — 自定义更新 Provider
//
// 封装与自有版本检查 API 的交互逻辑,
// 返回 electron-updater 格式的 UpdateInfo。
// ============================================================
import { app } from 'electron';
import { createHash } from 'node:crypto';
import { URL } from 'node:url';
import { Provider } from 'electron-updater';
import type { ProviderRuntimeOptions } from 'electron-updater/out/providers/Provider';
import type { UpdateInfo, UpdateFileInfo } from 'builder-util-runtime';
import type { ResolvedUpdateFileInfo } from 'electron-updater/out/types';
import type { UpdateCheckResult } from '../../../shared/types';
import { APP_VERSION } from '../../../shared/constants/version';
import { logger } from '../logger';
import { netRequest } from '../net-request';
// ---------- 构建时注入的渠道信息 ----------
/** 构建渠道(由 build.mjs 通过 VITE_BUILD_CHANNEL 注入Vite 编译时内联) */
export const BUILD_CHANNEL: string =
(typeof import.meta !== 'undefined' && (import.meta as unknown as Record<string, unknown>).env
? ((import.meta as unknown as Record<string, unknown>).env as Record<string, string>).VITE_BUILD_CHANNEL
: undefined)
|| process.env.VITE_BUILD_CHANNEL
|| 'stable';
/** 构建版本类型(由 build.mjs 通过 VITE_BUILD_EDITION 注入) */
export const BUILD_EDITION: string =
(typeof import.meta !== 'undefined' && (import.meta as unknown as Record<string, unknown>).env
? ((import.meta as unknown as Record<string, unknown>).env as Record<string, string>).VITE_BUILD_EDITION
: undefined)
|| process.env.VITE_BUILD_EDITION
|| 'personal';
// ---------- 设备指纹(稳定的机器标识,用于灰度分组)----------
let _deviceFingerprint: string | null = null;
/** 获取设备指纹(基于机器特定信息的 SHA-256 前 16 位) */
export function getDeviceFingerprint(): string {
if (_deviceFingerprint) return _deviceFingerprint;
try {
const parts = [
process.platform,
process.arch,
app.getPath('home'),
];
_deviceFingerprint = createHash('sha256')
.update(parts.join('|'))
.digest('hex')
.slice(0, 16);
} catch {
_deviceFingerprint = 'unknown-device';
}
return _deviceFingerprint;
}
// ---------- 配置 ----------
export function getApiBaseUrl(): string {
const url = (process.env.UPDATE_API_URL || 'https://www.heixiu.com').trim();
console.log(`[updater] UPDATE_API_URL=${process.env.UPDATE_API_URL} → 使用: ${url}`);
return url;
}
// ============================================================
// HeiXiuProvider
// ============================================================
export class HeiXiuProvider extends Provider<UpdateInfo> {
private apiBaseUrl: string;
/** 短时缓存:避免 checkForUpdates 预检 + electron-updater 内部调用导致两次 API 请求 */
private _cachedResult: { data: UpdateInfo; ts: number } | null = null;
constructor(runtimeOptions: ProviderRuntimeOptions) {
super(runtimeOptions);
this.apiBaseUrl = getApiBaseUrl();
}
/**
* 调用自有版本检查 API返回 electron-updater 格式的 UpdateInfo。
* blockMapSize > 0 时 electron-updater 自动启用差分下载。
* 无更新时返回当前版本号APP_VERSION
*/
async getLatestVersion(): Promise<UpdateInfo> {
// 短时缓存500ms预检和 electron-updater 内部调用共享同一结果
if (this._cachedResult && Date.now() - this._cachedResult.ts < 500) {
return this._cachedResult.data;
}
const data = await this._doGetLatestVersion();
this._cachedResult = { data, ts: Date.now() };
return data;
}
private async _doGetLatestVersion(): Promise<UpdateInfo> {
const { data } = await netRequest.get<{ code: number; data?: UpdateCheckResult } & UpdateCheckResult>(
'/api/v1/update/check',
{
baseURL: this.apiBaseUrl,
params: {
platform: process.platform,
version: APP_VERSION,
channel: BUILD_CHANNEL,
edition: BUILD_EDITION,
deviceFingerprint: getDeviceFingerprint(),
},
},
);
// 兼容两种 API 响应格式
const result: UpdateCheckResult =
(data as { data?: UpdateCheckResult }).data ?? (data as UpdateCheckResult);
// 无更新 → 返回当前版本号
if (!result.hasUpdate) {
return {
version: APP_VERSION,
files: [],
path: '',
sha512: '',
releaseDate: new Date().toISOString(),
};
}
const file: UpdateFileInfo = {
url: result.downloadUrl,
sha512: result.sha512,
size: result.fileSize,
};
// 后端 API 返回 blockMapSize 时启用增量更新
if (result.blockMapSize) {
file.blockMapSize = result.blockMapSize;
}
logger.info('updater', 'Provider 获取到新版本', {
latestVersion: result.latestVersion,
hasBlockmap: file.blockMapSize != null,
});
return {
version: result.latestVersion,
files: [file],
path: result.downloadUrl,
sha512: result.sha512,
releaseDate: result.releaseDate,
releaseNotes: result.changelog,
};
}
/**
* 将 UpdateInfo 中的相对路径解析为完整的下载 URL。
* electron-updater 在下载前调用此方法。
*/
resolveFiles(updateInfo: UpdateInfo): Array<ResolvedUpdateFileInfo> {
return updateInfo.files.map((f) => {
// 使用 downloadUrl 作为 base URL 来解析文件路径
const baseUrl = new URL(updateInfo.path);
const fileUrl = new URL(f.url, baseUrl.origin);
return {
url: fileUrl,
info: f,
};
});
}
}

View File

@@ -1,45 +1,39 @@
import { ipcRenderer, contextBridge } from 'electron'; // ============================================================
import { BIDIRECTIONAL } from '../shared/constants/ipc-channels'; // preload.ts — Electron 预加载脚本(入口)
//
// 职责:
// - 调用各功能模块的 expose 函数,将安全 API 暴露给渲染进程
// - 所有 API 通过 contextBridge 桥接,确保进程隔离安全
//
// 模块拆分:
// - base.ts → ipcRenderer 基础通信
// - safe-storage.ts → 加密/解密 API
// - file-storage.ts → 文件读写 API
// - app-runtime.ts → 运行时 API窗口标题等
// - canvas.ts → 无限画布 API
// ============================================================
// --------- Expose some API to the Renderer process --------- import {
contextBridge.exposeInMainWorld('ipcRenderer', { exposeBaseIpc,
on(...args: Parameters<typeof ipcRenderer.on>) { exposeSafeStorage,
const [channel, listener] = args; exposeFileStorage,
return ipcRenderer.on(channel, (event, ...args) => listener(event, ...args)); exposeAppRuntime,
}, exposeCanvas,
off(...args: Parameters<typeof ipcRenderer.off>) { } from './preload/index';
const [channel, ...omit] = args;
return ipcRenderer.off(channel, ...omit);
},
send(...args: Parameters<typeof ipcRenderer.send>) {
const [channel, ...omit] = args;
return ipcRenderer.send(channel, ...omit);
},
invoke(...args: Parameters<typeof ipcRenderer.invoke>) {
const [channel, ...omit] = args;
return ipcRenderer.invoke(channel, ...omit);
},
});
// --------- 安全加密 API基于 Electron safeStorage--------- // --------- 按顺序暴露各模块 API ---------
contextBridge.exposeInMainWorld('safeStorage', {
/**
* 加密明文字符串
* @param plaintext - 待加密的明文
* @returns Base64 编码的密文,失败返回 null
*/
async encrypt(plaintext: string): Promise<string | null> {
const result = await ipcRenderer.invoke(BIDIRECTIONAL.SAFESTORAGE_ENCRYPT, plaintext);
return result?.success ? result.data : null;
},
/** // 1. 基础 IPC 通信ipcRenderer.on/off/send/invoke
* 解密 Base64 密文 exposeBaseIpc();
* @param encryptedBase64 - Base64 编码的密文
* @returns 解密后的明文,失败返回 null // 2. 安全加密 API基于 Electron safeStorage
*/ exposeSafeStorage();
async decrypt(encryptedBase64: string): Promise<string | null> {
const result = await ipcRenderer.invoke(BIDIRECTIONAL.SAFESTORAGE_DECRYPT, encryptedBase64); // 3. 本地存储文件 API主进程 fs 桥接)
return result?.success ? result.data : null; exposeFileStorage();
},
}); // 4. 应用运行时 API窗口标题等
exposeAppRuntime();
// 5. 无限画布 API
exposeCanvas();

View File

@@ -0,0 +1,22 @@
// ============================================================
// preload/app-runtime.ts — 应用运行时 API
//
// 提供窗口标题、版本控制等运行时接口
// 渲染进程通过 window.appRuntime 调用
// ============================================================
import { ipcRenderer, contextBridge } from 'electron';
import { RENDERER_TO_MAIN } from '../../shared/constants/ipc';
/** 应用运行时 API */
export function exposeAppRuntime(): void {
contextBridge.exposeInMainWorld('appRuntime', {
/**
* 登录/登出后更新窗口标题中的版控后缀
* @param edition - 'personal' | 'enterprise' | nullnull 清除后缀)
*/
setWindowEdition(edition: string | null): void {
ipcRenderer.send(RENDERER_TO_MAIN.SET_WINDOW_TITLE, edition);
},
});
}

29
electron/preload/base.ts Normal file
View File

@@ -0,0 +1,29 @@
// ============================================================
// preload/base.ts — 基础 ipcRenderer 桥接
//
// 提供安全的 IPC 通信接口,暴露给渲染进程
// ============================================================
import { ipcRenderer, contextBridge } from 'electron';
/** 基础 IPC 通信 API */
export function exposeBaseIpc(): void {
contextBridge.exposeInMainWorld('ipcRenderer', {
on(...args: Parameters<typeof ipcRenderer.on>) {
const [channel, listener] = args;
return ipcRenderer.on(channel, (event, ...args) => listener(event, ...args));
},
off(...args: Parameters<typeof ipcRenderer.off>) {
const [channel, ...omit] = args;
return ipcRenderer.off(channel, ...omit);
},
send(...args: Parameters<typeof ipcRenderer.send>) {
const [channel, ...omit] = args;
return ipcRenderer.send(channel, ...omit);
},
invoke(...args: Parameters<typeof ipcRenderer.invoke>) {
const [channel, ...omit] = args;
return ipcRenderer.invoke(channel, ...omit);
},
});
}

124
electron/preload/canvas.ts Normal file
View File

@@ -0,0 +1,124 @@
// ============================================================
// preload/canvas.ts — 无限画布 API
//
// 提供画布窗口、文件扫描、缩略图、拖拽等接口
// 渲染进程通过 window.canvas 调用
// ============================================================
import { ipcRenderer, contextBridge } from 'electron';
import { BIDIRECTIONAL, RENDERER_TO_MAIN } from '../../shared/constants/ipc';
/** 无限画布 API */
export function exposeCanvas(): void {
contextBridge.exposeInMainWorld('canvas', {
/** 打开画布窗口(主进程创建新 BrowserWindow */
openWindow(projectPath?: string): void {
ipcRenderer.send(RENDERER_TO_MAIN.OPEN_CANVAS_WINDOW, { projectPath });
},
/** 扫描目录,返回匹配的文件列表 */
scanDirectory(params: {
roots: string[];
extensions: string[];
recursive: boolean;
}): Promise<{
success: boolean;
data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>;
error?: string;
}> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_DIRECTORY, params);
},
/** 扫描项目(主进程内部完成 buildScanRoots + walkDirectory推荐使用 */
scanProject(params: {
projectPath: string;
stage: string;
assetTypeFilter: string;
shotQuery: string;
}): Promise<{
success: boolean;
data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>;
error?: string;
}> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_PROJECT, params);
},
/** 读取旁加载 JSON 元数据 */
readSidecar(params: { filePath: string }): Promise<{ success: boolean; data: object | null }> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_READ_SIDECAR, params);
},
/** 写入旁加载 JSON 元数据 */
writeSidecar(params: { filePath: string; metadata: object }): Promise<{ success: boolean; error?: string }> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_WRITE_SIDECAR, params);
},
/** 生成缩略图JPEG base64 */
generateThumbnail(params: { filePath: string; size: number }): Promise<{
success: boolean;
data: { data: string; mimeType: string; width: number; height: number };
error?: string;
}> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_GENERATE_THUMBNAIL, params);
},
/** 复制文件到目标目录(导入操作) */
copyFile(params: { src: string; destDir: string; fileName: string }): Promise<{
success: boolean;
data: string;
error?: string;
}> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_COPY_FILE, params);
},
/** 列出目录下的子文件夹(非递归,排除隐藏) */
listSubdirs(params: { parentPath: string }): Promise<{ success: boolean; data: string[]; error?: string }> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_LIST_SUBDIRS, params);
},
/** 扫描阶段文件(单个 episode/shot/stage 组合) */
scanStageFiles(params: { projectPath: string; episode: string; shot: string; stage: string }): Promise<{
success: boolean;
data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>;
error?: string;
}> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_STAGE_FILES, params);
},
/** 扫描资产文件(深度递归扫描 assets/ 目录) */
scanAssetFiles(params: { projectPath: string; assetTypeFilter: string }): Promise<{
success: boolean;
data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>;
error?: string;
}> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_ASSET_FILES, params);
},
/** 创建目录 */
createDirectory(params: { parentPath: string; dirName: string }): Promise<{
success: boolean;
data: string;
error?: string;
}> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_CREATE_DIRECTORY, params);
},
/** 将本地文件路径转换为可通过 local-media:// 协议加载的 URL */
getMediaUrl(filePath: string): string {
const normalized = filePath.replace(/\\/g, '/');
// UNC 路径(//server/share/…)开头 // 会被 URL 解析器视为 authority
// 必须用 local-media://<host>/<path> 格式而非 local-media:/// 前缀
if (normalized.startsWith('//')) {
// normalized = "//192.168.110.250/share/path"
return 'local-media://' + normalized.slice(2);
}
// 本地路径C:/path → local-media:///C:/path
return 'local-media:///' + normalized;
},
/** 启动原生文件拖拽(从缩略图拖放媒体文件到外部应用) */
startDrag(params: { filePath: string; fileName: string }): Promise<{ success: boolean; error?: string }> {
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_START_DRAG, params);
},
});
}

View File

@@ -6,6 +6,7 @@
* *
* 适用环境: * 适用环境:
* - Node.js / ElectronWindows通过 child_process 执行 wmic / powershell 采集硬件 ID * - Node.js / ElectronWindows通过 child_process 执行 wmic / powershell 采集硬件 ID
* - Node.js / ElectronmacOS :通过 ioreg / sysctl / system_profiler 采集硬件 ID
* - 浏览器:仅提供 SHA256 哈希工具函数,硬件采集需由上层注入 * - 浏览器:仅提供 SHA256 哈希工具函数,硬件采集需由上层注入
*/ */
@@ -60,6 +61,37 @@ const COMPONENT_QUERIES: Record<Component, ComponentQuery> = {
}, },
}; };
// ============================================================
// Mac 查询配置
// ============================================================
interface MacComponentQuery {
command: string[];
/** 从命令 stdout 中提取目标值 */
extract: (output: string) => string;
}
const MAC_COMPONENT_QUERIES: Record<Component, MacComponentQuery> = {
uuid: {
command: ['ioreg', '-d2', '-c', 'IOPlatformExpertDevice'],
extract: (output: string): string => {
const m = output.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/);
return m?.[1] ?? '';
},
},
cpu: {
command: ['sysctl', '-n', 'hw.model'],
extract: (output: string): string => output.trim(),
},
hdd: {
command: ['system_profiler', 'SPNVMeDataType'],
extract: (output: string): string => {
const m = output.match(/Serial Number:\s*(.+)/i);
return m?.[1]?.trim() ?? '';
},
},
};
// ============================================================ // ============================================================
// 工具函数 // 工具函数
// ============================================================ // ============================================================
@@ -105,6 +137,10 @@ function isWindows(): boolean {
return platform() === "win32"; return platform() === "win32";
} }
function isMacOS(): boolean {
return platform() === "darwin";
}
function isWmicAvailable(): boolean { function isWmicAvailable(): boolean {
if (!isWindows()) return false; if (!isWindows()) return false;
const result = spawnSync("where", ["wmic"], { const result = spawnSync("where", ["wmic"], {
@@ -163,6 +199,35 @@ function runWmic(args: readonly string[]): string {
return runProcess([...args]); return runProcess([...args]);
} }
/** 执行 Mac 命令并返回 stdout去除首尾空白 */
function runMacCommand(args: string[], timeoutSec: number = 5.0): string {
if (!isMacOS()) return "";
return runProcess(args, timeoutSec);
}
/**
* Mac HDD 标识采集 — 三级回退保证覆盖率
* ① NVMe 磁盘序列号Apple Silicon / 新款 Intel
* ② SATA 磁盘序列号(老款 Intel Mac
* ③ 系统序列号(最终兜底,仍唯一)
*/
function getMacHddValue(): string {
// ① 尝试 NVMe
let output = runMacCommand(['system_profiler', 'SPNVMeDataType']);
let m = output.match(/Serial Number:\s*(.+)/i);
if (m?.[1]?.trim()) return m[1].trim();
// ② 回退SATA
output = runMacCommand(['system_profiler', 'SPSerialATADataType']);
m = output.match(/Serial Number:\s*(.+)/i);
if (m?.[1]?.trim()) return m[1].trim();
// ③ 最终兜底:系统序列号
output = runMacCommand(['system_profiler', 'SPHardwareDataType']);
m = output.match(/Serial Number[^:]*:\s*(.+)/i);
return m?.[1]?.trim() ?? '';
}
// ============================================================ // ============================================================
// DeviceFingerprint 类 // DeviceFingerprint 类
// ============================================================ // ============================================================
@@ -178,6 +243,18 @@ export class DeviceFingerprint {
* 对应 Python: get_component_id(component, method="auto") -> str * 对应 Python: get_component_id(component, method="auto") -> str
*/ */
getComponentId(component: Component, method: QueryMethod = "auto"): string { getComponentId(component: Component, method: QueryMethod = "auto"): string {
// Mac 路径ioreg / sysctl / system_profiler
if (isMacOS()) {
if (component === 'hdd') {
return normalizeIdentifier(getMacHddValue());
}
const macQuery = MAC_COMPONENT_QUERIES[component];
if (!macQuery) return '';
const output = runMacCommand(macQuery.command);
return normalizeIdentifier(macQuery.extract(output));
}
// Windows 路径wmic / powershell
const query = COMPONENT_QUERIES[component]; const query = COMPONENT_QUERIES[component];
if (!query) return ""; if (!query) return "";
@@ -225,6 +302,22 @@ export class DeviceFingerprint {
* 对应 Python: fingerprint_sha256(method="auto") -> str | None * 对应 Python: fingerprint_sha256(method="auto") -> str | None
*/ */
fingerprintSha256(method: QueryMethod = "auto"): string | null { fingerprintSha256(method: QueryMethod = "auto"): string | null {
// Mac 路径ioreg / sysctl / system_profiler
if (isMacOS()) {
const uuidId = this.getComponentId("uuid", method);
const diskId = this.getComponentId("hdd", method);
const cpuId = this.getComponentId("cpu", method);
if (!uuidId && !diskId && !cpuId) {
return null;
}
const combined = `${uuidId}|${diskId}|${cpuId}`;
const normalized = normalizeIdentifier(combined);
return sha256HexUpper(normalized);
}
// Windows 路径wmic / powershell
const resolvedMethod = resolveMethod(method); const resolvedMethod = resolveMethod(method);
const wmicOk = isWmicAvailable(); const wmicOk = isWmicAvailable();
const psOk = isPowershellAvailable(); const psOk = isPowershellAvailable();

View File

@@ -0,0 +1,94 @@
// ============================================================
// preload/file-storage.ts — 本地存储文件 API主进程 fs 桥接)
//
// 提供文件读写、目录管理、下载等接口
// 渲染进程通过 window.fileStorage 调用
// ============================================================
import { ipcRenderer, contextBridge } from 'electron';
import { BIDIRECTIONAL } from '../../shared/constants/ipc';
/** 本地存储文件 API */
export function exposeFileStorage(): void {
contextBridge.exposeInMainWorld('fileStorage', {
/** 获取 heixiu-data 根目录路径 */
async getDataDir(): Promise<string | null> {
const r = await ipcRenderer.invoke(BIDIRECTIONAL.GET_DATA_DIR);
return r?.success ? r.data : null;
},
/** 写入文件Base64 → 磁盘),返回写入的绝对路径 */
async writeFile(relativePath: string, data: string, encoding?: 'base64' | 'utf8'): Promise<string | null> {
const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_WRITE, { relativePath, data, encoding });
return r?.success ? r.data : null;
},
/** 读取文件(磁盘 → Base64 */
async readFile(relativePath: string): Promise<string | null> {
const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_READ, { relativePath });
return r?.success ? r.data : null;
},
/** 删除文件 */
async deleteFile(relativePath: string): Promise<boolean> {
const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_DELETE, { relativePath });
return r?.success ?? false;
},
/** 递归创建目录 */
async makeDir(relativePath: string): Promise<string | null> {
const r = await ipcRenderer.invoke(BIDIRECTIONAL.MAKE_DIR, { relativePath });
return r?.success ? r.data : null;
},
/** 在文件管理器中显示文件(绝对路径) */
async showItemInFolder(filePath: string): Promise<boolean> {
const r = await ipcRenderer.invoke(BIDIRECTIONAL.SHOW_ITEM_IN_FOLDER, { filePath });
return r?.success ?? false;
},
/** 获取文件大小(字节) */
async fileSize(relativePath: string): Promise<number | null> {
const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_SIZE, { relativePath });
return r?.success ? r.data : null;
},
/** 检查文件/目录是否存在 */
async fileExists(relativePath: string): Promise<boolean> {
const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_EXISTS, { relativePath });
return r?.success ? r.data : false;
},
/** 绝对路径文件写入(绕过 heixiu-data 沙箱) */
async writeFileAbsolute(absolutePath: string, data: string, encoding?: 'base64' | 'utf8'): Promise<string | null> {
const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_WRITE_ABSOLUTE, { absolutePath, data, encoding });
return r?.success ? r.data : null;
},
/** 绝对路径目录创建(绕过 heixiu-data 沙箱) */
async makeDirAbsolute(absolutePath: string): Promise<string | null> {
const r = await ipcRenderer.invoke(BIDIRECTIONAL.MAKE_DIR_ABSOLUTE, { absolutePath });
return r?.success ? r.data : null;
},
/** 主进程下载文件到绝对路径(绕过浏览器 CORS 限制),失败时 reject 并携带错误信息 */
async downloadToPath(url: string, destPath: string, headers?: Record<string, string>): Promise<string> {
const r = await ipcRenderer.invoke(BIDIRECTIONAL.DOWNLOAD_TO_PATH, { url, destPath, headers });
if (r?.success) return r.data;
throw new Error(r?.error || '主进程下载失败');
},
/** 获取系统下载目录路径 */
async getDownloadsPath(): Promise<string | null> {
const r = await ipcRenderer.invoke(BIDIRECTIONAL.GET_DOWNLOADS_PATH);
return r?.success ? r.data : null;
},
/** 生成图片缩略图(读取原图 → 缩放 → JPEG 写入目标路径) */
async generateThumbnail(srcPath: string, destPath: string, maxWidth?: number, quality?: number): Promise<string> {
const r = await ipcRenderer.invoke(BIDIRECTIONAL.GENERATE_THUMBNAIL, { srcPath, destPath, maxWidth, quality });
if (r?.success) return r.data;
throw new Error(r?.error || '缩略图生成失败');
},
});
}

11
electron/preload/index.ts Normal file
View File

@@ -0,0 +1,11 @@
// ============================================================
// preload/index.ts — 汇总导出所有 preload 模块
//
// 在主 preload.ts 中调用各模块的 expose 函数
// ============================================================
export { exposeBaseIpc } from './base';
export { exposeSafeStorage } from './safe-storage';
export { exposeFileStorage } from './file-storage';
export { exposeAppRuntime } from './app-runtime';
export { exposeCanvas } from './canvas';

View File

@@ -0,0 +1,33 @@
// ============================================================
// preload/safe-storage.ts — 安全加密 API基于 Electron safeStorage
//
// 提供加密/解密接口,渲染进程通过 window.safeStorage 调用
// ============================================================
import { ipcRenderer, contextBridge } from 'electron';
import { BIDIRECTIONAL } from '../../shared/constants/ipc';
/** 安全加密 API */
export function exposeSafeStorage(): void {
contextBridge.exposeInMainWorld('safeStorage', {
/**
* 加密明文字符串
* @param plaintext - 待加密的明文
* @returns Base64 编码的密文,失败返回 null
*/
async encrypt(plaintext: string): Promise<string | null> {
const result = await ipcRenderer.invoke(BIDIRECTIONAL.SAFESTORAGE_ENCRYPT, plaintext);
return result?.success ? result.data : null;
},
/**
* 解密 Base64 密文
* @param encryptedBase64 - Base64 编码的密文
* @returns 解密后的明文,失败返回 null
*/
async decrypt(encryptedBase64: string): Promise<string | null> {
const result = await ipcRenderer.invoke(BIDIRECTIONAL.SAFESTORAGE_DECRYPT, encryptedBase64);
return result?.success ? result.data : null;
},
});
}

View File

@@ -7,15 +7,16 @@
<!-- <meta name="viewport" content="width=device-width, initial-scale=1.0" />--> <!-- <meta name="viewport" content="width=device-width, initial-scale=1.0" />-->
<meta http-equiv="Content-Security-Policy" content=" <meta http-equiv="Content-Security-Policy" content="
default-src 'self'; default-src 'self';
script-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval';
style-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';
img-src 'self' data: https://rh-images-1252422369.cos.ap-beijing.myqcloud.com https://www.heixiu.net ; ; img-src 'self' data: https: ;
connect-src 'self' http://8.160.179.64:8000 http://localhost:5173 ws://localhost:5173 https://www.heixiu.net ; media-src 'self' https: ;
connect-src 'self' http://8.160.179.64:8000 http://localhost:5173 ws://localhost:5173 https://www.heixiu.net https: ;
"> ">
<title>船长·HeiXiu</title> <title>船长·HeiXiu</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/app/main.tsx"></script>
</body> </body>
</html> </html>

802
infinite_canvas_140.md Normal file
View File

@@ -0,0 +1,802 @@
# Infinite Canvas — PySide6 → React/Electron 重构方案
> 基于 `infinite_canvas_140/` 源码分析,映射到当前 React + TypeScript + Electron 技术栈。
> 重点关注:**文件夹 → 媒体文件映射逻辑**(核心差异点)
---
## 一、技术栈映射
| PySide6原版 | React/Electron目标 | 说明 |
|------------------------|--------------------------------------|-------------------------------|
| `QGraphicsView` | 虚拟滚动 `<canvas>` / `position:absolute` 布局 | 无限画布渲染 |
| `QGraphicsScene` | Zustand Store (items 集合) | 场景中所有 item 的状态 |
| `QGraphicsItem` | React 组件 + `absolute` 定位 | 单个缩略图卡片 |
| `QThreadPool` + `QRunnable` | Web Worker / Electron IPC | 后台任务(扫描/加载) |
| `Signal.connect` | Event Bus (`bus.ts`) 已就绪 | 事件解耦 |
| `QSharedMemory` | `app.getSingleInstanceLock()` | 单实例检测 |
| `QPixmapCache` | `Blob URL` + `Map<cache>` | 图片内存缓存 |
| sidecar JSON 文件 | 同方案(独立的 `.meta.json` 文件) | 元数据旁加载 |
| `os.walk` 扫描 | Electron `fs.readdir` / IPC | 文件系统遍历 |
---
## 二、文件夹 → 媒体文件映射机制(重点)
### 2.1 目录结构约定
原版项目的核心映射逻辑在 `ScanCoordinator._build_scan_roots()``_group_key_for_path()` 中。
目录结构遵循固定的 AIGC 生产管线约定:
```
工作区根目录 (ROOT_STORAGE)
└── 项目A/
├── shots/ ← 分镜/关键帧/视频/音频/口型
│ ├── E01/ ← 集数 (episode)
│ │ ├── SC001/ ← 镜头号 (shot)
│ │ │ ├── Storyboard/ ← 阶段:分镜
│ │ │ │ ├── E01_SC001_001.jpg
│ │ │ │ ├── E01_SC001_001.meta.json ← 旁加载元数据
│ │ │ │ └── ...
│ │ │ ├── Keyframe/ ← 阶段:关键帧
│ │ │ │ └── ...
│ │ │ ├── Video/ ← 阶段:视频
│ │ │ │ └── ...
│ │ │ ├── Audio/ ← 阶段:音频
│ │ │ │ └── ...
│ │ │ └── LipSync/ ← 阶段:口型
│ │ │ └── ...
│ │ └── SC002/
│ │ └── ...
│ └── E02/
│ └── ...
└── assets/ ← 资产
├── chr/ ← 角色
│ ├── 角色名A/
│ │ ├── image001.png
│ │ ├── image001.meta.json ← 包含 tags, description
│ │ └── ...
│ └── ...
├── prp/ ← 道具
└── set/ ← 场景
```
### 2.2 路径 → 阶段/分组 的解析算法
这是原版的核心逻辑,决定了一个文件属于哪个 **阶段Stage** 和哪个 **分组Group**
```
┌─────────────── 解析入口函数 ──────────────────────────────┐
detectStage(filePath: string): StageProfile
├─ 路径含有 "/Storyboard/" → Stage.Storyboard
│ ├─ includes: ["jpg","jpeg","png","webp","bmp","gif"]
│ ├─ uses_episode: true
│ ├─ uses_shot_query: true
│ ├─ audit_enabled: true
│ └─ sidecar_fields: ["remark", "audit_status"]
├─ 路径含有 "/Keyframe/" → Stage.Keyframe
│ ├─ includes: ["jpg","jpeg","png","webp","bmp","gif"]
│ ├─ uses_episode: true
│ ├─ uses_shot_query: true
│ ├─ audit_enabled: true
│ └─ sidecar_fields: ["description","remark","audit_status"]
├─ 路径含有 "/Video/" → Stage.Video
│ ├─ includes: ["mp4","webm","mov","avi","mkv"]
│ ├─ uses_episode: true
│ ├─ uses_shot_query: true
│ ├─ audit_enabled: true
│ └─ sidecar_fields: ["description","remark","audit_status"]
├─ 路径含有 "/Audio/" → Stage.Audio
│ ├─ includes: ["mp3","wav","ogg","flac","aac","m4a"]
│ ├─ uses_episode: true
│ └─ sidecar_fields: ["remark"]
├─ 路径含有 "/LipSync/" → Stage.LipSync
│ ├─ includes: ["mp3","wav","ogg","flac","aac","m4a"]
│ ├─ uses_episode: true
│ └─ sidecar_fields: ["remark"]
└─ 路径含有 "/assets/" → Stage.Assets
├─ includes: ["jpg","jpeg","png","webp","bmp","gif","mp4","webm"]
└─ sidecar_fields: ["description","tags"]
```
```
┌─────────────── 分组键提取 ──────────────────────────────┐
groupKeyForPath(filePath: string): string
├─ 若 stage === Assets
│ ├─ 向上找 assets/ 目录
│ ├─ assets 下一层 → type (chr/prp/set)
│ ├─ type 下一层 → name (角色名/道具名/场景名)
│ └─ 返回 "{type}/{name}" 例: "chr/主角A"
└─ 若 stage ≠ Assets
├─ 向上找到 shots/ 目录
├─ shots 下一层 → episode (E01)
├─ episode 下一层 → shot (SC001)
└─ 返回 "{episode}/{shot}" 例: "E01/SC001"
```
### 2.3 扫描根目录计算
```
buildScanRoots(projectPath, episodePath, stage): string[]
├─ 若 stage === Assets
│ ├─ assetTypeFilter === "全部" → [project/assets/]
│ ├─ "角色" → [project/assets/chr/]
│ ├─ "道具" → [project/assets/prp/]
│ └─ "场景" → [project/assets/set/]
└─ 若 stage !== Assets
├─ 若指定了 episodePath → episodes = [episodePath]
├─ 否则 → episodes = project/shots/ 下的所有子目录
└─ 对每个 episode
├─ 列出所有 shot_name 子目录
├─ 若 shot_query 不为空 → 过滤匹配的 shot
└─ 对每个 shot追加 {shot_dir}/{stage}/
```
### 2.4 Electron 中的 IPC 实现要点
由于浏览器没有 `os.walk`,文件扫描必须走 Electron 主进程:
```
渲染进程 主进程
───────── ────────
useDirectoryScan(projectPath, ipcMain.handle('fs:scan-dir',
stage, filters) (event, {roots, exts, recursive}) => {
→ contextBridge const results = []
.scanDirectory({roots, for (const root of roots) {
exts, recursive}) walkSync(root, (file) => {
← Promise<CatalogItem[]> if exts.has(ext) {
→ dispatch(setCatalog(items)) results.push({
path: file,
metadata: readSidecar(file)
})
}
})
}
return results
})
⚠️ 安全注意:
- 扫描根目录必须从用户选择的 workspace root 派生,不能接受任意路径
- 使用 contextBridge 暴露白名单方法,禁止渲染进程直接访问 fs 模块
- 大目录扫描应分批 emit每 80 个一批),避免主进程阻塞
```
---
## 三、React 状态架构
### 3.1 Store 设计 (Zustand)
对应原版 `CatalogModel` + `SelectionModel` + `CanvasStateMachine` 三者:
```typescript
// stores/useCanvasStore.ts
interface CanvasState {
// ── Catalog原 CatalogModel._items──
items: CatalogItem[]; // 全量文件列表
totalCount: number;
// ── Selection原 SelectionModel──
selectedIds: Set<number>;
// ── Stage & Filters ──
currentStage: StageType; // Storyboard | Keyframe | Video | Audio | LipSync | Assets
keywordQuery: string;
tagQuery: string;
auditFilter: AuditFilter; // all | pending | approved | rework | deprecated
assetTypeFilter: AssetType; // 全部 | 角色 | 道具 | 场景
shotQuery: string;
// ── Layout ──
layoutMode: 'grid' | 'single';
cols: number;
rowsPerPage: number;
singleRowsPerPage: number;
showShotPlaceholders: boolean;
// ── Pagination ──
currentPage: number;
pageEpoch: number; // 防止过期回调污染的版本号
// ── Canvas transform ──
zoom: number; // 当前缩放倍率
panOffset: { x: number; y: number };
// ── Interaction mode ──
mode: 'BROWSE' | 'SELECT' | 'REORDER';
// ── Meta ──
isScanning: boolean;
scanProgress: { found: number; total: number | null };
}
```
### 3.2 CatalogItem 实体
```typescript
interface CatalogItem {
id: number; // 全局唯一序号
path: string; // 文件系统绝对路径
fileName: string; // 文件名(不含路径)
ext: string; // 扩展名(小写)
stage: StageType; // 所属阶段
episodeName: string; // 集数名(从路径提取)
shotName: string; // 镜头名(从路径提取)
groupKey: string; // 分组键("E01/SC001" 或 "chr/主角A"
metadata: MediaMetadata | null; // 旁加载元数据
}
interface MediaMetadata {
// 通用字段
remark?: string;
description?: string; // AIGC Prompt
tags?: string[];
original_name?: string;
// 审核字段(仅 Storyboard / Keyframe / Video
audit_status?: 'pending' | 'approved' | 'rework' | 'deprecated';
// 媒体属性(扫描时填充)
width?: number;
height?: number;
duration?: number; // 视频/音频时长
fileSize?: number;
}
```
---
## 四、组件树
```
<InfiniteCanvasPage> ← 路由 /canvasDrawer 叠加模式)
├─ <CanvasStageOverlay> ← 阶段切换 Tab左上角浮层
├─ <CanvasFilterBar> ← 项目/集数/镜头/关键词/审核筛选(顶部浮层)
├─ <CanvasModeBar> ← 模式切换(右下角浮层)
├─ <CanvasViewport> ← 核心画布CSS transform 驱动的虚拟视口
│ │ transform: scale(zoom) translate(panOffset)
│ │
│ ├─ <ShotPlaceholder>×N ← 占位符single 模式下空镜头行)
│ ├─ <RowLabel>×N ← 行标签single 模式下左侧镜头号)
│ │
│ └─ <MediaCard>×N ← 核心渲染单元 = 原版 StoryboardItem
│ ├─ props: { item, x, y, w, h, behaviors[] }
│ ├─ <CheckerboardBackground /> ← 棋盘格背景(图片加载前)
│ ├─ <LODImage /> ← 多级 LOD 图片
│ ├─ <AuditBadge /> ← 审核角标pending/approved/rework/deprecated
│ ├─ <SelectionCheckbox /> ← 选择框SELECT 模式下可见)
│ └─ <MediaCardContextMenu /> ← 右键菜单
├─ <PagerOverlay> ← 分页器(底部居中浮层)
├─ <ZoomOverlay> ← 缩放控件(右下角浮层)
└─ <QuickEditPanel> ← 快速编辑面板(选中 item 时右侧弹出)
```
### 4.1 为什么用 CSS Transform 而不是 `<canvas>`
| 方案 | 优点 | 缺点 |
|---------------|---------------------------|-------------------------------|
| `<canvas>` | 像素级控制,无 DOM 开销 | 无法使用 React 组件、无障碍差 |
| **CSS Transform** | 天然 React 友好、CSS 动画 | 大量 item 时 DOM 节点多 |
选择 CSS Transform + **虚拟化**:只渲染视口内的 item离屏 item 不挂载 DOM。
这等价于原版的分页机制——用户只会看到当前页的 ~200-400 个 item。
---
## 五、LOD 图片加载策略
### 5.1 缩略图尺寸分级
```
Level 0: 512px — 缩略图(快速扫描/小缩放)
Level 1: 1024px — 中等1:1 显示)
Level 2: 2048px — 高清(放大查看)
Level 3: 4096px — 原图(仅在 MediaPreviewWindow 中使用)
```
### 5.2 浏览器端的实现方式
原版用 `QThreadPool` + `QImage.scaled()` 生成缩略图。浏览器端有两种策略:
**策略 A服务端缩略图推荐**
```
GET /api/v1/media/thumbnail?path={path}&size=512
→ 后端生成缩略图 → 返回 JPEG/WebP
→ 优势:不阻塞渲染进程,支持 CDN 缓存
```
**策略 B本地缩略图Electron 离线模式)**
```
IPC → 主进程 sharp 库缩放 → base64 → Blob URL
→ 优势:离线可用,不依赖后端
```
### 5.3 加载调度器设计
对应原版 `LoadScheduler`
```typescript
// hooks/useMediaLoader.ts
class LoadScheduler {
private inflight = new Set<string>(); // (path, level) 去重
private pending: Array<LoadRequest> = []; // 等待队列
private maxConcurrent = 10; // 最大并发
request(path: string, level: number): Promise<BlobURL> {
const key = `${path}@${level}`;
if (this.inflight.has(key)) {
// 已在加载中 → 返回同一个 Promise复用
return this.pending.find(r => r.key === key)!.promise;
}
if (this.inflight.size >= this.maxConcurrent) {
// 排队等待
return this.enqueue(key, path, level);
}
return this.startLoad(key, path, level);
}
}
```
### 5.4 视口感知的加载优先级
```typescript
// 在 CanvasViewport 的 scroll/resize 事件中:
function updateVisibleItems() {
const visible = getVisibleItemIds(viewportRect, allItemRects);
// 1. 取消已离开视口的加载请求
cancelOutOfView(visible);
// 2. 视口中心的 item 优先加载高分辨率
const center = viewportCenter();
visible.sort((a, b) => distance(center, a) - distance(center, b));
// 3. 可见 → level 基于 zoom 决定,不可见 → 不加载
for (const item of visible) {
const level = zoomToLODLevel(currentZoom);
scheduler.request(item.path, level);
}
}
```
---
## 六、数据流全链路
### 6.1 打开工作区 → 扫描 → 显示
```
用户选择工作区 (Electron dialog.showOpenDialog)
→ IPC: 'fs:open-workspace'
→ 主进程验证目录结构(是否有 shots/ 和 assets/
→ 返回 projectList
→ store.setCurrentWorkspace(rootDir)
用户选择项目/集数/阶段
→ CanvasFilterBar.onChange
→ store.setFilters({project, episode, stage})
→ useDirectoryScan hook 自动触发
useDirectoryScan(stage, scanRoots, filters):
→ 防抖 200ms避免连续点选触发多次扫描
→ IPC: 'fs:scan-dir'({roots, exts, recursive})
→ 主进程 walkSync:
for each file:
if ext in allowedExts:
if passesFilters(file, keyword, tag, audit):
readSidecar(file) // 并行读取 .meta.json
mainWindow.webContents.send('scan:found', {path, metadata})
→ 分批 emit每 80 个一批30ms 间隔)
→ 渲染进程收到 → store.addItems(batch)
→ 若 item 在当前页 → 创建 MediaCard 组件
→ MediaCard 挂载 → useMediaLoader.request(path) → 异步加载缩略图
→ onLoad → setBlobURL → <img src={blobURL} />
→ 扫描完成 → store.setScanFinished(true)
```
### 6.2 分页切换
```
PagerOverlay.onClick(page)
→ store.requestPage(page)
→ 防抖 100ms
→ store.gotoPage(page):
1. pageEpoch += 1 ← 过期标记
2. 中止所有 inflight 加载
3. 清空当前页 DOM卸载 MediaCard 组件)
4. 计算 pageBounds(page) ← grid: [start, end];
single: [group_start, group_end]
5. 创建新的 MediaCard 组件
6. 触发 LOD 加载
7. 更新 PagerOverlay 显示
```
### 6.3 文件导入(拖放/粘贴)
```
CanvasViewport.onDrop(files)
→ 提取文件路径
→ 若非 Assets 阶段:
ShotPickerDialog: 选择目标集数/镜头
→ IPC: 'fs:import-file'({src, destDir, prefix})
→ 主进程 fs.copyFile + 创建 .meta.json
→ 若 Assets 阶段:
AssetImportDialog: 选择资产类型/名称 + 标签
→ IPC: 'fs:import-asset'({src, projectPath, assetType, assetName, tags})
→ integrationBus.files_changed.emit(importedPaths)
→ 触发增量扫描(仅扫描新增文件)
```
---
## 七、布局算法
### 7.1 Grid 模式
```typescript
function gridPosition(itemId: number, page: number, cols: number): Position {
const start = (page - 1) * cols * rowsPerPage;
const localIdx = itemId - start;
const row = Math.floor(localIdx / cols);
const col = localIdx % cols;
return {
x: col * (ITEM_W + ITEM_SPACING),
y: row * (ITEM_H + ITEM_SPACING),
};
}
```
### 7.2 Single 模式(按镜头号分组,每行横向排列)
```typescript
function singlePosition(itemId: number, page: number): Position {
const groupIdx = itemToGroup[itemId]; // ← groupKey 映射
const groupsOnPage = getGroupBounds(page); // [startGroup, endGroup)
const row = groupIdx - groupsOnPage[0];
const col = groupItems[groupIdx].indexOf(itemId);
return {
x: ROW_LABEL_WIDTH + SPACING + col * (ITEM_W + ITEM_SPACING),
y: row * (ITEM_H + ITEM_SPACING),
};
}
```
---
## 八、与现有项目的集成
### 8.1 路由
无限画布作为一个 Drawer 叠加层,通过事件总线打开(符合 QT 多窗口模式):
```typescript
// 某个入口按钮:
emit(EVENTS.OPEN_PAGE, {
target: '/infinite-canvas',
modalType: 'drawer',
props: {
workspaceRoot: '/path/to/project',
initialStage: 'Keyframe',
},
});
```
### 8.2 复用现有基础设施
| 现有模块 | 用在哪 |
|-----------------------------------|----------------------------|
| `src/utils/bus.ts` | 事件解耦(替代 Qt Signal |
| `src/infrastructure/storage/` | 媒体缓存元数据持久化sql.js|
| `src/services/request.ts` | CDN 缩略图下载(带 Auth |
| `src/utils/media-upload.ts` | buildAccept / 类型校验 |
| `src/hooks/use-async.ts` | useAsyncData 加载状态管理 |
| `src/components/frame/PageDispatcher.tsx` | Drawer 模式打开画布 |
### 8.3 新增模块
```
src/pages/infinite-canvas/
├── CanvasPage.tsx ← 页面入口Drawer 内容)
├── stores/
│ └── useCanvasStore.ts ← Zustand Store上文设计
├── hooks/
│ ├── useDirectoryScan.ts ← IPC 扫描 Hook
│ ├── useMediaLoader.ts ← LOD 加载调度
│ └── useCanvasInteraction.ts ← 拖拽/缩放/平移交互
├── components/
│ ├── CanvasViewport.tsx ← 核心画布CSS Transform + 虚拟化)
│ ├── MediaCard.tsx ← 缩略图卡片
│ ├── MediaCardContextMenu.tsx ← 右键菜单
│ ├── MediaPreview.tsx ← 全屏预览Lightbox
│ ├── ShotPlaceholder.tsx ← 空镜头占位符
│ ├── RowLabel.tsx ← 行标签
│ ├── PagerOverlay.tsx ← 分页器浮层
│ ├── ZoomOverlay.tsx ← 缩放控件
│ ├── StageTabs.tsx ← 阶段切换
│ ├── FilterBar.tsx ← 筛选栏
│ ├── QuickEditPanel.tsx ← 快速编辑
│ ├── ModeSwitch.tsx ← 模式切换
│ └── AssetImportDialog.tsx ← 资产导入对话框
└── electron/
├── scanner-ipc.ts ← 主进程文件扫描 IPC handler
└── thumbnail-worker.ts ← 缩略图生成 Worker
```
---
## 九、实施路径(分阶段)
### Phase 1目录浏览文件夹 → 文件映射核心)
- [ ] Electron IPC 目录扫描(`scanner-ipc.ts`
- [ ] `buildScanRoots()` + `groupKeyForPath()` 算法实现
- [ ] `useCanvasStore` 基础状态
- [ ] 静态列表展示扫描结果(先做表格验证,不走画布)
### Phase 2画布渲染
- [ ] `CanvasViewport` CSS Transform 虚拟视口
- [ ] `MediaCard` + 棋盘格背景
- [ ] LOD 缩略图加载
- [ ] 分页Grid 模式)
### Phase 3交互完善
- [ ] BROWSE / SELECT / REORDER 模式
- [ ] 右键菜单 + ContextMenuBuilder
- [ ] 拖放导入
- [ ] 快速编辑面板
### Phase 4性能优化
- [ ] 虚拟化(只渲染视口内 item
- [ ] Web Worker 缩略图生成
- [ ] 防抖/节流优化
- [ ] 增量扫描watchdog
---
## 十、关键差异记录
| 原版 (PySide6) | 重构版 (React/Electron) | 理由 |
|----------------------------------------|---------------------------------------|-------------------------------------|
| `QThreadPool` 后台线程扫描 | Electron IPC → 主进程 `fs` 操作 | 浏览器沙箱不能直接操作文件系统 |
| `QImage.scaled()` 本地缩略图 | 服务端 API 或 `sharp` 在主进程生成 | 浏览器 Image 解码有限 |
| `QGraphicsItem.paint()` 自绘 | React 组件 + `<img>` / `<video>` | React 声明式渲染更易维护 |
| QGraphicsScene 自动脏区域管理 | 虚拟化 + React.memo + useMemo | 手动控制渲染范围 |
| sidecar 是独立 `.json` 文件 | **同方案** | 保持兼容,不改变存储格式 |
| `QSharedMemory` 单实例锁 | `app.requestSingleInstanceLock()` | Electron 标准 API |
| `QOpenGLWidget` 硬件加速 | 浏览器 GPU 合成(无需额外操作) | CSS transform 天然触发 GPU 合成 |
---
## 十一、补充注意事项
### 11.1 旁加载元数据 JSON 格式sidecar
原版 `MetadataService` 将元数据和图片文件**同目录、同名、不同扩展名**存放:
```
SC001_Storyboard_001.jpg ← 图片
SC001_Storyboard_001.json ← 元数据(同目录)
```
**JSON 结构(`_default_sidecar_metadata`**
```json
{
"schema_version": 1,
"favorite": false,
"remark": "",
"description": "",
"tags": [],
"create_time": "2026-06-22 14:30:00",
"original_name": "SC001_Storyboard_001.jpg",
"audit_status": "pending"
}
```
⚠️ **关键设计**
- `audit_status` 仅对 **Storyboard / Keyframe / Video** 阶段有效,默认值 `"pending"`
- 审核状态流转:`pending → approved → rework → deprecated`
- **JSON 文件与图片文件是一一对应关系**,删除图片时需要同时清理 JSON
- 重构时必须**读写兼容**已有 JSON 格式(`schema_version` 用于未来迁移)
### 11.2 审核状态汇聚算法
原版 `MetadataService` 有两个汇聚函数,用于计算"镜头"和"集数"层的总体审核状态:
```
computeStageAuditStatus(stageDir):
对该阶段目录下所有文件:
├─ 任一 audit_status === "rework" → 返回 "rework"
├─ 全部 audit_status === "approved" → 返回 "approved"
└─ 否则 → 返回 "pending"
computeShotAuditStatus(shotDir):
收集 [Storyboard, Keyframe, Video] 三个阶段的状态:
├─ 任一阶段 "rework" → 返回 "rework"
├─ 任一阶段 "pending" → 返回 "pending"
└─ 全部 "approved" → 返回 "approved"
```
Web 端需要在扫描完成后**批量计算**这些汇聚值,缓存到 Store 中供 UI 角标使用。
### 11.3 Shot 名称匹配算法(`shot_query` 过滤)
```
shotNameMatchesQuery(shotName: string, query: string): boolean
├─ query 为空 → 匹配所有
├─ query.toUpperCase() in shotName.toUpperCase() → 匹配
├─ query 为纯数字 → 提取 shotName 中所有数字位后比较 → 匹配
│ 例: query="1" 匹配 "SC001"、"SC010"、"EP01_SHOT_1"
└─ 不匹配 → 过滤掉该镜头目录
```
这个算法是用户在筛选栏输入 `1` 就能过滤出所有编号含 1 的镜头的关键。
### 11.4 CanvasLauncher 上下文推导
原版 `CanvasLauncher.launch()` 在打开无限画布时,会根据当前 Tab 和选中路径**自动推导** projectPath 和 episodePath
```
若 tab 为 Storyboard/Keyframe/Video/Audio/LipSync
├─ episodePath 为空 → 从 currentShotPath 向上取一级(父目录)
└─ projectPath 为空 → 沿 episodePath 向上找到 "shots" 的父目录
若 tab 为 Assets
└─ 只需要 projectPath不需要 episodePath
```
重构时 `CanvasPage` 的打开入口也需要实现同样的推导逻辑,确保用户在 Tab 中选中镜头后点击"无限画布"能直接定位到正确目录。
### 11.5 Behavior 链在 React 中的落地
原版的 `IItemBehavior` 协议链是运行时组合的。React 中建议**编译时组合**
```typescript
// 原版:运行时循环调用
// for (const behavior of behaviors) {
// if (behavior.onMousePress(item, event)) break;
// }
// React 版:编译时 HOC / Hook 组合
function MediaCard({ item, mode, ... }: MediaCardProps) {
// 每个行为是一个 Hook按需启用
const selection = useSelectionBehavior(item, { enabled: mode !== 'BROWSE' });
const contextMenu = useContextMenuBehavior(item, { builder: menuBuilder });
const drag = useDragBehavior(item, { enabled: mode !== 'REORDER' });
const preview = usePreviewBehavior(item);
return (
<div
onClick={selection.handleClick}
onContextMenu={contextMenu.handleContextMenu}
onDragStart={drag.handleDragStart}
onDoubleClick={preview.handleDoubleClick}
// ...
>
{/* ... */}
</div>
);
}
```
### 11.6 跨窗口通信Canvas ↔ 宿主 Tab
原版通过 `IntegrationBus`(全局单例 Signal实现画布修改后**通知宿主 Tab 刷新**
```
无限画布中修改了 sidecar 元数据
→ MetadataService.update_sidecar_metadata()
→ EventBus.sidecar_changed.emit([path])
→ IntegrationBus.sidecar_changed.emit([path]) ← 全局信号
→ CanvasLauncher 中的 refresh_timer (250ms 防抖)
→ 宿主 Tab 调用 refresh_callback(tabName)
→ Tab 重新加载数据
```
React 版中,这个链路可以用**事件总线 + 防抖**实现:
```typescript
// 画布中:
emit(EVENTS.SIDECAR_CHANGED, { paths: [changedPath] });
// CanvasLauncher/PageDispatcher 中:
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
const handler = () => {
clearTimeout(timer);
timer = setTimeout(() => refreshCurrentTab(), 250);
};
on(EVENTS.SIDECAR_CHANGED, handler);
return () => off(EVENTS.SIDECAR_CHANGED, handler);
}, []);
```
同样,`files_changed`(文件导入后)也需要通知宿主刷新。
### 11.7 文件变更检测(增量扫描)
原版通过 `integration_bus.files_changed` 支持外部文件变化后触发 `refresh_all()`。重构时需要实现:
- **方案 A**Electron 主进程使用 `fs.watch`(或 `chokidar`)监听工作区目录变化 → IPC 通知渲染进程
- **方案 B**:每次画布窗口获得焦点时,对比文件列表指纹(最后修改时间),有变化才重新扫描
- ⚠️ **推荐方案 B**`fs.watch` 在大目录下有性能问题,而且用户可能通过其他程序修改文件
### 11.8 DOM 渲染数量的硬上限
原版通过**分页**自然限制同时渲染的 item 数量200-400 个。React 版即使做虚拟化,也应该设置硬上限:
| 布局模式 | Max items/页 | 说明 |
|---------|-------------|------|
| Grid | `cols × rows`(默认 9×16=144 | 网格布局 |
| Single | `singleRowsPerPage × 平均每组文件数` | 可能达到数百个 |
- ⚠️ 如果 `singleRowsPerPage` 设为 9999"显示全部"),渲染 10000+ 个 MediaCard 会导致浏览器卡死
- 建议强制上限:**单页最多渲染 500 个 item**,超过时自动分割
### 11.9 Blob URL 生命周期管理
原版 QPixmap 由 Qt 自动管理。React 版的 `URL.createObjectURL(blob)` 必须显式释放:
```typescript
// MediaCard 组件:
useEffect(() => {
return () => {
// 组件卸载时释放 blob URL
if (blobUrl) URL.revokeObjectURL(blobUrl);
};
}, [blobUrl]);
// 切页时批量释放:
function gotoPage(newPage: number) {
// 👇 必须在 scene.clear() 前释放所有 blob URL
for (const item of currentPageItems) {
if (item.blobUrl) URL.revokeObjectURL(item.blobUrl);
}
// ...
}
```
⚠️ **不释放的后果**:每个 blob URL 占用内存直到页面关闭,浏览数百页后可能耗尽浏览器内存。
### 11.10 棋盘格背景的 CSS 实现
原版用 `QPainter` 绘制 2×2 像素棋盘格。CSS 实现:
```css
.media-card-checkerboard {
background-image:
linear-gradient(45deg, #e0e0e0 25%, transparent 25%),
linear-gradient(-45deg, #e0e0e0 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #e0e0e0 75%),
linear-gradient(-45deg, transparent 75%, #e0e0e0 75%);
background-size: 16px 16px;
background-position: 0 0, 0 8px, 8px -8px, -8px 0px;
}
```
比原版的 QImage 缓存方案简单得多,且纯 CSS 零内存开销。
### 11.11 树浏览器ShotBrowser与画布的联动
原版 `AIGCManagerApp` 左侧的 `ShotBrowser`(项目→集数→镜头三层树)通过 `shotSelected` 信号驱动右侧 Tab 加载。重构时这块逻辑可以复用项目已有的 `ModelSelector` / 树形组件模式:
```
左侧 ShotBrowser树组件 右侧画布
───────────────────── ────────
选择项目 → setProject(projectPath) → FilterBar 更新项目下拉
选择集数 → setEpisode(episodePath) → FilterBar 更新集数下拉
选择镜头 → setShot(shotPath) → 按镜头筛选 + 切换 StageTab
```
树节点通过 `computeShotAuditStatus()` 计算审核状态角标颜色(绿=全通过、黄=待审核、红=需返工)。

709
package-lock.json generated
View File

@@ -1,19 +1,22 @@
{ {
"name": "ele-heixiu", "name": "ele-heixiu",
"version": "0.0.13", "version": "0.0.18",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ele-heixiu", "name": "ele-heixiu",
"version": "0.0.13", "version": "0.0.18",
"dependencies": { "dependencies": {
"@react-buddy/ide-toolbox": "^2.5.0", "@react-buddy/ide-toolbox": "^2.5.0",
"antd": "^6.4.3", "antd": "^6.4.3",
"axios": "^1.16.1", "axios": "^1.16.1",
"electron-updater": "^6.8.3", "electron-updater": "^6.8.3",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7" "react-dom": "^19.2.7",
"sharp": "^0.35.2",
"sql.js": "^1.14.1",
"zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.60.0", "@playwright/test": "^1.60.0",
@@ -22,6 +25,8 @@
"@types/node": "^25.9.1", "@types/node": "^25.9.1",
"@types/react": "^19.2.16", "@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/sharp": "^0.31.1",
"@types/sql.js": "^1.4.11",
"@typescript-eslint/eslint-plugin": "^8.60.1", "@typescript-eslint/eslint-plugin": "^8.60.1",
"@typescript-eslint/parser": "^8.60.1", "@typescript-eslint/parser": "^8.60.1",
"@vitejs/plugin-react": "^6.0.0", "@vitejs/plugin-react": "^6.0.0",
@@ -571,9 +576,9 @@
} }
}, },
"node_modules/@electron/get/node_modules/undici": { "node_modules/@electron/get/node_modules/undici": {
"version": "7.27.0", "version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-+t2Z/GwkZQDtu00813aP66ygViGtPHKhhoFZpQKpKrE+9jIgES+Zw+mFNaDWOVRKiuJjuqKHzD3B1sfGg8+ZOQ==", "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -1066,6 +1071,564 @@
"url": "https://github.com/sponsors/nzakas" "url": "https://github.com/sponsors/nzakas"
} }
}, },
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz",
"integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.1"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz",
"integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.1"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz",
"integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.2"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz",
"integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz",
"integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz",
"integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz",
"integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz",
"integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz",
"integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz",
"integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz",
"integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz",
"integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz",
"integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz",
"integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.1"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz",
"integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.1"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz",
"integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.1"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz",
"integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.1"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz",
"integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.1"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz",
"integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.1"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz",
"integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.1"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz",
"integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.1"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz",
"integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.11.1"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-wasm32/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz",
"integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.2"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz",
"integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz",
"integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz",
"integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@isaacs/fs-minipass": { "node_modules/@isaacs/fs-minipass": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@@ -2594,6 +3157,13 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/emscripten": {
"version": "1.41.5",
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
"integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/esrecurse": { "node_modules/@types/esrecurse": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@@ -2675,7 +3245,7 @@
"version": "19.2.16", "version": "19.2.16",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.16.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.16.tgz",
"integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==", "integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -2701,6 +3271,27 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/sharp": {
"version": "0.31.1",
"resolved": "https://registry.npmjs.org/@types/sharp/-/sharp-0.31.1.tgz",
"integrity": "sha512-5nWwamN9ZFHXaYEincMSuza8nNfOof8nmO+mcI+Agx1uMUk4/pQnNIcix+9rLPXzKrm1pS34+6WRDbDV0Jn7ag==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/sql.js": {
"version": "1.4.11",
"resolved": "https://registry.npmjs.org/@types/sql.js/-/sql.js-1.4.11.tgz",
"integrity": "sha512-QXIx38p2ZThJaK9vP5ZdqdlRe1FG9I8SmCZOS7FHfB/2qPAjZwkL7/vlfPg6N/oWHuuOaGg/P/IRwfP2W0kWVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/emscripten": "*",
"@types/node": "*"
}
},
"node_modules/@types/verror": { "node_modules/@types/verror": {
"version": "1.10.11", "version": "1.10.11",
"resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz",
@@ -4194,7 +4785,6 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -5080,16 +5670,16 @@
} }
}, },
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.5", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.8", "combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0", "es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2", "hasown": "^2.0.4",
"mime-types": "^2.1.12" "mime-types": "^2.1.35"
}, },
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@@ -7438,10 +8028,9 @@
} }
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "7.8.1", "version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@@ -7489,6 +8078,50 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/sharp": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz",
"integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==",
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
"semver": "^7.8.4"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.2",
"@img/sharp-darwin-x64": "0.35.2",
"@img/sharp-freebsd-wasm32": "0.35.2",
"@img/sharp-libvips-darwin-arm64": "1.3.1",
"@img/sharp-libvips-darwin-x64": "1.3.1",
"@img/sharp-libvips-linux-arm": "1.3.1",
"@img/sharp-libvips-linux-arm64": "1.3.1",
"@img/sharp-libvips-linux-ppc64": "1.3.1",
"@img/sharp-libvips-linux-riscv64": "1.3.1",
"@img/sharp-libvips-linux-s390x": "1.3.1",
"@img/sharp-libvips-linux-x64": "1.3.1",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.1",
"@img/sharp-libvips-linuxmusl-x64": "1.3.1",
"@img/sharp-linux-arm": "0.35.2",
"@img/sharp-linux-arm64": "0.35.2",
"@img/sharp-linux-ppc64": "0.35.2",
"@img/sharp-linux-riscv64": "0.35.2",
"@img/sharp-linux-s390x": "0.35.2",
"@img/sharp-linux-x64": "0.35.2",
"@img/sharp-linuxmusl-arm64": "0.35.2",
"@img/sharp-linuxmusl-x64": "0.35.2",
"@img/sharp-webcontainers-wasm32": "0.35.2",
"@img/sharp-win32-arm64": "0.35.2",
"@img/sharp-win32-ia32": "0.35.2",
"@img/sharp-win32-x64": "0.35.2"
}
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -7599,6 +8232,12 @@
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"optional": true "optional": true
}, },
"node_modules/sql.js": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz",
"integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==",
"license": "MIT"
},
"node_modules/stat-mode": { "node_modules/stat-mode": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz",
@@ -7881,7 +8520,6 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD", "license": "0BSD",
"optional": true "optional": true
}, },
@@ -7920,9 +8558,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "6.26.0", "version": "6.27.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz",
"integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==", "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -8283,6 +8921,35 @@
"peerDependencies": { "peerDependencies": {
"zod": "^3.25.0 || ^4.0.0" "zod": "^3.25.0 || ^4.0.0"
} }
},
"node_modules/zustand": {
"version": "5.0.14",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",
"integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
} }
} }
} }

View File

@@ -1,22 +1,29 @@
{ {
"name": "ele-heixiu", "name": "ele-heixiu",
"private": true, "private": true,
"version": "0.0.18", "version": "0.0.28",
"description": "船长·HeiXiu — 桌面效率工作台", "description": "船长·HeiXiu — 桌面效率工作台",
"author": "HeiXiu 杨烨", "author": "HeiXiu 杨烨",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "chcp 65001 > null && cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ vite", "dev": "chcp 65001 > null && cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ vite",
"build": "node build.mjs --win --personal --build", "build": "node build/build.mjs --win --build",
"build:win": "node build.mjs --win --personal --build", "build:win": "node build/build.mjs --win --build",
"build:win:enterprise": "node build.mjs --win --enterprise --build", "build:win:enterprise": "node build/build.mjs --win --edition enterprise --build",
"build:mac": "node build.mjs --mac --personal --build", "build:win:beta": "node build/build.mjs --win --channel beta --build",
"build:mac:enterprise": "node build.mjs --mac --enterprise --build", "build:win:alpha": "node build/build.mjs --win --channel alpha --build",
"build:linux": "node build.mjs --linux --personal --build", "build:mac": "node build/build.mjs --mac --build",
"build:linux:enterprise": "node build.mjs --linux --enterprise --build", "build:mac:enterprise": "node build/build.mjs --mac --edition enterprise --build",
"build:clean": "node scripts/clean.mjs", "build:linux": "node build/build.mjs --linux --build",
"upload:oss": "node scripts/upload-oss.mjs", "build:linux:enterprise": "node build/build.mjs --linux --edition enterprise --build",
"publish:release": "node scripts/publish-release.mjs", "build:clean": "node build/scripts/clean.mjs",
"upload:oss": "node build/scripts/upload-oss.mjs",
"publish:release": "node build/scripts/publish-release.mjs",
"version:patch": "node build/scripts/bump-version.mjs patch",
"version:minor": "node build/scripts/bump-version.mjs minor",
"version:major": "node build/scripts/bump-version.mjs major",
"version:pre-beta": "node build/scripts/bump-version.mjs pre beta",
"version:pre-alpha": "node build/scripts/bump-version.mjs pre alpha",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview", "preview": "vite preview",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\" \"electron/**/*.ts\" \"shared/**/*.ts\"", "format": "prettier --write \"src/**/*.{ts,tsx,css}\" \"electron/**/*.ts\" \"shared/**/*.ts\"",
@@ -31,61 +38,10 @@
"axios": "^1.16.1", "axios": "^1.16.1",
"electron-updater": "^6.8.3", "electron-updater": "^6.8.3",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7" "react-dom": "^19.2.7",
}, "sharp": "^0.35.2",
"build": { "sql.js": "^1.14.1",
"appId": "com.heixiu.electron", "zustand": "^5.0.14"
"productName": "船长·HeiXiu",
"asar": true,
"directories": {
"output": "release/${version}"
},
"files": [
"dist",
"dist-electron"
],
"electronDownload": {
"mirror": "https://npmmirror.com/mirrors/electron/"
},
"publish": {
"provider": "generic",
"url": "https://update.heixiu.com"
},
"win": {
"icon": "src/assets/logo/heixiu.ico",
"target": [
{
"target": "NSIS",
"arch": [
"x64"
]
}
],
"artifactName": "${productName}-Windows-${version}-${env.EDITION}-Setup.${ext}"
},
"mac": {
"icon": "src/assets/logo/heixiu.icns",
"target": [
"dmg"
],
"artifactName": "${productName}-Mac-${version}-${env.EDITION}-Installer.${ext}"
},
"linux": {
"icon": "src/assets/logo/HX.png",
"target": [
"AppImage"
],
"artifactName": "${productName}-Linux-${version}-${env.EDITION}.${ext}"
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true,
"deleteAppDataOnUninstall": false,
"differentialPackage": true,
"installerIcon": "src/assets/logo/heixiu.ico",
"uninstallerIcon": "src/assets/logo/heixiu.ico"
}
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.60.0", "@playwright/test": "^1.60.0",
@@ -94,6 +50,8 @@
"@types/node": "^25.9.1", "@types/node": "^25.9.1",
"@types/react": "^19.2.16", "@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/sharp": "^0.31.1",
"@types/sql.js": "^1.4.11",
"@typescript-eslint/eslint-plugin": "^8.60.1", "@typescript-eslint/eslint-plugin": "^8.60.1",
"@typescript-eslint/parser": "^8.60.1", "@typescript-eslint/parser": "^8.60.1",
"@vitejs/plugin-react": "^6.0.0", "@vitejs/plugin-react": "^6.0.0",

Binary file not shown.

BIN
public/sql-wasm.wasm Normal file

Binary file not shown.

View File

@@ -1,248 +0,0 @@
// ============================================================
// 上传安装包 + blockmap 文件到阿里云 OSS
//
// 用法:
// node scripts/upload-oss.mjs <platform> <edition> [version]
//
// 环境变量(使用 Node.js 直传时需要):
// OSS_ENDPOINT — OSS endpoint如 oss-cn-hangzhou.aliyuncs.com
// OSS_BUCKET — Bucket 名称
// OSS_ACCESS_KEY_ID — AccessKey ID
// OSS_ACCESS_KEY_SECRET — AccessKey Secret
// OSS_BASE_PATH — Bucket 内基路径,默认 releases
//
// 不传 OSS 环境变量时 → 打印手动上传命令ossutil / 控制台)
//
// 示例:
// # 仅打印上传命令
// node scripts/upload-oss.mjs win32 personal 0.1.0
//
// # 直接上传
// OSS_BUCKET=heixiu OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com \
// OSS_ACCESS_KEY_ID=xxx OSS_ACCESS_KEY_SECRET=xxx \
// node scripts/upload-oss.mjs win32 personal 0.1.0
// ============================================================
import {readFileSync, readdirSync} from 'node:fs';
import {join, dirname} from 'node:path';
import {fileURLToPath} from 'node:url';
import {createHmac} from 'node:crypto';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
// ---------- 参数 ----------
const platform = process.argv[2];
const edition = process.argv[3];
if (!platform || !edition) {
console.error('用法: node scripts/upload-oss.mjs <platform> <edition> [version]');
console.error('示例: node scripts/upload-oss.mjs win32 personal 0.1.0');
process.exit(1);
}
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
const version = process.argv[4] || pkg.version;
// ---------- 查找安装包 ----------
const releaseDir = join(ROOT, 'release', version);
let installerName;
try {
const all = readdirSync(releaseDir);
const exts = platform === 'win32' ? ['.exe'] : platform === 'darwin' ? ['.dmg'] : ['.AppImage'];
installerName = all.find((f) => exts.some((e) => f.endsWith(e)));
if (!installerName) {
console.error(`错误: ${releaseDir} — 未找到安装包`);
process.exit(1);
}
} catch (err) {
console.error(`错误: ${releaseDir}${err.message}`);
process.exit(1);
}
const installerPath = join(releaseDir, installerName);
// ---------- 查找 blockmap 文件 ----------
let blockmapName = null;
let blockmapPath = null;
try {
const all = readdirSync(releaseDir);
blockmapName = all.find((f) => f.endsWith('.blockmap'));
if (blockmapName) {
blockmapPath = join(releaseDir, blockmapName);
}
} catch {
// blockmap 不存在不影响主流程
}
// ---------- OSS 配置 ----------
const {
OSS_ENDPOINT,
OSS_BUCKET,
OSS_ACCESS_KEY_ID,
OSS_ACCESS_KEY_SECRET,
} = process.env;
const basePath = (process.env.OSS_BASE_PATH || 'releases').replace(/^\/+|\/+$/g, '');
const objectKey = `${basePath}/${version}/${installerName}`;
// ============================================================
// 模式 A无 OSS 配置 → 打印手动命令
// ============================================================
if (!OSS_ENDPOINT || !OSS_BUCKET || !OSS_ACCESS_KEY_ID) {
const exampleBucket = OSS_BUCKET || '<bucket>';
const exampleEndpoint = OSS_ENDPOINT || '<endpoint>';
const downloadUrl = `https://${exampleBucket}.${exampleEndpoint}/${objectKey}`;
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('📦 上传安装包 + blockmap手动');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log(`文件: ${installerPath}`);
console.log(`大小: ${(readFileSync(installerPath).length / 1024 / 1024).toFixed(2)} MB\n`);
if (blockmapPath) {
console.log(`Blockmap: ${blockmapPath}`);
console.log(`Blockmap 大小: ${(readFileSync(blockmapPath).length / 1024).toFixed(2)} KB\n`);
}
console.log('方式 1 — ossutil CLI推荐');
console.log(` ossutil cp "${installerPath}" oss://${exampleBucket}/${objectKey}`);
if (blockmapPath) {
const blockmapKey = `${basePath}/${version}/${blockmapName}`;
console.log(` ossutil cp "${blockmapPath}" oss://${exampleBucket}/${blockmapKey}`);
}
console.log('');
console.log('方式 2 — 阿里云控制台:');
console.log(` 登录 OSS 控制台 → ${exampleBucket} → 上传 → 目标路径: ${objectKey}`);
if (blockmapPath) {
console.log(` 同时上传 ${blockmapName} 到相同目录`);
}
console.log('');
const blockmapUrl = blockmapPath
? `https://${exampleBucket}.${exampleEndpoint}/${basePath}/${version}/${blockmapName}`
: '';
console.log('上传后执行以下命令,用实际 URL 重新生成 info');
if (blockmapUrl) {
console.log(` node scripts/generate-update-info.mjs ${platform} ${edition} ${version} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`);
console.log('');
console.log('或直接发布到后端 API');
console.log(` node scripts/publish-release.mjs ${platform} ${edition} ${version} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`);
} else {
console.log(` node scripts/generate-update-info.mjs ${platform} ${edition} ${version} --url "${downloadUrl}"`);
console.log('');
console.log('或直接发布到后端 API');
console.log(` node scripts/publish-release.mjs ${platform} ${edition} ${version} --url "${downloadUrl}"`);
}
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
process.exit(0);
}
// ============================================================
// 模式 BNode.js 直传 OSS安装包 + blockmap
// ============================================================
const fileBuffer = readFileSync(installerPath);
const contentType = platform === 'win32'
? 'application/vnd.microsoft.portable-executable'
: 'application/octet-stream';
// 上传安装包
const date = new Date().toUTCString();
const stringToSign = [
'PUT', '', contentType, date, `/${OSS_BUCKET}/${objectKey}`,
].join('\n');
const signature = createHmac('sha1', OSS_ACCESS_KEY_SECRET)
.update(stringToSign)
.digest('base64');
const authHeader = `OSS ${OSS_ACCESS_KEY_ID}:${signature}`;
const downloadUrl = `https://${OSS_BUCKET}.${OSS_ENDPOINT}/${objectKey}`;
console.log(`上传中: ${installerName} (${(fileBuffer.length / 1024 / 1024).toFixed(2)} MB)`);
console.log(`目标: oss://${OSS_BUCKET}/${objectKey}`);
try {
const response = await fetch(downloadUrl, {
method: 'PUT',
headers: {
'Content-Type': contentType,
'Content-Length': String(fileBuffer.length),
'Date': date,
'Authorization': authHeader,
},
body: fileBuffer,
});
if (!response.ok) {
const body = await response.text();
console.error(`❌ 安装包上传失败 HTTP ${response.status}: ${body}`);
process.exit(1);
}
console.log(`✅ 安装包上传成功`);
console.log(`下载 URL: ${downloadUrl}`);
} catch (err) {
console.error(`❌ 安装包上传失败: ${err.message}`);
process.exit(1);
}
// 上传 blockmap 文件(如果存在)
let blockmapUrl = null;
if (blockmapPath) {
const blockmapBuffer = readFileSync(blockmapPath);
const blockmapKey = `${basePath}/${version}/${blockmapName}`;
blockmapUrl = `https://${OSS_BUCKET}.${OSS_ENDPOINT}/${blockmapKey}`;
const bmDate = new Date().toUTCString();
const bmStringToSign = [
'PUT', '', 'application/octet-stream', bmDate, `/${OSS_BUCKET}/${blockmapKey}`,
].join('\n');
const bmSignature = createHmac('sha1', OSS_ACCESS_KEY_SECRET)
.update(bmStringToSign)
.digest('base64');
console.log(`\n上传 blockmap: ${blockmapName} (${(blockmapBuffer.length / 1024).toFixed(2)} KB)`);
try {
const bmResponse = await fetch(blockmapUrl, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
'Content-Length': String(blockmapBuffer.length),
'Date': bmDate,
'Authorization': `OSS ${OSS_ACCESS_KEY_ID}:${bmSignature}`,
},
body: blockmapBuffer,
});
if (bmResponse.ok) {
console.log(`✅ Blockmap 上传成功`);
console.log(`Blockmap URL: ${blockmapUrl}`);
} else {
console.warn(`⚠️ Blockmap 上传失败不影响主流程HTTP ${bmResponse.status}`);
blockmapUrl = null;
}
} catch (err) {
console.warn(`⚠️ Blockmap 上传失败(不影响主流程): ${err.message}`);
blockmapUrl = null;
}
}
// 后续步骤提示
console.log('\n接下来 — 重新生成 update-info.json');
if (blockmapUrl) {
console.log(` node scripts/generate-update-info.mjs ${platform} ${edition} ${version} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`);
console.log('\n或直接发布到后端 API');
console.log(` node scripts/publish-release.mjs ${platform} ${edition} ${version} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`);
} else {
console.log(` node scripts/generate-update-info.mjs ${platform} ${edition} ${version} --url "${downloadUrl}"`);
console.log('\n或直接发布到后端 API');
console.log(` node scripts/publish-release.mjs ${platform} ${edition} ${version} --url "${downloadUrl}"`);
}

View File

@@ -6,25 +6,18 @@
import type { Edition, Platform } from '../types'; import type { Edition, Platform } from '../types';
import { APP_VERSION } from './version'; import { APP_VERSION } from './version';
/** 应用显示名称 */ /** 应用显示名称(文件/标题通用前缀) */
export const APP_NAME = '船长·HeiXiu'; export const APP_NAME = '船长-HeiXiu';
// APP_VERSION 从 ./version.ts 导入(读取 package.json不再硬编码 // APP_VERSION 从 ./version.ts 导入(读取 package.json不再硬编码
// ============================================================ // ============================================================
// 版本类型(企业版 / 个人版) // 版本类型(企业版 / 个人版)
//
// 注意edition 由后端 account_type 运行时决定,不由构建变量控制。
// 不再需要 parseEdition() —— 构建时 EDITION 环境变量已移除。
// ============================================================ // ============================================================
/**
* 解析版本类型字符串
* @param raw - 环境变量值,如 "enterprise" / undefined
* @returns 版本类型,默认 personal
*/
export function parseEdition(raw: string | undefined): Edition {
if (raw === 'enterprise') return 'enterprise';
return 'personal';
}
/** 版本中文名称 */ /** 版本中文名称 */
export function getEditionName(edition: Edition): string { export function getEditionName(edition: Edition): string {
return edition === 'enterprise' ? '企业版' : '个人版'; return edition === 'enterprise' ? '企业版' : '个人版';
@@ -42,18 +35,24 @@ function platformShortName(platform: Platform): string {
} }
/** /**
* 构建完整窗口标题 * 构建窗口标题
* 格式船长·HeiXiu V{版本号} {平台}{版本} *
* 示例:"船长·HeiXiu V0.0.1 MAC企业版" * 格式(无版控):船长-HeiXiu-{平台}-{版本号}
* "船长·HeiXiu V0.0.1 Windows个人版" * 示例:船长-HeiXiu-Windows-0.0.23
*
* 格式(含版控):船长-HeiXiu-{平台}-{版本号}·{版控}
* 示例:船长-HeiXiu-Windows-0.0.23·企业版
* *
* @param platform - 当前操作系统平台 * @param platform - 当前操作系统平台
* @param edition - 版本类型
* @param version - 可选覆盖版本号(默认 APP_VERSION * @param version - 可选覆盖版本号(默认 APP_VERSION
* @param edition - 可选版本类型(登录后追加,未登录不显示)
*/ */
export function buildWindowTitle(platform: Platform, edition: Edition, version?: string): string { export function buildWindowTitle(platform: Platform, version?: string, edition?: Edition): string {
const ver = version ?? APP_VERSION; const ver = version ?? APP_VERSION;
const os = platformShortName(platform); const os = platformShortName(platform);
const edName = getEditionName(edition); const base = `${APP_NAME}-${os}-${ver}`;
return `${APP_NAME} V${ver} ${os}${edName}`; if (edition) {
return `${base}·${getEditionName(edition)}`;
}
return base;
} }

View File

@@ -1,49 +1,18 @@
// ============================================================ // ============================================================
// IPC 通道名称常量 —— 避免魔法字符串 // IPC 通道名称常量 — 兼容层(已拆分为 ipc/ 子模块)
//
// @deprecated 请使用新的模块化导入:
// import { BIDIRECTIONAL } from '@/shared/constants/ipc';
// import { BIDIRECTIONAL_STORAGE } from '@/shared/constants/ipc/storage';
// ============================================================ // ============================================================
/** 主进程 → 渲染进程 */ export {
export const MAIN_TO_RENDERER = { MAIN_TO_RENDERER,
/** 主进程消息推送 */ RENDERER_TO_MAIN,
MAIN_PROCESS_MESSAGE: 'main-process-message', BIDIRECTIONAL,
/** 主题变更通知 */ MAIN_TO_RENDERER_CANVAS,
THEME_CHANGED: 'theme-changed', RENDERER_TO_MAIN_CANVAS,
/** 窗口聚焦通知 */ BIDIRECTIONAL_CANVAS,
WINDOW_FOCUSED: 'window-focused', MAIN_TO_RENDERER_UPDATER,
/** 窗口失焦通知 */ RENDERER_TO_MAIN_UPDATER,
WINDOW_BLURRED: 'window-blurred', } from './ipc';
} as const;
/** 渲染进程 → 主进程 */
export const RENDERER_TO_MAIN = {
/** 打开子窗口 */
OPEN_SUB_WINDOW: 'open-sub-window',
/** 关闭子窗口 */
CLOSE_SUB_WINDOW: 'close-sub-window',
/** 设置窗口标题 */
SET_WINDOW_TITLE: 'set-window-title',
/** 获取平台信息 */
GET_PLATFORM_INFO: 'get-platform-info',
/** 最小化窗口 */
MINIMIZE_WINDOW: 'minimize-window',
/** 最大化/还原窗口 */
TOGGLE_MAXIMIZE: 'toggle-maximize',
/** 关闭窗口 */
CLOSE_WINDOW: 'close-window',
/** 渲染进程日志上报 */
LOG_MESSAGE: 'log-message',
} as const;
/** 双向通信通道 */
export const BIDIRECTIONAL = {
/** 读取应用配置 */
READ_CONFIG: 'read-config',
/** 写入应用配置 */
WRITE_CONFIG: 'write-config',
/** 文件对话框 */
FILE_DIALOG: 'file-dialog',
/** safeStorage 加密(明文 → Base64 密文) */
SAFESTORAGE_ENCRYPT: 'safe-storage:encrypt',
/** safeStorage 解密Base64 密文 → 明文) */
SAFESTORAGE_DECRYPT: 'safe-storage:decrypt',
} as const;

View File

@@ -0,0 +1,45 @@
// ============================================================
// IPC 通道常量 — 基础通道(窗口、日志、主题等)
// ============================================================
/** 主进程 → 渲染进程 */
export const MAIN_TO_RENDERER = {
/** 主进程消息推送 */
MAIN_PROCESS_MESSAGE: 'main-process-message',
/** 主题变更通知 */
THEME_CHANGED: 'theme-changed',
/** 窗口聚焦通知 */
WINDOW_FOCUSED: 'window-focused',
/** 窗口失焦通知 */
WINDOW_BLURRED: 'window-blurred',
} as const;
/** 渲染进程 → 主进程 */
export const RENDERER_TO_MAIN = {
/** 打开子窗口 */
OPEN_SUB_WINDOW: 'open-sub-window',
/** 关闭子窗口 */
CLOSE_SUB_WINDOW: 'close-sub-window',
/** 设置窗口标题 */
SET_WINDOW_TITLE: 'set-window-title',
/** 获取平台信息 */
GET_PLATFORM_INFO: 'get-platform-info',
/** 最小化窗口 */
MINIMIZE_WINDOW: 'minimize-window',
/** 最大化/还原窗口 */
TOGGLE_MAXIMIZE: 'toggle-maximize',
/** 关闭窗口 */
CLOSE_WINDOW: 'close-window',
/** 渲染进程日志上报 */
LOG_MESSAGE: 'log-message',
} as const;
/** 双向通信通道 — 应用配置 */
export const BIDIRECTIONAL_CONFIG = {
/** 读取应用配置 */
READ_CONFIG: 'read-config',
/** 写入应用配置 */
WRITE_CONFIG: 'write-config',
/** 文件对话框 */
FILE_DIALOG: 'file-dialog',
} as const;

View File

@@ -0,0 +1,45 @@
// ============================================================
// IPC 通道常量 — 无限画布相关通道
// ============================================================
/** 主进程 → 渲染进程 — 画布 */
export const MAIN_TO_RENDERER_CANVAS = {
/** 画布窗口准备就绪 */
CANVAS_READY: 'canvas:ready',
/** 画布扫描进度 */
CANVAS_SCAN_PROGRESS: 'canvas:scan-progress',
} as const;
/** 渲染进程 → 主进程 — 画布 */
export const RENDERER_TO_MAIN_CANVAS = {
/** 画布:打开无限画布窗口 */
OPEN_CANVAS_WINDOW: 'canvas:open-window',
} as const;
/** 双向通信通道 — 画布 */
export const BIDIRECTIONAL_CANVAS = {
/** 画布:扫描目录 */
CANVAS_SCAN_DIRECTORY: 'canvas:scan-directory',
/** 画布:扫描项目(接收 projectPath主进程内部完成 buildScanRoots + walkDirectory */
CANVAS_SCAN_PROJECT: 'canvas:scan-project',
/** 画布:读取 sidecar 元数据 */
CANVAS_READ_SIDECAR: 'canvas:read-sidecar',
/** 画布:写入 sidecar 元数据 */
CANVAS_WRITE_SIDECAR: 'canvas:write-sidecar',
/** 画布:生成缩略图 */
CANVAS_GENERATE_THUMBNAIL: 'canvas:generate-thumbnail',
/** 画布:复制文件(导入) */
CANVAS_COPY_FILE: 'canvas:copy-file',
/** 画布:获取原生图片尺寸 */
CANVAS_GET_IMAGE_SIZE: 'canvas:get-image-size',
/** 画布:列出目录下的子文件夹(非递归,排除隐藏) */
CANVAS_LIST_SUBDIRS: 'canvas:list-subdirs',
/** 画布:扫描阶段文件(单个 episode/shot/stage 组合) */
CANVAS_SCAN_STAGE_FILES: 'canvas:scan-stage-files',
/** 画布:扫描资产文件(深度递归扫描 assets/ 目录) */
CANVAS_SCAN_ASSET_FILES: 'canvas:scan-asset-files',
/** 画布:创建目录 */
CANVAS_CREATE_DIRECTORY: 'canvas:create-directory',
/** 画布:启动原生文件拖拽(拖放媒体文件到外部应用) */
CANVAS_START_DRAG: 'canvas:start-drag',
} as const;

View File

@@ -0,0 +1,25 @@
// ============================================================
// IPC 通道常量 — 汇总导出
//
// 使用方式:
// import { MAIN_TO_RENDERER, BIDIRECTIONAL } from '@/shared/constants/ipc';
// import { BIDIRECTIONAL_STORAGE } from '@/shared/constants/ipc/storage';
// ============================================================
export { MAIN_TO_RENDERER, RENDERER_TO_MAIN, BIDIRECTIONAL_CONFIG } from './base';
export { BIDIRECTIONAL_STORAGE } from './storage';
export { MAIN_TO_RENDERER_CANVAS, RENDERER_TO_MAIN_CANVAS, BIDIRECTIONAL_CANVAS } from './canvas';
export { MAIN_TO_RENDERER_UPDATER, RENDERER_TO_MAIN_UPDATER } from './updater';
// ---------- 兼容性汇总(向后兼容) ----------
import { BIDIRECTIONAL_CONFIG } from './base';
import { BIDIRECTIONAL_STORAGE } from './storage';
import { BIDIRECTIONAL_CANVAS } from './canvas';
/** @deprecated 请使用按功能域拆分的导出 */
export const BIDIRECTIONAL = {
...BIDIRECTIONAL_CONFIG,
...BIDIRECTIONAL_STORAGE,
...BIDIRECTIONAL_CANVAS,
} as const;

View File

@@ -0,0 +1,37 @@
// ============================================================
// IPC 通道常量 — 存储相关通道
// ============================================================
/** 双向通信通道 — 文件存储 */
export const BIDIRECTIONAL_STORAGE = {
/** safeStorage 加密(明文 → Base64 密文) */
SAFESTORAGE_ENCRYPT: 'safe-storage:encrypt',
/** safeStorage 解密Base64 密文 → 明文) */
SAFESTORAGE_DECRYPT: 'safe-storage:decrypt',
/** 文件写入Blob Buffer → 磁盘) */
FILE_WRITE: 'storage:file-write',
/** 文件读取(磁盘 → Base64 */
FILE_READ: 'storage:file-read',
/** 文件删除 */
FILE_DELETE: 'storage:file-delete',
/** 创建目录(递归) */
MAKE_DIR: 'storage:make-dir',
/** 在文件管理器中显示文件 */
SHOW_ITEM_IN_FOLDER: 'storage:show-item-in-folder',
/** 获取应用数据目录路径 */
GET_DATA_DIR: 'storage:get-data-dir',
/** 获取文件大小 */
FILE_SIZE: 'storage:file-size',
/** 文件/目录是否存在 */
FILE_EXISTS: 'storage:file-exists',
/** 绝对路径文件写入(绕过 heixiu-data 沙箱,用于用户自定义输出目录) */
FILE_WRITE_ABSOLUTE: 'storage:file-write-absolute',
/** 绝对路径目录创建(绕过 heixiu-data 沙箱) */
MAKE_DIR_ABSOLUTE: 'storage:make-dir-absolute',
/** 主进程下载文件到绝对路径(绕过浏览器 CORS 限制) */
DOWNLOAD_TO_PATH: 'storage:download-to-path',
/** 获取系统下载目录路径 */
GET_DOWNLOADS_PATH: 'storage:get-downloads-path',
/** 生成图片缩略图(读取原图 → 缩放 → 写入目标路径) */
GENERATE_THUMBNAIL: 'storage:generate-thumbnail',
} as const;

View File

@@ -0,0 +1,27 @@
// ============================================================
// IPC 通道常量 — 自动更新相关通道
// ============================================================
/** 主进程 → 渲染进程 — 更新 */
export const MAIN_TO_RENDERER_UPDATER = {
/** 发现新版本 */
UPDATE_AVAILABLE: 'update-available',
/** 下载进度 */
UPDATE_PROGRESS: 'update-progress',
/** 更新已下载完成 */
UPDATE_DOWNLOADED: 'update-downloaded',
/** 更新出错 */
UPDATE_ERROR: 'update-error',
/** 当前已是最新版本 */
UPDATE_NOT_AVAILABLE: 'update-not-available',
} as const;
/** 渲染进程 → 主进程 — 更新 */
export const RENDERER_TO_MAIN_UPDATER = {
/** 安装更新 */
INSTALL_UPDATE: 'install-update',
/** 检查更新 */
CHECK_FOR_UPDATE: 'check-for-update',
/** 下载更新 */
DOWNLOAD_UPDATE: 'download-update',
} as const;

View File

@@ -0,0 +1,75 @@
// ============================================================
// Keyboard Key Constants — 键盘按键枚举值
//
// 用法:
// import { Key } from '@shared/constants/keyboard';
// if (e.key === Key.Delete) { ... }
// if (e.key === Key.ArrowLeft) { ... }
//
// 避免手动编写字符串,防止拼写错误。
// ============================================================
export const Key = {
// ── 导航键 ──
Enter: 'Enter',
Escape: 'Escape',
Tab: 'Tab',
Space: ' ',
Backspace: 'Backspace',
Delete: 'Delete',
// ── 方向键 ──
ArrowUp: 'ArrowUp',
ArrowDown: 'ArrowDown',
ArrowLeft: 'ArrowLeft',
ArrowRight: 'ArrowRight',
// ── 定位键 ──
Home: 'Home',
End: 'End',
PageUp: 'PageUp',
PageDown: 'PageDown',
// ── 修饰键 ──
Control: 'Control',
Shift: 'Shift',
Alt: 'Alt',
Meta: 'Meta',
// ── 字母 ──
A: 'a', B: 'b', C: 'c', D: 'd',
E: 'e', F: 'f', G: 'g', H: 'h',
I: 'i', J: 'j', K: 'k', L: 'l',
M: 'm', N: 'n', O: 'o', P: 'p',
Q: 'q', R: 'r', S: 's', T: 't',
U: 'u', V: 'v', W: 'w', X: 'x',
Y: 'y', Z: 'z',
// ── 数字 ──
Digit0: '0',
Digit1: '1',
Digit2: '2',
Digit3: '3',
Digit4: '4',
Digit5: '5',
Digit6: '6',
Digit7: '7',
Digit8: '8',
Digit9: '9',
// ── 功能键 ──
F1: 'F1',
F2: 'F2',
F3: 'F3',
F4: 'F4',
F5: 'F5',
F6: 'F6',
F7: 'F7',
F8: 'F8',
F9: 'F9',
F10: 'F10',
F11: 'F11',
F12: 'F12',
} as const;
export type Key = (typeof Key)[keyof typeof Key];

View File

@@ -14,7 +14,10 @@ export type LogCategory =
| 'app' // 应用生命周期 | 'app' // 应用生命周期
| 'ui' // UI 层错误 / 警告 | 'ui' // UI 层错误 / 警告
| 'ipc' // IPC 通信 | 'ipc' // IPC 通信
| 'safe-storage'; // 安全存储safeStorage 加密/解密) | 'safe-storage' // 安全存储safeStorage 加密/解密)
| 'storage' // 本地存储IndexedDB / sql.js / 文件缓存)
| 'canvas' // 无限画布 IPC 处理
| 'canvas-window'; // 画布窗口生命周期
/** /**
* 结构化错误快照 * 结构化错误快照

View File

@@ -1,80 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { useAppContext } from '@/components/AppProvider';
import { LoginPage } from './pages/login';
import { SettingsPage } from './pages/settings';
import { on, off, EVENTS } from './utils/event-bus';
import { Layout } from '@/components/Layout';
import { HomePage } from '@/pages/home/HomePage';
import { PageDispatcher } from '@/components/PageDispatcher';
/**
* 根组件 — 主窗口
*
* 层级:
* LayoutBanner + NavBar
* └── HomePage主页面始终挂载
* LoginPageModal 弹层,事件总线驱动)
* SettingsPageModal 居中叠加,事件总线驱动 — 对应 QT 原版设置窗口)
*
* 设计原则(对应 QT 原版多窗口模型):
* - 叠加层(设置、公告、页面等)→ Modal/Drawer + 事件总线,主页面不卸载
* - 不再使用 React Router所有导航由事件总线驱动
*/
function App() {
const { isLoggedIn } = useAppContext();
// 登录 Modal 状态
const [loginOpen, setLoginOpen] = useState(false);
// 首次加载时未登录 → 弹出
useEffect(() => {
if (!isLoggedIn) {
setLoginOpen(true);
}
}, [isLoggedIn]);
// 监听事件总线401 → 弹出登录 / 导航栏点击登录 → 弹出登录
useEffect(() => {
const handler = () => setLoginOpen(true);
on(EVENTS.AUTH_REQUIRED, handler);
on(EVENTS.SHOW_LOGIN, handler);
return () => {
off(EVENTS.AUTH_REQUIRED, handler);
off(EVENTS.SHOW_LOGIN, handler);
};
}, []);
const handleLoginClose = useCallback(() => {
setLoginOpen(false);
}, []);
// 设置 Drawer 状态
const [settingsOpen, setSettingsOpen] = useState(false);
// 监听打开设置 Drawer
useEffect(() => {
const handler = () => setSettingsOpen(true);
on(EVENTS.OPEN_SETTINGS, handler);
return () => { off(EVENTS.OPEN_SETTINGS, handler); };
}, []);
return (
<>
<Layout>
<HomePage />
</Layout>
{/* 登录 Modal — 可关闭,关闭后主页可操作 */}
<LoginPage open={loginOpen && !isLoggedIn} onClose={handleLoginClose} />
{/* 设置 Modal — 事件驱动,居中叠加,主页面始终挂载(对应 QT 原版独立设置窗口) */}
<SettingsPage open={settingsOpen} onClose={() => setSettingsOpen(false)} />
{/* 页面调度器 — 监听 OPEN_PAGE 事件,根据 modalType 渲染 Modal/Drawer/FloatingPanel */}
<PageDispatcher />
</>
);
}
export default App;

121
src/app/App.tsx Normal file
View File

@@ -0,0 +1,121 @@
import { useState, useEffect, useCallback } from 'react';
import { App as AntdApp } from 'antd';
import { useAppContext } from '@/app/providers/AppProvider';
import { LoginPage } from '@/modules/auth/views/AuthPage';
import { SettingsPage } from '@/modules/settings';
import { on, off, emit, EVENTS } from '@/shared/utils/bus';
import type { BusinessErrorEvent } from '@/shared/utils/bus';
import { Layout } from '@/app/layout/Layout';
import { HomePage } from '@/modules/home/shared/views/HomePage';
import { PageDispatcher } from '@/app/layout/PageDispatcher';
/**
* 根组件 — 主窗口
*
* 层级:
* LayoutBanner + NavBar
* └── HomePage主页面始终挂载
* LoginPageModal 弹层,事件总线驱动)
* SettingsPageModal 居中叠加,事件总线驱动 — 对应 QT 原版设置窗口)
*
* 设计原则(对应 QT 原版多窗口模型):
* - 叠加层(设置、公告、页面等)→ Modal/Drawer + 事件总线,主页面不卸载
* - 不再使用 React Router所有导航由事件总线驱动
*/
function App() {
const { isLoggedIn } = useAppContext();
const { modal } = AntdApp.useApp();
// 登录 Modal 状态
const [loginOpen, setLoginOpen] = useState(false);
// 首次加载时未登录 → 弹出
useEffect(() => {
if (!isLoggedIn) {
setLoginOpen(true);
}
}, [isLoggedIn]);
// 监听事件总线401 → 弹出登录 / 导航栏点击登录 → 弹出登录
useEffect(() => {
const handler = () => setLoginOpen(true);
on(EVENTS.AUTH_REQUIRED, handler);
on(EVENTS.SHOW_LOGIN, handler);
return () => {
off(EVENTS.AUTH_REQUIRED, handler);
off(EVENTS.SHOW_LOGIN, handler);
};
}, []);
const handleLoginClose = useCallback(() => {
setLoginOpen(false);
}, []);
// 设置 Drawer 状态
const [settingsOpen, setSettingsOpen] = useState(false);
// 监听打开设置 Drawer
useEffect(() => {
const handler = () => setSettingsOpen(true);
on(EVENTS.OPEN_SETTINGS, handler);
return () => { off(EVENTS.OPEN_SETTINGS, handler); };
}, []);
// 监听余额不足业务错误 → 弹窗引导充值
useEffect(() => {
const handler = (payload: BusinessErrorEvent) => {
console.log('[App] 收到 BALANCE_INSUFFICIENT 事件:', payload);
modal.confirm({
title: '余额不足',
content: payload.message || '当前账户余额不足,请充值后重试',
okText: '去充值',
cancelText: '取消',
onOk: () => {
console.log('[App] 用户点击"去充值",打开充值页面');
emit(EVENTS.OPEN_PAGE, { target: '/recharge', modalType: 'drawer' });
},
});
};
on(EVENTS.BALANCE_INSUFFICIENT, handler as (...args: unknown[]) => void);
return () => { off(EVENTS.BALANCE_INSUFFICIENT, handler as (...args: unknown[]) => void); };
}, [modal]);
// 监听软件使用权到期HTTP 403→ 弹窗引导购买 VIP
useEffect(() => {
const handler = (payload: BusinessErrorEvent) => {
console.log('[App] 收到 SUBSCRIPTION_EXPIRED 事件:', payload);
modal.confirm({
title: '软件使用权已到期',
content: payload.message || '软件使用权已到期,请购买 VIP 套餐后继续使用',
okText: '去购买 VIP',
cancelText: '取消',
onOk: () => {
console.log('[App] 用户点击"去购买 VIP",打开充值页面');
emit(EVENTS.OPEN_PAGE, { target: '/recharge', modalType: 'drawer' });
},
});
};
on(EVENTS.SUBSCRIPTION_EXPIRED, handler as (...args: unknown[]) => void);
return () => { off(EVENTS.SUBSCRIPTION_EXPIRED, handler as (...args: unknown[]) => void); };
}, [modal]);
return (
<>
<Layout>
<HomePage />
</Layout>
{/* 登录 Modal — 可关闭,关闭后主页可操作 */}
<LoginPage open={loginOpen && !isLoggedIn} onClose={handleLoginClose} />
{/* 设置 Modal — 事件驱动,居中叠加,主页面始终挂载(对应 QT 原版独立设置窗口) */}
<SettingsPage open={settingsOpen} onClose={() => setSettingsOpen(false)} />
{/* 页面调度器 — 监听 OPEN_PAGE 事件,根据 modalType 渲染 Modal/Drawer/FloatingPanel */}
<PageDispatcher />
</>
);
}
export default App;

View File

@@ -5,8 +5,8 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { theme as antTheme } from 'antd'; import { theme as antTheme } from 'antd';
import { NavBar } from './navbar'; import { NavBar } from '@/modules/navbar';
import { BannerCarousel } from '@/pages/home/components/BannerCarousel'; import { BannerCarousel } from '@/modules/home/shared/views/BannerCarousel';
export function Layout({ children }: { children: ReactNode }) { export function Layout({ children }: { children: ReactNode }) {
const { token } = antTheme.useToken(); const { token } = antTheme.useToken();

View File

@@ -18,10 +18,15 @@
import {type ComponentType, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {type ComponentType, useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {Drawer, Modal} from 'antd'; import {Drawer, Modal} from 'antd';
import {EVENTS, off, on} from '@/utils/event-bus'; import {EVENTS, off, on} from '@/shared/utils/bus';
import {FloatingPanel} from '@/components/ui/FloatingPanel'; import {FloatingPanel} from '@/shared/components/FloatingPanel';
import {WechatWorkPage} from '@/pages/headers/WechatWorkPage';
import {AccountInfo} from '@/pages/headers/AccountInfo.tsx'; import {WechatWorkPage} from '@/modules/account/views/wechat-work/WechatWorkPage';
import {AccountInfo} from '@/modules/account/views/info/AccountPage';
import {BillingInfo} from '@/modules/account/views/billing/BillingPage';
import {RechargePage} from '@/modules/account/views/recharge/RechargePage';
import {OrdersPage} from '@/modules/account/views/orders/OrdersPage';
import {VipPage} from '@/modules/account/views/vip/VipPage';
// ---------- 类型 ---------- // ---------- 类型 ----------
@@ -78,12 +83,25 @@ const PAGE_MAP: Record<string, PageConfig> = {
width: () => Math.min(window.outerHeight * 0.65, 720), width: () => Math.min(window.outerHeight * 0.65, 720),
height: () => Math.min(window.outerHeight * 0.65, 680), height: () => Math.min(window.outerHeight * 0.65, 680),
}, },
'/vip': {label: '开会员/激活'}, '/vip': {component: VipPage, label: 'VIP 会员开通', width: 560},
'/recharge': {label: '充值积分'}, '/recharge': {component: RechargePage, label: '充值', width: 520},
'/billing': {label: '消费记录'}, '/billing': {label: '账单记录',
'/orders': {label: '订单明细'}, component: BillingInfo,
width: 960,
height: 680,},
'/orders': {
component: OrdersPage,
label: '订单明细',
width: 820,
height: 640,
},
'/projects': {label: '项目管理'}, '/projects': {label: '项目管理'},
'/quota': {label: '查配额'}, '/quota': {
component: AccountInfo,
label: '配额',
width: () => Math.min(window.outerHeight * 0.65, 720),
height: () => Math.min(window.outerHeight * 0.65, 680),
},
}; };
// 预计算所有占位组件(模块加载时执行一次,引用永久稳定) // 预计算所有占位组件(模块加载时执行一次,引用永久稳定)
@@ -114,6 +132,12 @@ function resolveHeight(target: string): number | undefined {
export function PageDispatcher() { export function PageDispatcher() {
const [activePanel, setActivePanel] = useState<ActivePanel | null>(null); const [activePanel, setActivePanel] = useState<ActivePanel | null>(null);
const nextIdRef = useRef(0); const nextIdRef = useRef(0);
const [locked, setLocked] = useState(false);
// 关闭面板
const closePanel = useCallback(() => {
setActivePanel(null);
}, []);
// 监听 OPEN_PAGE 事件 // 监听 OPEN_PAGE 事件
useEffect(() => { useEffect(() => {
@@ -137,11 +161,26 @@ export function PageDispatcher() {
return () => off(EVENTS.OPEN_PAGE, handler); return () => off(EVENTS.OPEN_PAGE, handler);
}, []); }, []);
// 关闭面板 // 监听 CLOSE_PAGE 事件(允许页面组件内部"取消"按钮关闭容器)
const closePanel = useCallback(() => { useEffect(() => {
setActivePanel(null); on(EVENTS.CLOSE_PAGE, closePanel);
return () => off(EVENTS.CLOSE_PAGE, closePanel);
}, [closePanel]);
// 监听 PAGE_LOCK 事件(页面组件操作进行中时禁止关闭容器)
useEffect(() => {
const handler = (payload: unknown) => {
setLocked((payload as { locked: boolean }).locked);
};
on(EVENTS.PAGE_LOCK, handler);
return () => off(EVENTS.PAGE_LOCK, handler);
}, []); }, []);
// 面板关闭时重置锁定状态(防止下次打开时仍处于锁定)
useEffect(() => {
if (!activePanel) setLocked(false);
}, [activePanel]);
// 页面组件useMemo 稳定引用) // 页面组件useMemo 稳定引用)
const PanelPage = useMemo<ComponentType | null>( const PanelPage = useMemo<ComponentType | null>(
() => (activePanel ? resolvePage(activePanel.target) : null), () => (activePanel ? resolvePage(activePanel.target) : null),
@@ -166,7 +205,7 @@ export function PageDispatcher() {
onCancel={closePanel} onCancel={closePanel}
footer={null} footer={null}
destroyOnHidden={true} destroyOnHidden={true}
mask={{closable: true}} mask={{closable: !locked}}
width={panelWidth ?? defaultModalWidth} width={panelWidth ?? defaultModalWidth}
> >
{/* eslint-disable-next-line react-hooks/static-components */} {/* eslint-disable-next-line react-hooks/static-components */}
@@ -181,6 +220,9 @@ export function PageDispatcher() {
title={activePanel.label} title={activePanel.label}
onClose={closePanel} onClose={closePanel}
destroyOnHidden={true} destroyOnHidden={true}
mask={{
closable:locked
}}
size={panelWidth ?? defaultDrawerWidth} size={panelWidth ?? defaultDrawerWidth}
> >
{/* eslint-disable-next-line react-hooks/static-components */} {/* eslint-disable-next-line react-hooks/static-components */}

View File

@@ -3,38 +3,48 @@ import ReactDOM from 'react-dom/client';
import {App as AntdApp} from 'antd'; import {App as AntdApp} from 'antd';
import App from './App'; import App from './App';
import {ThemeProvider} from './components/ThemeProvider'; import {ThemeProvider} from './providers/ThemeProvider';
import {AppProvider} from './components/AppProvider'; import {AppProvider} from './providers/AppProvider';
import {SettingsProvider} from './components/SettingsProvider'; import {SettingsProvider} from './providers/SettingsProvider';
import CanvasApp from '@/modules/canvas/views/CanvasApp';
// 全局样式(包含 Tailwind 指令 + 主题扩展 + 重置) // 全局样式(包含 Tailwind 指令 + 主题扩展 + 重置)
import './theme/globals.css'; import '@/shared/theme/globals.css';
/** 判断当前是否为画布窗口(通过 URL hash 区分) */
const isCanvasMode = window.location.hash.startsWith('#/canvas');
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
{/* AppProvider — 全局应用状态(平台/版本/登录态) */} {isCanvasMode ? (
<AppProvider> // Canvas 模式:轻量级 Provider无 Auth/Storage/Settings 依赖
{/* ThemeProvider 内部管理 ConfigProvider亮色/暗色切换) */} <ThemeProvider>
<AntdApp>
<CanvasApp />
</AntdApp>
</ThemeProvider>
) : (
// 正常模式:完整的 AppProvider 链
<AppProvider>
<ThemeProvider> <ThemeProvider>
{/* SettingsProvider — 集中管理设置项(存储路径等) */}
<SettingsProvider> <SettingsProvider>
{/* AntdApp 提供 message / notification / modal 的静态方法上下文 */}
<AntdApp> <AntdApp>
<App /> <App />
</AntdApp> </AntdApp>
</SettingsProvider> </SettingsProvider>
</ThemeProvider> </ThemeProvider>
</AppProvider> </AppProvider>
)}
</React.StrictMode>, </React.StrictMode>,
); );
// 仅在 Electron 环境中监听主进程消息(浏览器预览时跳过) // 仅在 Electron 环境中监听主进程消息(浏览器预览时跳过)
import { safeIpcOn } from './utils/ipc'; import { safeIpcOn } from '@/shared/utils/bus';
safeIpcOn('main-process-message', (_event: unknown, message: unknown) => { safeIpcOn('main-process-message', (_event: unknown, message: unknown) => {
console.log('[Main Process Message]:', message); console.log('[Main Process Message]:', message);
}); });
// 渲染进程全局未捕获错误(兜底记录到日志文件) // 渲染进程全局未捕获错误(兜底记录到日志文件)
import {logger} from './utils/logger'; import {logger} from '@/shared/utils/bus';
window.addEventListener('error', (event) => { window.addEventListener('error', (event) => {
logger.error('ui', 'Uncaught error (renderer)', event.error, { logger.error('ui', 'Uncaught error (renderer)', event.error, {

View File

@@ -10,12 +10,12 @@
import { createContext, useContext, useState, useMemo, useCallback, useEffect, type ReactNode } from 'react'; import { createContext, useContext, useState, useMemo, useCallback, useEffect, type ReactNode } from 'react';
import type { Platform, Edition } from '@shared/types'; import type { Platform, Edition } from '@shared/types';
import { getPlatform, isDev } from '@/utils/platform'; import { getPlatform, isDev } from '@/shared/utils/env';
import { safeIpcOn, safeIpcOff } from '@/utils/ipc'; import { safeIpcOn, safeIpcOff } from '@/shared/utils/bus';
import { AuthAPI, type LoginResponseBody } from '@/services/modules/auth'; import { AuthAPI, type LoginResponseBody } from '@/services/modules/auth/auth';
import { setToken, clearToken, initTokenStore } from '@/services/request'; import { setToken, clearToken, initTokenStore } from '@/services/request';
import { emit, EVENTS } from '@/utils/event-bus'; import { emit, EVENTS } from '@/shared/utils/bus';
import { logger } from '@/utils/logger'; import { logger } from '@/shared/utils/bus';
import { getAutoLoginFlag, clearAutoLoginFlag } from '@/services/auth-persistence'; import { getAutoLoginFlag, clearAutoLoginFlag } from '@/services/auth-persistence';
import { import {
initAuthRefresh, initAuthRefresh,
@@ -23,8 +23,11 @@ import {
setStoredRefreshToken, setStoredRefreshToken,
clearStoredRefreshToken, clearStoredRefreshToken,
initRefreshTokenStore, initRefreshTokenStore,
scheduleProactiveRefresh,
clearProactiveRefresh,
} from '@/services/auth-token'; } from '@/services/auth-token';
import {initModelCache} from '@/services/cache/model-cache'; import {initModelCache} from '@/services/cache/model-cache';
import {initStorage} from '@/shared/infrastructure/storage';
// ---------- Context 类型 ---------- // ---------- Context 类型 ----------
@@ -84,11 +87,18 @@ export function AppProvider({ children }: AppProviderProps) {
return user.account_type === 'individual' ? 'personal' : 'enterprise'; return user.account_type === 'individual' ? 'personal' : 'enterprise';
}, [user]); }, [user]);
// 登录/登出后同步窗口标题版控后缀到主进程
useEffect(() => {
window.appRuntime?.setWindowEdition(isLoggedIn ? edition : null);
}, [edition, isLoggedIn]);
// ---------- 登录 / 退出 ---------- // ---------- 登录 / 退出 ----------
const login = useCallback(async (auth: LoginResponseBody) => { const login = useCallback(async (auth: LoginResponseBody) => {
await setToken(auth.access_token); await setToken(auth.access_token);
await setStoredRefreshToken(auth.refresh_token); await setStoredRefreshToken(auth.refresh_token);
// 启动主动刷新定时器(在 access_token 过期前自动续期)
scheduleProactiveRefresh(auth.access_token);
setUser(auth); setUser(auth);
setIsLoggedIn(true); setIsLoggedIn(true);
logger.info('auth', 'User logged in', { logger.info('auth', 'User logged in', {
@@ -102,9 +112,13 @@ export function AppProvider({ children }: AppProviderProps) {
logger.info('auth', 'User logged out'); logger.info('auth', 'User logged out');
clearToken(); clearToken();
clearStoredRefreshToken(); clearStoredRefreshToken();
clearProactiveRefresh();
setUser(null); setUser(null);
setIsLoggedIn(false); setIsLoggedIn(false);
emit(EVENTS.LOGOUT); emit(EVENTS.LOGOUT);
// 注意:退出登录不自动清理本地缓存
// —— 数据已按 user_id 隔离,同一用户重新登录后缓存仍可命中
// 用户主动清理请使用设置页面的「清理缓存」按钮
}, []); }, []);
// ---------- 启动时初始化 token 存储 + 自动登录 ---------- // ---------- 启动时初始化 token 存储 + 自动登录 ----------
@@ -116,7 +130,7 @@ export function AppProvider({ children }: AppProviderProps) {
let aborted = false; let aborted = false;
// 从加密存储恢复 token + 模型缓存 到内存缓存 // 从加密存储恢复 token + 模型缓存 到内存缓存
Promise.all([initTokenStore(), initRefreshTokenStore(), initModelCache()]).then(() => { Promise.all([initTokenStore(), initRefreshTokenStore(), initModelCache(), initStorage()]).then(() => {
if (aborted) return; if (aborted) return;
// 初始化 401 刷新回调token 缓存就绪后才注册) // 初始化 401 刷新回调token 缓存就绪后才注册)
@@ -133,6 +147,7 @@ export function AppProvider({ children }: AppProviderProps) {
if (aborted) return; if (aborted) return;
await setToken(res.access_token); await setToken(res.access_token);
await setStoredRefreshToken(res.refresh_token); await setStoredRefreshToken(res.refresh_token);
scheduleProactiveRefresh(res.access_token);
setIsLoggedIn(true); setIsLoggedIn(true);
logger.info('auth', 'Auto-login succeeded'); logger.info('auth', 'Auto-login succeeded');
// 补全用户信息 // 补全用户信息
@@ -147,18 +162,21 @@ export function AppProvider({ children }: AppProviderProps) {
user_id: info.id, user_id: info.id,
username: info.username, username: info.username,
role: info.role, role: info.role,
email: info.email, email: info.email ?? '',
balance_cent: info.balance_cent, balance_cent: info.balance_cent,
account_type: info.account_type, account_type: info.account_type,
display_name: info.display_name, display_name: info.display_name ?? '',
company_id: info.company_id, company_id: info.company_id ?? '',
real_name: info.real_name, real_name: info.real_name ?? '',
phone: info.phone, phone: info.phone,
license_code: '', license_code: '',
admin_permissions: info.admin_permissions, admin_permissions: info.admin_permissions ?? [],
}; };
setUser(u); setUser(u);
emit(EVENTS.LOGIN_SUCCESS, u); emit(EVENTS.LOGIN_SUCCESS, u);
}).catch((err) => {
if (aborted) return;
logger.warn('auth', '自动登录-获取用户信息失败', err instanceof Error ? err : undefined);
}); });
}) })
.catch(() => { .catch(() => {
@@ -168,6 +186,9 @@ export function AppProvider({ children }: AppProviderProps) {
// 仅清除自动登录标记,保留用户名供下次表单回填 // 仅清除自动登录标记,保留用户名供下次表单回填
clearAutoLoginFlag(); clearAutoLoginFlag();
}); });
}).catch((err) => {
if (aborted) return;
logger.warn('auth', '应用初始化失败(存储/Token/模型缓存)', err instanceof Error ? err : undefined);
}); });
return () => { aborted = true; }; return () => { aborted = true; };

View File

@@ -8,6 +8,7 @@
// ============================================================ // ============================================================
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'; import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
import { setOutputPath as saveOutputPath, setTeamRepoPath as saveTeamRepoPath } from '@/shared/infrastructure/storage';
// ---------- localStorage 键 ---------- // ---------- localStorage 键 ----------
@@ -108,6 +109,7 @@ export function SettingsProvider({ children }: SettingsProviderProps) {
const setOutputPath = useCallback((path: string) => { const setOutputPath = useCallback((path: string) => {
setOutputPathState(path); setOutputPathState(path);
writeStoredOutputPath(path); writeStoredOutputPath(path);
try { saveOutputPath(path); } catch { /* db 未初始化则跳过 */ }
}, []); }, []);
const clearOutputPath = useCallback(() => { const clearOutputPath = useCallback(() => {
@@ -120,6 +122,7 @@ export function SettingsProvider({ children }: SettingsProviderProps) {
const setTeamRepoPath = useCallback((path: string) => { const setTeamRepoPath = useCallback((path: string) => {
setTeamRepoPathState(path); setTeamRepoPathState(path);
writeStoredTeamRepoPath(path); writeStoredTeamRepoPath(path);
try { saveTeamRepoPath(path); } catch { /* db 未初始化则跳过 */ }
}, []); }, []);
const clearTeamRepoPath = useCallback(() => { const clearTeamRepoPath = useCallback(() => {

View File

@@ -12,7 +12,7 @@ import { flushSync } from 'react-dom';
import { ConfigProvider } from 'antd'; import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN'; import zhCN from 'antd/locale/zh_CN';
import { getThemeConfig } from '../theme/antd-theme'; import { getThemeConfig } from '@/shared/theme/antd-theme';
// ---------- Context 类型 ---------- // ---------- Context 类型 ----------

View File

@@ -1 +0,0 @@
export { NavBar } from './NavBar';

View File

@@ -1,169 +0,0 @@
// ============================================================
// use-api.ts — 业务 API Hook 集合
//
// 基于 useAsyncData / useAsyncMutation 封装各模块 API
// 组件只需调用 Hook 获取 { data, loading, error } 三元组,
// 不再需要在组件内手写 useEffect + cancelled flag + useState。
//
// 每个 Hook 自动处理:
// - 登录状态门控enabled: isLoggedIn
// - 竞态条件
// - 错误日志
// - 用户可读错误信息errorMessage
// ============================================================
import {useMemo, useState} from 'react';
import {useAppContext} from '@/components/AppProvider';
import { TaskAPI, type TaskListRequestParams, type TaskInfoItemResponseBody, type CreatTaskRequestBody } from '@/services/modules/task';
import { ModelAPI, MODEL_CATEGORY_LABELS, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models';
import { fetchBanners, type Banner } from '@/services/modules/banner';
import {useAsyncData, UseAsyncDataReturn, useAsyncMutation} from './use-async';
import {getCachedModels, setCachedModels} from '@/services/cache/model-cache';
// ============================================================
// Banner
// ============================================================
/** 首页轮播图数据 Hook */
export function useBannerList() {
const {isLoggedIn} = useAppContext();
return useAsyncData<Banner[]>(
fetchBanners,
[],
{enabled: isLoggedIn, label: 'banner-list'},
);
}
// ============================================================
// 模型
// ============================================================
/** 模块级:单次会话中是否已完成首次 API 拉取 */
let modelFirstFetchDone = false;
/** 模型列表数据 Hook缓存优先 + 后台刷新)
*
* 策略:
* 1. 启动时同步读取加密缓存 → 立即展示(避免每次打开都 loading
* 2. 异步拉取最新模型列表 → 覆盖缓存数据
* 3. 拉取成功后将最新数据加密写入 localStorage
*
* 因后端 category 查询参数实际不可用(传参返回空列表),
* 改为不带分类参数一次拉取全部,再由客户端根据响应中的
* category_name 字段手动分组。 */
export function useModelList() {
const {isLoggedIn} = useAppContext();
// 缓存即时数据(首次 render 同步读取,后续 API 返回后覆盖)
const [instantData, setInstantData] = useState<ModelsListResponseBody[] | null>(() =>
getCachedModels(),
);
const result: UseAsyncDataReturn<ModelsListResponseBody[]> = useAsyncData<ModelsListResponseBody[]>(
async () => {
const models = await ModelAPI.fetchModels({page: 1, page_size: 200});
// 异步写入加密缓存best-effort不影响数据流
setCachedModels(models).catch(() => {});
modelFirstFetchDone = true;
setInstantData(models);
return models;
},
[],
{enabled: isLoggedIn, label: 'model-list'},
);
// 合并策略API 数据 > 缓存即时数据
const mergedData = result.data ?? instantData;
// loading 仅在无任何数据时展示(缓存命中时不闪烁 loading
const loading = result.loading && mergedData === null;
// 按 category_name 分组(后端返回的 category_name 为 slug 格式如 img2img
const groupedModels: Map<string, ModelsListResponseBody[]> = useMemo(() => {
const groups = new Map<string, ModelsListResponseBody[]>();
mergedData?.forEach((model: ModelsListResponseBody) => {
const cat: ModelCategoryStringEnum = model.model_type;
if (!groups.has(cat)) groups.set(cat, []);
groups.get(cat)!.push(model);
});
return groups;
}, [mergedData]);
return {
...result,
/** 合并后的模型数据(缓存优先) */
data: mergedData,
/** 仅在无缓存且正在加载时为 true */
loading,
/** 按 model_type 分组后的模型key 为后端 slug */
groupedModels,
};
}
// ============================================================
// 任务列表
// ============================================================
export interface UseTaskListParams {
/** 完整的 API 请求参数(由调用方根据 antd Table onChange 构建) */
requestParams: TaskListRequestParams;
/** 外部传入的刷新信号(任意变化触发重新请求) */
refreshSignal?: number;
}
/**
* 任务列表 Hook分页 + 筛选 + 排序)
*
* 调用方负责构建 TaskListRequestParams包括 edition-aware 的 user_ids 逻辑),
* 本 Hook 仅负责数据获取、竞态控制、错误日志。
*/
export function useTaskList(params: UseTaskListParams) {
const {isLoggedIn} = useAppContext();
const {requestParams, refreshSignal = 0} = params;
return useAsyncData(
() => TaskAPI.getTaskList(requestParams),
[requestParams, refreshSignal],
{enabled: isLoggedIn, label: 'task-list'},
);
}
// ============================================================
// 任务详情
// ============================================================
/** 任务详情 HooktaskId 为 null 时不请求) */
export function useTaskDetail(taskId: string | null | undefined) {
const {isLoggedIn} = useAppContext();
return useAsyncData<TaskInfoItemResponseBody>(
() => TaskAPI.getTaskInfo(taskId!),
[taskId],
{enabled: isLoggedIn && !!taskId, label: 'task-detail'},
);
}
// ============================================================
// 提交任务
// ============================================================
/** 提交任务 Mutation Hookdevice_id 由 API 层自动注入,调用方无需传入) */
export function useSubmitTask(options?: {
onSuccess?: (data: TaskInfoItemResponseBody) => void;
onError?: (err: Error) => void;
}) {
return useAsyncMutation<TaskInfoItemResponseBody, [Omit<CreatTaskRequestBody, 'device_id'>]>(
(body) => TaskAPI.submitTask(body),
{
label: 'submit-task',
onSuccess: options?.onSuccess,
onError: options?.onError,
},
);
}
// ---------- 重新导出类型和工具 ----------
export {MODEL_CATEGORY_LABELS};
export type {ModelCategoryStringEnum, ModelsListResponseBody, TaskInfoItemResponseBody, Banner};

View File

@@ -0,0 +1,7 @@
// account 模块 — 账户与支付
export { AccountInfo } from './views/info/AccountPage';
export { VipPage } from './views/vip/VipPage';
export { RechargePage } from './views/recharge/RechargePage';
export { BillingInfo } from './views/billing/BillingPage';
export { OrdersPage } from './views/orders/OrdersPage';
export { WechatWorkPage } from './views/wechat-work/WechatWorkPage';

View File

@@ -0,0 +1,258 @@
import {useState, useMemo} from 'react';
import {
Card,
Table,
Button,
Typography,
Skeleton,
Result,
Space,
Tag,
theme as antTheme,
} from 'antd';
import {
WalletOutlined,
RiseOutlined,
ApiOutlined,
} from '@ant-design/icons';
import {useBillingBalance, useBillingLedger} from '@/shared/hooks/use-api';
import type {EntryTypeValue, LedgerItemResponseBody, LedgerReqeustParam} from '@/services/modules/account/billing';
import {EnumResolver} from '@/shared/utils/enum-resolver';
import {centToYuan, formatDate} from '@/shared/utils/display';
const {Text, Title} = Typography;
// ============================================================
// 余额卡片
// ============================================================
function BalanceCard() {
const {token} = antTheme.useToken();
const {data, loading, error, errorMessage, refetch} = useBillingBalance();
// ======== 加载态 ========
if (loading) {
return (
<Card size="small" className="rounded-md!">
<Skeleton active paragraph={{rows: 3}} />
</Card>
);
}
// ======== 错误态 ========
if (error || !data) {
return (
<Card size="small" className="rounded-md!">
<Result
status="error"
title="余额加载失败"
subTitle={errorMessage}
extra={
<Button type="primary" size="small" onClick={refetch}>
</Button>
}
/>
</Card>
);
}
// ======== 正常展示 ========
const totalBalance = (data.balance_cent ?? 0) + (data.gift_balance_cent ?? 0);
return (
<Card
size="small"
styles={{body: {padding: '16px 20px'}}}
className="rounded-md!"
>
<Text type="secondary" style={{fontSize: 12, marginBottom: 8, display: 'block'}}>
<WalletOutlined style={{marginRight: 6}} />
</Text>
<Title level={3} style={{margin: '0 0 12px 0'}}>
{centToYuan(totalBalance)}
<Text type="secondary" style={{fontSize: 14, marginLeft: 4}}>
{data.currency}
</Text>
</Title>
<Space size={32}>
<div>
<Text type="secondary" style={{fontSize: 12}}></Text>
<br />
<Text strong>{centToYuan(data.balance_cent ?? 0)}</Text>
</div>
<div>
<Text type="secondary" style={{fontSize: 12}}></Text>
<br />
<Text strong>{centToYuan(data.gift_balance_cent ?? 0)}</Text>
</div>
<div>
<Space size={4}>
<RiseOutlined style={{color: token.colorWarning}} />
<Text type="secondary" style={{fontSize: 12}}></Text>
</Space>
<br />
<Text strong>{centToYuan(data.today_spent_cent)}</Text>
</div>
<div>
<Space size={4}>
<ApiOutlined style={{color: token.colorPrimary}} />
<Text type="secondary" style={{fontSize: 12}}></Text>
</Space>
<br />
<Text strong>{data.today_calls} </Text>
</div>
</Space>
</Card>
);
}
function LedgerTable() {
const {token} = antTheme.useToken();
const [pagination, setPagination] = useState({page: 1, page_size: 10});
const params: LedgerReqeustParam = useMemo(() => ({
month: null,
page: pagination.page,
page_size: pagination.page_size,
}), [pagination]);
const {data, loading, error, errorMessage, refetch} = useBillingLedger(params);
const columns = [
{
title: '时间',
dataIndex: 'created_at',
key: 'created_at',
width: 120,
align: 'center' as const,
render: (_: unknown, record: LedgerItemResponseBody) => (
<Text style={{fontSize: 12, whiteSpace: 'nowrap'}}>
{formatDate(record.created_at)}
</Text>
),
},
{
title: '类型',
dataIndex: 'entry_type',
key: 'entry_type',
width: 100,
align: 'center' as const,
render: (type: EntryTypeValue) => (
<Tag color={EnumResolver.billing.entryType.color(type)} style={{fontSize: 11, lineHeight: '18px'}}>
{EnumResolver.billing.entryType.label(type)}
</Tag>
),
},
{
title: '金额',
dataIndex: 'amount_cent',
key: 'amount_cent',
width: 100,
align: 'center' as const,
render: (amount: number): React.ReactNode => {
const isNegative = amount < 0;
return (
<Text
strong
style={{
fontSize: 13,
color: isNegative ? token.colorError : token.colorSuccess,
}}
>
{isNegative ? '-' : '+'}
{centToYuan(Math.abs(amount))}
</Text>
);
},
},
{
title: '余额',
dataIndex: 'balance_after_cent',
key: 'balance_after_cent',
width: 100,
align: 'center' as const,
render: (val: number) => (
<Text style={{fontSize: 12}}>{centToYuan(val)}</Text>
),
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
ellipsis: true,
align: 'left' as const,
render: (desc: string) => (
<Text style={{fontSize: 12}}>{desc || '—'}</Text>
),
},
];
// ======== 错误态 ========
if (error) {
return (
<Card size="small" className="rounded-md!">
<Result
status="error"
title="账单加载失败"
subTitle={errorMessage}
extra={
<Button type="primary" size="small" onClick={refetch}>
</Button>
}
/>
</Card>
);
}
// ======== 正常 / 加载态 ========
return (
<Card
size="small"
styles={{body: {padding: 0}}}
className="rounded-md!"
>
<Table<LedgerItemResponseBody>
columns={columns}
dataSource={data ?? []}
rowKey="id"
loading={loading}
size="small"
pagination={{
current: pagination.page,
pageSize: pagination.page_size,
total: (data ?? []).length >= pagination.page_size
? pagination.page * pagination.page_size + 1
: pagination.page * pagination.page_size,
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50'],
showTotal: (total: number, range: [number, number]) =>
`${range[0]}-${range[1]},共 ${total}+ 条`,
onChange: (page: number, pageSize: number) => {
setPagination({page, page_size: pageSize});
},
position: ['bottomCenter'],
}}
locale={{emptyText: '暂无消费记录'}}
/>
</Card>
);
}
// ============================================================
// 页面入口
// ============================================================
export function BillingInfo() {
return (
<div style={{padding: '8px 0', width: '100%'}}>
<div className="flex flex-col gap-4" style={{width: '100%'}}>
<BalanceCard />
<LedgerTable />
</div>
</div>
);
}

View File

@@ -1,4 +1,3 @@
import {useEffect, useState, useCallback} from 'react';
import { import {
Card, Card,
Descriptions, Descriptions,
@@ -18,48 +17,16 @@ import {
BarChartOutlined, BarChartOutlined,
RightOutlined, RightOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import {AuthAPI, type UserInfoBody, UserAccountTypeLabelMap} from '@/services/modules/auth'; import {EnumResolver} from '@/shared/utils/enum-resolver';
import {VipAPI, VipLevelsItem} from "@/services/modules/vip-api.ts"; import type { VipLevelsItem } from '@/services/modules/account/vip-api';
import { useAccountInfo } from '@/shared/hooks/use-api';
import { centToYuan, formatDate } from '@/shared/utils/display';
const {Text, Title} = Typography; const {Text, Title} = Typography;
/** 将分转为元,保留两位小数 */
function centToYuan(cent: number): string {
return (cent / 100).toFixed(2);
}
/** 格式化日期 */
function formatDate(date: Date | string | undefined): string {
if (!date) return '—';
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleDateString('zh-CN', {year: 'numeric', month: '2-digit', day: '2-digit'});
}
export function AccountInfo() { export function AccountInfo() {
const {token} = antTheme.useToken(); const {token} = antTheme.useToken();
const [userInfo, setUserInfo] = useState<UserInfoBody | null>(null); const { data, loading, error, errorMessage, refetch } = useAccountInfo();
const [vipsInfo, setVipsInfo] = useState<VipLevelsItem[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const info: UserInfoBody = await AuthAPI.getUserInfo();
const vips: VipLevelsItem[] = await VipAPI.getVipLevels();
setVipsInfo(vips);
setUserInfo(info);
} catch (err) {
setError((err as Error).message || '加载账户信息失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
// ======== 加载态 ======== // ======== 加载态 ========
if (loading) { if (loading) {
@@ -71,14 +38,14 @@ export function AccountInfo() {
} }
// ======== 错误态 ======== // ======== 错误态 ========
if (error || !userInfo) { if (error || !data) {
return ( return (
<Result <Result
status="error" status="error"
title="加载失败" title="加载失败"
subTitle={error || '未能获取账户信息'} subTitle={errorMessage || '未能获取账户信息'}
extra={ extra={
<Button type="primary" onClick={load}> <Button type="primary" onClick={refetch}>
</Button> </Button>
} }
@@ -87,6 +54,7 @@ export function AccountInfo() {
} }
// ======== 数据展示 ======== // ======== 数据展示 ========
const [userInfo, vipsInfo] = data;
const totalBalance: number = userInfo.balance_cent + userInfo.gift_balance_cent; const totalBalance: number = userInfo.balance_cent + userInfo.gift_balance_cent;
// 匹配当前 VIP 等级vip_level_id → VipLevelsItem // 匹配当前 VIP 等级vip_level_id → VipLevelsItem
@@ -107,7 +75,7 @@ export function AccountInfo() {
{userInfo.display_name || userInfo.real_name || userInfo.username || userInfo.id} {userInfo.display_name || userInfo.real_name || userInfo.username || userInfo.id}
{userInfo.account_type && ( {userInfo.account_type && (
<Tag color="gold" style={{lineHeight: '18px', fontSize: 11}}> <Tag color="gold" style={{lineHeight: '18px', fontSize: 11}}>
{UserAccountTypeLabelMap[userInfo.account_type]} {EnumResolver.auth.accountType.label(userInfo.account_type)}
</Tag> </Tag>
)} )}
</Title> </Title>

View File

@@ -0,0 +1,543 @@
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { logger } from '@/shared/utils/bus';
import {
Card,
Table,
Button,
Typography,
Tag,
Segmented,
Result,
App,
theme as antTheme,
} from 'antd';
import { DollarOutlined, ReloadOutlined } from '@ant-design/icons';
import {
PaymentAPI,
PAYMENT_STATUS,
type PaymentStatus,
type OrderListResponseBody,
type OrderInfoResponseBody,
} from '@/services/modules/account/payments';
import {
VipAPI,
VipOrderStatus,
type VipOrderStatusValue,
type VipOrderRead,
} from '@/services/modules/account/vip-api';
import { centToYuan, formatDate } from '@/shared/utils/display';
import { EnumResolver } from '@/shared/utils/enum-resolver';
import { RequestError, RequestErrorType } from '@/services/request';
const { Text } = Typography;
// ---------- 常量 ----------
/** 订单状态轮询间隔(毫秒) */
const POLL_INTERVAL_MS = 3000;
/** 状态筛选项 */
const STATUS_FILTERS = [
{ label: '全部', value: 'all' },
{ label: '待支付', value: PAYMENT_STATUS.Pending },
{ label: '已支付', value: PAYMENT_STATUS.Paid },
{ label: '已过期', value: PAYMENT_STATUS.Expired },
{ label: '支付失败', value: PAYMENT_STATUS.Failed },
];
/** 订单类型 */
type OrderType = 'recharge' | 'vip';
const ORDER_TYPE_OPTIONS = [
{ label: '充值订单', value: 'recharge' as OrderType },
{ label: 'VIP订单', value: 'vip' as OrderType },
];
// ---------- 组件 ----------
export function OrdersPage() {
const { message } = App.useApp();
const { token } = antTheme.useToken();
// ======== 筛选 & 分页 ========
const [orderType, setOrderType] = useState<OrderType>('recharge');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [pagination, setPagination] = useState({ page: 1, page_size: 10 });
// ======== 数据状态 ========
const [rechargeOrders, setRechargeOrders] = useState<OrderListResponseBody[]>([]);
const [vipOrders, setVipOrders] = useState<VipOrderRead[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 轮询定时器 MaporderId → timer
const pollingRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
// 组件是否已挂载
const mountedRef = useRef(true);
// fetchOrders 的 AbortController切换筛选/分页时取消旧请求)
const abortRef = useRef<AbortController | null>(null);
// ======== 数据获取 ========
const fetchOrders = useCallback(async () => {
// 取消上一次未完成的请求(防竞态)
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
setError(null);
try {
if (orderType === 'recharge') {
const params: { page: number; page_size: number; status?: PaymentStatus } = {
page: pagination.page,
page_size: pagination.page_size,
};
if (statusFilter !== 'all') {
params.status = statusFilter as PaymentStatus;
}
const data = await PaymentAPI.getOrdersListInfo(params, controller.signal);
if (mountedRef.current) {
setRechargeOrders(data);
}
} else {
const params: { page?: number; page_size?: number; status?: VipOrderStatusValue } = {
page: pagination.page,
page_size: pagination.page_size,
};
if (statusFilter !== 'all') {
params.status = statusFilter as VipOrderStatusValue;
}
const data = await VipAPI.getVipOrders(params);
if (mountedRef.current) {
setVipOrders(data);
}
}
} catch (err) {
// axios 拦截器将取消错误转为 RequestError(CANCELLED),非原始 AbortError
if (err instanceof RequestError && err.type === RequestErrorType.CANCELLED) return;
logger.warn('ui', '加载订单列表失败', err instanceof Error ? err : new Error(String(err)));
if (mountedRef.current) {
setError((err as Error)?.message || '加载订单列表失败');
}
} finally {
if (mountedRef.current) {
setLoading(false);
}
}
}, [pagination, statusFilter, orderType]);
// 筛选或分页变化 → 重新获取
useEffect(() => {
fetchOrders();
}, [fetchOrders]);
// 挂载/卸载管理
useEffect(() => {
mountedRef.current = true;
const polling = pollingRef.current;
const abort = abortRef.current;
return () => {
mountedRef.current = false;
// 清理所有轮询定时器
polling.forEach((timer) => clearTimeout(timer));
polling.clear();
// 取消进行中的列表请求
abort?.abort();
};
}, []);
// ======== 单订单轮询 ========
/**
* 单订单轮询:对指定 orderId 启动轮询,每 POLL_INTERVAL_MS 查一次
* - 幂等启动:取消同一订单的旧轮询再创建新的
* - 局部更新 orders state终态时调 fetchOrders 保证一致性
* - 网络异常时延长间隔2×重试
* - 通过 mountedRef 防止卸载后 setState
*/
const startPolling = useCallback((orderId: string) => {
// ① 取消同一订单的旧轮询(幂等启动,避免重复轮询)
const existing = pollingRef.current.get(orderId);
if (existing) {
clearTimeout(existing);
pollingRef.current.delete(orderId);
}
let cancelled = false;
const poll = async () => {
if (cancelled || !mountedRef.current) return;
try {
const order: OrderInfoResponseBody = await PaymentAPI.getOrderInfo({ order_id: orderId });
if (cancelled || !mountedRef.current) return;
const newStatus = order.status;
// 局部更新 orders statemerge 最新订单信息)
setRechargeOrders((prev) =>
prev.map((o) => (o.id === orderId ? { ...o, ...order } : o)),
);
if (newStatus === PAYMENT_STATUS.Pending) {
// 仍待支付 → 继续轮询
const timer = setTimeout(poll, POLL_INTERVAL_MS);
pollingRef.current.set(orderId, timer);
} else {
// 终态 → 停止轮询,刷新列表保证整体一致性
pollingRef.current.delete(orderId);
fetchOrders();
if (newStatus === PAYMENT_STATUS.Paid) {
message.success('支付成功!');
} else if (newStatus === PAYMENT_STATUS.Failed) {
message.error('支付失败,请重试');
} else if (newStatus === PAYMENT_STATUS.Expired) {
message.warning('订单已过期');
}
}
} catch {
// 网络异常 → 延长间隔后重试
if (!cancelled && mountedRef.current) {
const timer = setTimeout(poll, POLL_INTERVAL_MS * 2);
pollingRef.current.set(orderId, timer);
}
}
};
// 启动首次轮询
const timer = setTimeout(poll, POLL_INTERVAL_MS);
pollingRef.current.set(orderId, timer);
}, [message, fetchOrders]);
/** VIP 订单轮询(与充值订单轮询模式一致,使用 VipAPI.getVipOrder */
const startVipPolling = useCallback((orderId: string) => {
const existing = pollingRef.current.get(orderId);
if (existing) {
clearTimeout(existing);
pollingRef.current.delete(orderId);
}
let cancelled = false;
const poll = async () => {
if (cancelled || !mountedRef.current) return;
try {
const order = await VipAPI.getVipOrder(orderId);
if (cancelled || !mountedRef.current) return;
const newStatus = order.status;
setVipOrders((prev) =>
prev.map((o) => (o.id === orderId ? { ...o, ...order } : o)),
);
if (newStatus === VipOrderStatus.Pending) {
const timer = setTimeout(poll, POLL_INTERVAL_MS);
pollingRef.current.set(orderId, timer);
} else {
pollingRef.current.delete(orderId);
fetchOrders();
if (newStatus === VipOrderStatus.Paid) {
message.success('支付成功VIP 权益已生效');
} else if (newStatus === VipOrderStatus.Expired) {
message.warning('订单已过期');
} else if (newStatus === VipOrderStatus.Cancelled) {
message.info('订单已取消');
}
}
} catch {
if (!cancelled && mountedRef.current) {
const timer = setTimeout(poll, POLL_INTERVAL_MS * 2);
pollingRef.current.set(orderId, timer);
}
}
};
const timer = setTimeout(poll, POLL_INTERVAL_MS);
pollingRef.current.set(orderId, timer);
}, [message, fetchOrders]);
// ======== 操作回调 ========
/** "继续支付":打开支付页面 + 启动轮询 */
const handleContinuePay = useCallback(
(orderId: string, payUrl: string) => {
window.open(payUrl, '_blank');
if (orderType === 'recharge') {
startPolling(orderId);
} else {
startVipPolling(orderId);
}
message.info('已打开支付页面,请完成支付');
},
[orderType, startPolling, startVipPolling, message],
);
// ======== 表格列定义 ========
/** 充值订单列 */
const rechargeColumns = useMemo(() => [
{
title: '订单编号',
dataIndex: 'id',
key: 'id',
width: 200,
ellipsis: true,
render: (id: string) => (
<Text copyable style={{ fontSize: 12 }}>{id}</Text>
),
},
{
title: '金额',
dataIndex: 'amount_cent',
key: 'amount_cent',
width: 90,
align: 'center' as const,
render: (amount: number) => (
<Text strong style={{ fontSize: 13, color: token.colorPrimary }}>¥{centToYuan(amount)}</Text>
),
},
{
title: '支付方式',
dataIndex: 'payment_channel',
key: 'payment_channel',
width: 90,
align: 'center' as const,
render: (channel: string) => (
<Text style={{ fontSize: 12 }}>{EnumResolver.payments.method.label(channel)}</Text>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 90,
align: 'center' as const,
render: (status: string) => (
<Tag
color={EnumResolver.payments.status.color(status as PaymentStatus)}
style={{ fontSize: 11, lineHeight: '18px' }}
>
{EnumResolver.payments.status.label(status as PaymentStatus)}
</Tag>
),
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 120,
align: 'center' as const,
render: (date: Date) => (
<Text style={{ fontSize: 12, whiteSpace: 'nowrap' }}>{formatDate(date)}</Text>
),
},
{
title: '操作',
key: 'action',
width: 110,
align: 'center' as const,
render: (_: unknown, record: OrderListResponseBody) => {
if (record.status === PAYMENT_STATUS.Pending && record.pay_url) {
return (
<Button
type="primary"
size="small"
icon={<DollarOutlined />}
onClick={() => handleContinuePay(record.id, record.pay_url)}
>
</Button>
);
}
return <Text type="secondary" style={{ fontSize: 12 }}></Text>;
},
},
], [token.colorPrimary, handleContinuePay]);
/** VIP 订单列 */
const vipColumns = useMemo(() => [
{
title: '订单编号',
dataIndex: 'id',
key: 'id',
width: 220,
ellipsis: true,
render: (id: string) => (
<Text copyable style={{ fontSize: 12 }}>{id}</Text>
),
},
{
title: '金额',
dataIndex: 'amount_cent',
key: 'amount_cent',
width: 90,
align: 'center' as const,
render: (amount: number) => (
<Text strong style={{ fontSize: 13, color: token.colorPrimary }}>¥{centToYuan(amount)}</Text>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 90,
align: 'center' as const,
render: (status: string) => (
<Tag
color={EnumResolver.vip.orderStatus.color(status as VipOrderStatusValue)}
style={{ fontSize: 11, lineHeight: '18px' }}
>
{EnumResolver.vip.orderStatus.label(status as VipOrderStatusValue)}
</Tag>
),
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 130,
align: 'center' as const,
render: (date: Date) => (
<Text style={{ fontSize: 12, whiteSpace: 'nowrap' }}>{formatDate(date)}</Text>
),
},
{
title: '操作',
key: 'action',
width: 110,
align: 'center' as const,
render: (_: unknown, record: VipOrderRead) => {
if (record.status === VipOrderStatus.Pending && record.pay_url) {
return (
<Button
type="primary"
size="small"
icon={<DollarOutlined />}
onClick={() => handleContinuePay(record.id, record.pay_url ?? '')}
>
</Button>
);
}
return <Text type="secondary" style={{ fontSize: 12 }}></Text>;
},
},
], [token.colorPrimary, handleContinuePay]);
/** 当前类型对应的列和数据 */
const columns = orderType === 'recharge' ? rechargeColumns : vipColumns;
const orders = orderType === 'recharge' ? rechargeOrders : vipOrders;
// ============================================================
// 渲染:错误态(无数据时)
// ============================================================
if (error && orders.length === 0) {
return (
<div style={{ padding: '8px 0' }}>
<Card size="small" className="rounded-md!">
<Result
status="error"
title="订单加载失败"
subTitle={error}
extra={
<Button type="primary" size="small" onClick={fetchOrders}>
</Button>
}
/>
</Card>
</div>
);
}
// ============================================================
// 渲染:正常
// ============================================================
/** 当前订单类型对应的状态筛选项 */
const currentStatusFilters = orderType === 'recharge'
? STATUS_FILTERS
: [
{ label: '全部', value: 'all' },
{ label: '待支付', value: VipOrderStatus.Pending },
{ label: '已支付', value: VipOrderStatus.Paid },
{ label: '已过期', value: VipOrderStatus.Expired },
{ label: '已取消', value: VipOrderStatus.Cancelled },
];
return (
<div style={{ padding: '8px 0', width: '100%' }}>
<div className="flex flex-col gap-3" style={{ width: '100%' }}>
{/* ---- 订单类型 + 状态筛选 ---- */}
<Segmented
options={ORDER_TYPE_OPTIONS}
value={orderType}
onChange={(val) => {
setOrderType(val as OrderType);
setStatusFilter('all');
setPagination((prev) => ({ ...prev, page: 1 }));
}}
block
style={{ backgroundColor: token.colorFillSecondary }}
/>
<Segmented
options={currentStatusFilters}
value={statusFilter}
onChange={(val) => {
setStatusFilter(val as string);
setPagination((prev) => ({ ...prev, page: 1 }));
}}
block
style={{ backgroundColor: token.colorFillSecondary }}
/>
{/* ---- 手动刷新 ---- */}
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button size="small" icon={<ReloadOutlined />} onClick={fetchOrders} loading={loading}>
</Button>
</div>
{/* ---- 订单表格 ---- */}
<Card size="small" styles={{ body: { padding: 0 } }} className="rounded-md!">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Table<any>
columns={columns}
dataSource={orders}
rowKey="id"
loading={loading}
size="small"
pagination={{
current: pagination.page,
pageSize: pagination.page_size,
total:
orders.length >= pagination.page_size
? pagination.page * pagination.page_size + 1
: pagination.page * pagination.page_size,
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50'],
showTotal: (total: number, range: [number, number]) =>
`${range[0]}-${range[1]},共 ${total}+ 条`,
onChange: (page: number, pageSize: number) => {
setPagination({ page, page_size: pageSize });
},
placement: ['bottomCenter'],
}}
locale={{ emptyText: '暂无订单记录' }}
/>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,526 @@
// ============================================================
// RechargePage — 积分充值表单 + 支付状态跟踪
//
// 通过 PageDispatcher 调度Modal 容器),导航栏"充值"按钮触发。
// 两阶段视图:
// form — 金额选择 → 自定义输入 → 支付方式 → 协议 → 提交
// pending — 订单已创建,轮询支付状态,可重新打开支付页面
// paid — 支付成功
// expired — 订单过期,可重新下单
// failed — 支付失败,可重新下单
// ============================================================
import { useState, useMemo, useCallback, useEffect } from 'react';
import { logger } from '@/shared/utils/bus';
import {
Radio,
InputNumber,
Button,
Checkbox,
Typography,
App,
Space,
Divider,
theme as antTheme,
Result,
} from 'antd';
import {
AlipayOutlined,
WechatOutlined,
WalletOutlined,
DollarOutlined,
} from '@ant-design/icons';
import { useTheme } from '@/app/providers/ThemeProvider';
import { LegalTextModal } from '@/shared/components/LegalTextModal';
import {
PaymentAPI,
PAYMENT_METHODS,
PAYMENT_STATUS,
getPaymentStatusText,
type PaymentStatus,
} from '@/services/modules/account/payments';
import { emit, EVENTS } from '@/shared/utils/bus';
const { Text } = Typography;
// ---------- 常量 ----------
const PRESET_AMOUNTS = [10, 100, 500, 1000, 5000, 10000] as const;
/** 单次充值上限(元) */
const MAX_AMOUNT_YUAN = 100000;
/** 订单状态轮询间隔(毫秒) */
const POLL_INTERVAL_MS = 3000;
/** 页面阶段 */
type RechargePhase = 'form' | PaymentStatus;
// ---------- 组件 ----------
export function RechargePage() {
const { isDark } = useTheme();
const { message } = App.useApp();
const { token } = antTheme.useToken();
// ======== 表单状态 ========
const [selectedAmount, setSelectedAmount] = useState<number | null>(null);
const [customAmount, setCustomAmount] = useState<number | null>(null);
const [paymentMethod, setPaymentMethod] = useState<string>(PAYMENT_METHODS.Alipay);
const [agreedToTerms, setAgreedToTerms] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [legalOpen, setLegalOpen] = useState(false);
// ======== 订单状态 ========
const [phase, setPhase] = useState<RechargePhase>('form');
const [orderId, setOrderId] = useState<string | null>(null);
const [orderStatus, setOrderStatus] = useState<PaymentStatus | null>(null);
const [payUrl, setPayUrl] = useState<string | null>(null);
// ======== 派生值 ========
/** 最终充值金额(元):自定义金额优先,否则预设金额,否则 0 */
const finalAmountYuan = useMemo(() => {
if (customAmount !== null && customAmount !== undefined) {
return customAmount;
}
return selectedAmount ?? 0;
}, [customAmount, selectedAmount]);
/** 最终充值金额(分):元 × 100用于 API 调用 */
const finalAmountCent = finalAmountYuan * 100;
// ======== 工具函数 ========
const clearError = useCallback((name: string) => {
setErrors((prev) => {
if (!(name in prev)) return prev;
const next = { ...prev };
delete next[name];
return next;
});
}, []);
// ======== 校验 ========
const validate = useCallback((): Record<string, string> => {
const errs: Record<string, string> = {};
if (!finalAmountYuan || finalAmountYuan <= 0) {
errs.amount = '请选择或输入充值金额';
} else if (!Number.isInteger(finalAmountYuan) || finalAmountYuan % 10 !== 0) {
errs.customAmount = '金额必须为 10 的倍数';
} else if (finalAmountYuan > MAX_AMOUNT_YUAN) {
errs.customAmount = `单次充值上限为 ${MAX_AMOUNT_YUAN.toLocaleString()}`;
}
if (!agreedToTerms) {
errs.agreedToTerms = '请阅读并同意充值协议';
}
return errs;
}, [finalAmountYuan, agreedToTerms]);
// ======== 支付方式切换 ========
const handlePaymentChange = useCallback(
(value: string) => {
if (value === PAYMENT_METHODS.WeChat) {
void message.info('微信支付正在接入中,敬请期待');
return; // 不更新 state保持支付宝选中
}
setPaymentMethod(value);
},
[message],
);
// ======== 提交流程 ========
const handleSubmit = useCallback(async () => {
const validationErrors = validate();
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setSubmitting(true);
try {
const res = await PaymentAPI.createOrder({
amount_cent: finalAmountCent,
payment_channel: PAYMENT_METHODS.Alipay,
});
// 保存订单信息,进入等待支付阶段
setOrderId(res.id);
setOrderStatus(PAYMENT_STATUS.Pending);
setPayUrl(res.pay_url);
setPhase(PAYMENT_STATUS.Pending);
window.open(res.pay_url, '_blank');
void message.success('订单已创建,请在新页面中完成支付');
} catch (err: unknown) {
logger.warn('ui', '充值创建订单失败', err instanceof Error ? err : new Error(String(err)));
message.error((err as Error)?.message || '创建订单失败,请重试');
} finally {
setSubmitting(false);
}
}, [finalAmountCent, validate, message]);
// ======== 订单状态轮询 ========
useEffect(() => {
if (phase !== PAYMENT_STATUS.Pending || !orderId) return;
let timer: ReturnType<typeof setTimeout>;
let cancelled = false;
const poll = async () => {
try {
const order = await PaymentAPI.getOrderInfo({ order_id: orderId });
if (cancelled) return;
const newStatus = order.status;
setOrderStatus(newStatus);
if (newStatus === PAYMENT_STATUS.Pending) {
// 仍待支付 → 继续轮询
timer = setTimeout(poll, POLL_INTERVAL_MS);
} else {
// 终态 → 停止轮询,切换阶段
setPhase(newStatus);
if (newStatus === PAYMENT_STATUS.Paid) {
void message.success('支付成功,积分已到账');
}
}
} catch {
// 网络异常 → 延长间隔后继续轮询
if (!cancelled) timer = setTimeout(poll, POLL_INTERVAL_MS * 2);
}
};
timer = setTimeout(poll, POLL_INTERVAL_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [phase, orderId, message]);
// ======== 页面锁定(待支付时禁止关闭容器) ========
useEffect(() => {
const locked = phase === PAYMENT_STATUS.Pending;
emit(EVENTS.PAGE_LOCK, { locked });
// 组件卸载时确保解锁(防止锁定残留)
return () => {
if (locked) emit(EVENTS.PAGE_LOCK, { locked: false });
};
}, [phase]);
// ======== 操作回调 ========
/** 重新下单:回到表单,保留之前填写的金额 */
const handleRetry = useCallback(() => {
setPhase('form');
setOrderId(null);
setOrderStatus(null);
setPayUrl(null);
}, []);
/** 关闭充值页面 */
const handleClose = useCallback(() => {
emit(EVENTS.CLOSE_PAGE);
}, []);
// ================================================================
// 渲染:支付状态视图(非 form 阶段)
// ================================================================
if (phase !== 'form') {
const statusText = orderStatus ? getPaymentStatusText(orderStatus) : '';
const isPending = phase === PAYMENT_STATUS.Pending;
const isPaid = phase === PAYMENT_STATUS.Paid;
const isTerminal = !isPending;
return (
<>
<div style={{ padding: '16px 0' }}>
<Result
status={
isPaid
? 'success'
: isPending
? 'info'
: 'error'
}
title={
isPaid
? '支付成功'
: isPending
? `等待支付(${statusText}`
: statusText
}
subTitle={
isPaid
? `已到账积分:${finalAmountYuan.toLocaleString()} 积分`
: isPending
? '请在新打开的浏览器页面中完成支付'
: undefined
}
>
{/* ---- 订单信息卡片 ---- */}
{isPending && (
<div
style={{
background: isDark ? '#2E2A5A' : '#F5F3FF',
padding: '12px 16px',
borderRadius: 8,
marginBottom: 16,
textAlign: 'left',
maxWidth: 360,
margin: '0 auto 16px',
}}
>
<Space orientation="vertical" size={6} style={{ width: '100%' }}>
<Text style={{ fontSize: 12 }} type="secondary">
<Text copyable style={{ fontSize: 12 }}>{orderId}</Text>
</Text>
<Text style={{ fontSize: 13 }}>
<DollarOutlined style={{ marginRight: 6, color: token.colorPrimary }} />
<Text strong>¥{finalAmountYuan.toLocaleString()} </Text>
</Text>
<Text style={{ fontSize: 13 }}>
<WalletOutlined style={{ marginRight: 6, color: token.colorSuccess }} />
<Text strong>{finalAmountYuan.toLocaleString()}</Text>
</Text>
</Space>
</div>
)}
{/* ---- 操作按钮 ---- */}
<Space size={12}>
{isPending && payUrl && (
<Button
type="primary"
icon={<DollarOutlined />}
onClick={() => window.open(payUrl, '_blank')}
>
</Button>
)}
{isTerminal && !isPaid && (
<Button type="primary" onClick={handleRetry}>
</Button>
)}
<Button onClick={handleClose}>
{isPaid ? '完成' : '关闭(取消支付)'}
</Button>
</Space>
</Result>
</div>
{/* ---- 协议弹窗(待支付阶段仍可查看) ---- */}
<LegalTextModal
open={legalOpen}
type="points"
onClose={() => setLegalOpen(false)}
/>
</>
);
}
// ================================================================
// 渲染表单视图form 阶段)
// ================================================================
return (
<>
<div style={{ padding: '4px 0' }}>
{/* ---- 页面副标题 ---- */}
<Text
type="secondary"
style={{ fontSize: 13, marginBottom: 20, display: 'block' }}
>
</Text>
{/* ---- Part 1预设金额单选组 ---- */}
<div style={{ marginBottom: 16 }}>
<Radio.Group
value={selectedAmount}
onChange={(e) => {
setSelectedAmount(e.target.value);
setCustomAmount(null);
clearError('amount');
}}
optionType="button"
buttonStyle="solid"
style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}
>
{PRESET_AMOUNTS.map((amt) => (
<Radio.Button key={amt} value={amt}>
{amt}
</Radio.Button>
))}
</Radio.Group>
{errors.amount && (
<Text type="danger" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
{errors.amount}
</Text>
)}
</div>
{/* ---- Part 2自定义金额输入 ---- */}
<div style={{ marginBottom: 16 }}>
<Space.Compact style={{ width: '100%' }}>
<InputNumber
value={customAmount}
onChange={(val) => {
setCustomAmount(val);
setSelectedAmount(null);
clearError('customAmount');
}}
min={10}
step={10}
max={MAX_AMOUNT_YUAN}
placeholder="输入自定义金额10的倍数"
style={{ flex: 1 }}
size="large"
precision={0}
/>
<Button size="large" disabled style={{ minWidth: 44 }}>
</Button>
</Space.Compact>
{errors.customAmount && (
<Text type="danger" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
{errors.customAmount}
</Text>
)}
</div>
{/* ---- Part 3到账信息 + 支付金额 ---- */}
{finalAmountYuan > 0 && (
<div
style={{
background: isDark ? '#2E2A5A' : '#F5F3FF',
padding: '12px 16px',
borderRadius: 8,
marginBottom: 12,
}}
>
<Space orientation="vertical" size={6}>
<Text style={{ fontSize: 13 }}>
<WalletOutlined style={{ marginRight: 6, color: token.colorPrimary }} />
<Text strong>{finalAmountYuan.toLocaleString()}</Text>
</Text>
<Text style={{ fontSize: 13 }}>
<DollarOutlined style={{ marginRight: 6, color: token.colorSuccess }} />
<Text strong style={{ color: token.colorPrimary, fontSize: 15 }}>
¥{finalAmountYuan.toLocaleString()}
</Text>
{' '}
</Text>
</Space>
</div>
)}
<Divider style={{ margin: '12px 0' }} />
{/* ---- Part 4支付方式 ---- */}
<div style={{ marginBottom: 16 }}>
<Text
type="secondary"
style={{ fontSize: 12, marginBottom: 8, display: 'block' }}
>
</Text>
<Radio.Group
value={paymentMethod}
onChange={(e) => handlePaymentChange(e.target.value)}
optionType="button"
buttonStyle="solid"
>
<Radio.Button value={PAYMENT_METHODS.Alipay}>
<Space size={4}>
<AlipayOutlined />
</Space>
</Radio.Button>
<Radio.Button value={PAYMENT_METHODS.WeChat}>
<Space size={4}>
<WechatOutlined />
</Space>
</Radio.Button>
</Radio.Group>
</div>
<Divider style={{ margin: '12px 0' }} />
{/* ---- Part 5充值协议 ---- */}
<div style={{ marginBottom: 20 }}>
<Checkbox
checked={agreedToTerms}
onChange={(e) => {
setAgreedToTerms(e.target.checked);
clearError('agreedToTerms');
}}
>
<Text style={{ fontSize: 12 }}>
<Button
type="link"
size="small"
onClick={() => setLegalOpen(true)}
style={{
padding: '0 2px',
fontSize: 12,
height: 'auto',
lineHeight: 'inherit',
}}
>
</Button>
</Text>
</Checkbox>
{errors.agreedToTerms && (
<Text type="danger" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
{errors.agreedToTerms}
</Text>
)}
</div>
{/* ---- Part 6操作按钮 ---- */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button onClick={handleClose}>
</Button>
<Button
type="primary"
loading={submitting}
onClick={() => void handleSubmit()}
disabled={finalAmountYuan <= 0}
>
</Button>
</div>
</div>
{/* ---- 协议弹窗 ---- */}
<LegalTextModal
open={legalOpen}
type="points"
onClose={() => setLegalOpen(false)}
/>
</>
);
}

View File

@@ -0,0 +1,640 @@
// ============================================================
// VipPage — VIP 会员开通页面
//
// 通过 PageDispatcher 调度Modal 容器),导航栏"开会员/激活"按钮触发。
// 四部分布局:
// 激活码 — Space.Compact 输入框 + 激活按钮
// 套餐选择 — Radio.Group 卡片式单选API 获取套餐列表
// 支付协议 — Checkbox + LegalTextModal用户服务协议 + 确认开通
// 权益说明 — 选中套餐的大图展示
// ============================================================
import {useState, useCallback, useEffect, useRef} from 'react';
import { logger } from '@/shared/utils/bus';
import {
Radio,
Button,
Checkbox,
Input,
Image,
Typography,
App,
Space,
Divider,
theme as antTheme,
Spin,
Result,
} from 'antd';
import {CrownOutlined, GiftOutlined, DollarOutlined} from '@ant-design/icons';
import {useTheme} from '@/app/providers/ThemeProvider';
import {LegalTextModal} from '@/shared/components/LegalTextModal';
import {
VipAPI,
VipOrderStatus,
type VipLevelsItem,
type VipOrderStatusValue,
} from '@/services/modules/account/vip-api';
import {centToYuan} from '@/shared/utils/display';
import {EnumResolver} from '@/shared/utils/enum-resolver';
import {emit, EVENTS} from '@/shared/utils/bus';
import {RequestError, RequestErrorType} from '@/services/request';
const {Text} = Typography;
// ---------- 常量 ----------
/** 订单状态轮询间隔(毫秒) */
const POLL_INTERVAL_MS = 3000;
/** 页面阶段 */
type VipPhase = 'form' | 'activate' | VipOrderStatusValue;
/** 状态 → Result status 映射 */
function getResultStatus(status: VipOrderStatusValue): 'success' | 'info' | 'error' {
if (status === VipOrderStatus.Paid) return 'success';
if (status === VipOrderStatus.Pending) return 'info';
return 'error';
}
// ---------- 组件 ----------
export function VipPage() {
const {isDark} = useTheme();
const {message} = App.useApp();
const {token} = antTheme.useToken();
// ======== 套餐列表 ========
const [levels, setLevels] = useState<VipLevelsItem[]>([]);
const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const mountedRef = useRef(true);
// ======== 表单状态 ========
const [selectedLevelId, setSelectedLevelId] = useState<number | null>(null);
const [agreedToTerms, setAgreedToTerms] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [legalOpen, setLegalOpen] = useState(false);
// ======== 激活码 ========
const [activateCode, setActivateCode] = useState('');
const [activating, setActivating] = useState(false);
// ======== 支付订单状态 ========
const [phase, setPhase] = useState<VipPhase>('form');
const [vipOrderId, setVipOrderId] = useState<string | null>(null);
const [vipOrderStatus, setVipOrderStatus] = useState<VipOrderStatusValue | null>(null);
const [vipPayUrl, setVipPayUrl] = useState<string | null>(null);
// ======== 工具函数 ========
const clearError = useCallback((name: string) => {
setErrors((prev) => {
if (!(name in prev)) return prev;
const next = {...prev};
delete next[name];
return next;
});
}, []);
// ======== 获取套餐列表 ========
const fetchLevels = useCallback(async () => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
setFetchError(null);
try {
const data = await VipAPI.getVipLevels(controller.signal);
if (mountedRef.current) {
setLevels(data.filter((l) => l.is_active && l.image));
}
} catch (err) {
// axios 拦截器将取消错误转为 RequestError(CANCELLED),非原始 AbortError
if (err instanceof RequestError && err.type === RequestErrorType.CANCELLED) return;
logger.warn('ui', '加载VIP套餐列表失败', err instanceof Error ? err : new Error(String(err)));
if (mountedRef.current) {
setFetchError((err as Error)?.message || '加载套餐列表失败');
}
} finally {
if (mountedRef.current) {
setLoading(false);
}
}
}, []);
useEffect(() => {
mountedRef.current = true;
void fetchLevels();
return () => {
mountedRef.current = false;
abortRef.current?.abort();
};
}, [fetchLevels]);
// ======== 校验 ========
const validate = useCallback((): Record<string, string> => {
const errs: Record<string, string> = {};
if (selectedLevelId === null) {
errs.selectedLevel = '请选择会员套餐';
}
if (!agreedToTerms) {
errs.agreedToTerms = '请阅读并同意用户服务协议';
}
return errs;
}, [selectedLevelId, agreedToTerms]);
// ======== 提交 ========
const handleSubmit = useCallback(async () => {
const validationErrors = validate();
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setSubmitting(true);
try {
const res = await VipAPI.createVipOrder({
vip_level_id: selectedLevelId!,
payment_channel: 'alipay',
});
// 保存订单信息,进入待支付阶段
setVipOrderId(res.id);
setVipOrderStatus(VipOrderStatus.Pending);
setVipPayUrl(res.pay_url);
setPhase(VipOrderStatus.Pending);
window.open(res.pay_url ?? '', '_blank');
void message.success('订单已创建,请在新页面中完成支付');
} catch (err: unknown) {
logger.warn('ui', 'VIP创建订单失败', err instanceof Error ? err : new Error(String(err)));
message.error((err as Error)?.message || '创建订单失败,请重试');
} finally {
setSubmitting(false);
}
}, [selectedLevelId, validate, message]);
// ======== 激活码提交 ========
const handleActivate = useCallback(async () => {
const code = activateCode.trim();
if (!code) {
message.warning('请输入激活码');
return;
}
setActivating(true);
try {
await VipAPI.activateVipCode({code});
void message.success('激活成功VIP 权益已生效');
emit(EVENTS.CLOSE_PAGE);
} catch (err: unknown) {
logger.warn('ui', 'VIP激活失败', err instanceof Error ? err : new Error(String(err)));
message.error((err as Error)?.message || '激活失败,请检查激活码是否正确');
} finally {
setActivating(false);
}
}, [activateCode, message]);
// ======== 操作回调 ========
// ======== 订单状态轮询 ========
useEffect(() => {
if (phase !== VipOrderStatus.Pending || !vipOrderId) return;
let timer: ReturnType<typeof setTimeout>;
let cancelled = false;
const poll = async () => {
try {
const order = await VipAPI.getVipOrder(vipOrderId);
if (cancelled) return;
const newStatus = order.status;
setVipOrderStatus(newStatus);
if (newStatus === VipOrderStatus.Pending) {
timer = setTimeout(poll, POLL_INTERVAL_MS);
} else {
setPhase(newStatus);
if (newStatus === VipOrderStatus.Paid) {
void message.success('支付成功VIP 权益已生效');
}
}
} catch {
if (!cancelled) timer = setTimeout(poll, POLL_INTERVAL_MS * 2);
}
};
timer = setTimeout(poll, POLL_INTERVAL_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [phase, vipOrderId, message]);
// ======== 页面锁定(待支付时禁止关闭容器) ========
useEffect(() => {
const locked = phase === VipOrderStatus.Pending;
emit(EVENTS.PAGE_LOCK, { locked });
return () => {
if (locked) emit(EVENTS.PAGE_LOCK, { locked: false });
};
}, [phase]);
// ======== 操作回调 ========
/** 重新下单:回到表单,保留套餐选择 */
const handleRetry = useCallback(() => {
setPhase('form');
setVipOrderId(null);
setVipOrderStatus(null);
setVipPayUrl(null);
}, []);
const handleClose = useCallback(() => {
emit(EVENTS.CLOSE_PAGE);
}, []);
// ================================================================
// 渲染:加载态
// ================================================================
if (loading) {
return (
<div style={{display: 'flex', justifyContent: 'center', padding: '48px 0'}}>
<Spin size="medium">
<div style={{padding: 24, textAlign: 'center'}}>
<Text type="secondary" style={{fontSize: 13}}></Text>
</div>
</Spin>
</div>
);
}
// ================================================================
// 渲染:错误态
// ================================================================
if (fetchError) {
return (
<div style={{padding: '8px 0'}}>
<Result
status="error"
title="套餐加载失败"
subTitle={fetchError}
extra={
<Button type="primary" size="small" onClick={fetchLevels}>
</Button>
}
/>
</div>
);
}
// ================================================================
// 渲染:支付状态视图(非 form / activate 阶段)
// ================================================================
if (phase !== 'form' && phase !== 'activate') {
const statusText = vipOrderStatus ? EnumResolver.vip.orderStatus.label(vipOrderStatus) : '';
const isPending = phase === VipOrderStatus.Pending;
const isPaid = phase === VipOrderStatus.Paid;
return (
<>
<div style={{ padding: '16px 0' }}>
<Result
status={vipOrderStatus ? getResultStatus(vipOrderStatus) : 'info'}
title={
isPaid
? '支付成功'
: isPending
? `等待支付(${statusText}`
: statusText
}
subTitle={
isPaid
? 'VIP 会员权益已生效'
: isPending
? '请在新打开的浏览器页面中完成支付'
: undefined
}
>
{/* ---- 订单信息卡片 ---- */}
{isPending && (
<div
style={{
background: isDark ? '#2E2A5A' : '#F5F3FF',
padding: '12px 16px',
borderRadius: 8,
marginBottom: 16,
textAlign: 'left',
maxWidth: 360,
margin: '0 auto 16px',
}}
>
<Space orientation="vertical" size={6} style={{ width: '100%' }}>
<Text style={{ fontSize: 12 }} type="secondary">
<Text copyable style={{ fontSize: 12 }}>{vipOrderId}</Text>
</Text>
<Text style={{ fontSize: 13 }}>
<DollarOutlined style={{ marginRight: 6, color: token.colorPrimary }} />
<Text strong>
¥{(() => {
const lvl = levels.find((l) => l.id === selectedLevelId);
return lvl ? centToYuan(lvl.price_cent) : '—';
})()}
</Text>
</Text>
<Text style={{ fontSize: 13 }}>
<CrownOutlined style={{ marginRight: 6, color: token.colorSuccess }} />
<Text strong>{levels.find((l) => l.id === selectedLevelId)?.name ?? '—'}</Text>
</Text>
</Space>
</div>
)}
{/* ---- 操作按钮 ---- */}
<Space size={12}>
{isPending && vipPayUrl && (
<Button
type="primary"
icon={<DollarOutlined />}
onClick={() => window.open(vipPayUrl, '_blank')}
>
</Button>
)}
{!isPending && !isPaid && (
<Button type="primary" onClick={handleRetry}>
</Button>
)}
<Button onClick={handleClose}>
{isPaid ? '完成' : '关闭'}
</Button>
</Space>
</Result>
</div>
{/* ---- 协议弹窗(待支付阶段仍可查看) ---- */}
<LegalTextModal
open={legalOpen}
type="service"
onClose={() => setLegalOpen(false)}
/>
</>
);
}
// ================================================================
// 渲染:正常
// ================================================================
return (
<>
<div style={{padding: '4px 0'}}>
<div>
<Text type="secondary" style={{fontSize: 13, marginBottom: 10, display: 'block'}}>
<GiftOutlined style={{marginRight: 4}} />
使
</Text>
<Space.Compact style={{width: '100%'}}>
<Input
value={activateCode}
onChange={(e) => setActivateCode(e.target.value)}
placeholder="输入 VIP 激活码"
allowClear
onPressEnter={() => void handleActivate()}
style={{flex: 1}}
/>
<Button
type="primary"
icon={<GiftOutlined />}
loading={activating}
onClick={() => void handleActivate()}
disabled={!activateCode.trim()}
>
</Button>
</Space.Compact>
</div>
<Divider style={{margin: '20px 0 16px'}} />
<Text type="secondary" style={{fontSize: 13, marginBottom: 12, display: 'block'}}>
</Text>
{/* 双列布局:左列套餐选择 | 右列权益图库 */}
<div style={{display: 'flex', gap: 12, marginBottom: 16}}>
{/* ---- 左列:套餐选择 ---- */}
<div style={{flex: '0 0 52%', minWidth: 0}}>
<Radio.Group
value={selectedLevelId}
onChange={(e) => {
setSelectedLevelId(e.target.value);
clearError('selectedLevel');
}}
style={{width: '100%'}}
>
<Space orientation="vertical" style={{width: '100%'}} size={6}>
{levels.map((level) => (
<Radio
key={level.id}
value={level.id}
style={{
display: 'flex',
alignItems: 'flex-start',
padding: '10px 12px',
borderRadius: 8,
border: `1px solid ${
selectedLevelId === level.id
? token.colorPrimary
: isDark
? '#2E2A5A'
: '#E5E7EB'
}`,
background:
selectedLevelId === level.id
? isDark
? '#2E2A5A'
: '#F5F3FF'
: isDark
? '#1A1730'
: '#FFFFFF',
width: '100%',
margin: 0,
transition: 'border-color 0.2s, background 0.2s',
}}
>
<div style={{flex: 1, marginLeft: 8, minWidth: 0}}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
marginBottom: 4,
}}
>
<Text strong style={{fontSize: 13}}>
{level.name}
</Text>
<Text
strong
style={{
fontSize: 14,
color: token.colorPrimary,
whiteSpace: 'nowrap',
}}
>
¥{centToYuan(level.price_cent)}
</Text>
</div>
<div style={{display: 'flex', gap: 8, flexWrap: 'wrap'}}>
<Text type="secondary" style={{fontSize: 11}}>
{level.duration_days}
</Text>
{level.gift_balance_cent > 0 && (
<Text type="secondary" style={{fontSize: 11}}>
{centToYuan(level.gift_balance_cent)}
</Text>
)}
<Text type="secondary" style={{fontSize: 11}}>
{EnumResolver.vip.targetAccountType.label(level.target_account_type)}
</Text>
</div>
{level.description && (
<Text type="secondary" style={{fontSize: 10, display: 'block', marginTop: 2}}>
{level.description}
</Text>
)}
</div>
</Radio>
))}
</Space>
</Radio.Group>
{errors.selectedLevel && (
<Text type="danger" style={{fontSize: 12, marginTop: 4, display: 'block'}}>
{errors.selectedLevel}
</Text>
)}
</div>
{/* ---- 右列:权益图库 ---- */}
<div
style={{
flex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
gap: 6,
}}
>
<Text type="secondary" style={{fontSize: 11, textAlign: 'center'}}>
</Text>
{levels.map((level) => (
<div
key={level.id}
style={{
borderRadius: 6,
overflow: 'hidden',
border: `2px solid ${
selectedLevelId === level.id
? token.colorPrimary
: 'transparent'
}`,
opacity: selectedLevelId === null || selectedLevelId === level.id ? 1 : 0.45,
transition: 'border-color 0.2s, opacity 0.2s',
cursor: 'pointer',
}}
>
<Image
src={level.image!}
alt={`${level.name} 权益`}
width="100%"
style={{display: 'block'}}
preview={{mask: '查看大图'}}
/>
</div>
))}
</div>
</div>
{/* ---- Part 2支付协议 + 确认开通 ---- */}
<div style={{marginBottom: 16}}>
<Checkbox
checked={agreedToTerms}
onChange={(e) => {
setAgreedToTerms(e.target.checked);
clearError('agreedToTerms');
}}
>
<Text style={{fontSize: 12}}>
<Button
type="link"
size="small"
onClick={() => setLegalOpen(true)}
style={{
padding: '0 2px',
fontSize: 12,
height: 'auto',
lineHeight: 'inherit',
}}
>
</Button>
</Text>
</Checkbox>
{errors.agreedToTerms && (
<Text type="danger" style={{fontSize: 12, marginTop: 4, display: 'block'}}>
{errors.agreedToTerms}
</Text>
)}
</div>
{/* 操作按钮 */}
<div style={{display: 'flex', justifyContent: 'flex-end', gap: 8}}>
<Button onClick={handleClose}></Button>
<Button
type="primary"
icon={<CrownOutlined />}
loading={submitting}
onClick={() => void handleSubmit()}
disabled={selectedLevelId === null}
>
</Button>
</div>
</div>
{/* ---- 协议弹窗 ---- */}
<LegalTextModal
open={legalOpen}
type="service"
onClose={() => setLegalOpen(false)}
/>
</>
);
}

View File

@@ -9,9 +9,10 @@
// ============================================================ // ============================================================
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { logger } from '@/shared/utils/bus';
import { Spin, Empty } from 'antd'; import { Spin, Empty } from 'antd';
import { WechatOutlined } from '@ant-design/icons'; import { WechatOutlined } from '@ant-design/icons';
import { fetchBanners, type Banner } from '@/services/modules/banner'; import { fetchBanners, type Banner } from '@/services/modules/home/banner';
export function WechatWorkPage() { export function WechatWorkPage() {
const [banner, setBanner] = useState<Banner | null>(null); const [banner, setBanner] = useState<Banner | null>(null);
@@ -29,6 +30,7 @@ export function WechatWorkPage() {
); );
setBanner(promotion ?? null); setBanner(promotion ?? null);
} catch (err) { } catch (err) {
logger.warn('ui', '企业微信二维码加载失败', err instanceof Error ? err : new Error(String(err)));
setError((err as Error).message || '加载失败'); setError((err as Error).message || '加载失败');
} finally { } finally {
setLoading(false); setLoading(false);

View File

@@ -0,0 +1,5 @@
// announcement 模块 — 公告
export { AnnouncementDrawer } from './views/AnnouncementDrawer';
export { fetchAnnouncements } from '@/services/modules/announcement/announcement';
export type { Announcement, AnnouncementType, AnnouncementTypeValue, AnnouncementListResponse } from '@/services/modules/announcement/announcement';
export { AnnouncementTypeEnum, AnnouncementTypeLabelMap, AnnouncementTypeColorMap } from '@/services/modules/announcement/announcement';

View File

@@ -1,34 +1,22 @@
// ============================================================ // ============================================================
// AnnouncementDrawer — 公告侧拉抽屉SPA 内部弹出) // AnnouncementDrawer — 公告侧拉抽屉SPA 内部弹出)
// 数据来源GET /api/v1/announcements //
// 数据由父组件NavBar通过 useAnnouncements Hook 提供:
// - 挂载时自动拉取 + 每 15 分钟轮询
// - 打开抽屉时已标记已读Badge 消除)
// ============================================================ // ============================================================
import {useEffect, useState, useCallback} from 'react'; import {useState} from 'react';
import {Drawer, Card, Typography, Empty, Tag, Spin, Modal, Tooltip} from 'antd'; import {Drawer, Card, Typography, Empty, Tag, Spin, Modal, Tooltip} from 'antd';
import {NotificationOutlined} from '@ant-design/icons'; import {NotificationOutlined} from '@ant-design/icons';
import { fetchAnnouncements, type Announcement, type AnnouncementListResponse } from '@/services/modules/announcement'; import type { Announcement, AnnouncementTypeValue } from '@/services/modules/announcement/announcement';
import { EnumResolver } from '@/shared/utils/enum-resolver';
const {Text, Paragraph} = Typography; const {Text, Paragraph} = Typography;
// ---------- 辅助 ---------- // ---------- 辅助 ----------
/** 公告类型 → Tag 颜色映射 */
const typeColorMap: Record<string, string> = {
system: 'blue',
feature: 'purple',
activity: 'orange',
maintenance: 'red',
};
/** 公告类型 → 中文标签 */
const typeLabelMap: Record<string, string> = {
system: '系统',
feature: '功能',
activity: '活动',
maintenance: '维护',
};
/** 格式化日期YYYY-MM-DD HH:mm */ /** 格式化日期YYYY-MM-DD HH:mm */
function formatDate(iso: string): string { function formatDate(iso: string): string {
try { try {
@@ -49,37 +37,16 @@ function formatDate(iso: string): string {
interface AnnouncementDrawerProps { interface AnnouncementDrawerProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
/** 公告列表(由 useAnnouncements Hook 提供) */
announcements: Announcement[];
/** 是否正在加载(由 useAnnouncements Hook 提供) */
loading: boolean;
} }
export function AnnouncementDrawer({open, onClose}: AnnouncementDrawerProps) { export function AnnouncementDrawer({open, onClose, announcements, loading}: AnnouncementDrawerProps) {
const [list, setList] = useState<Announcement[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 详情 Modal // 详情 Modal
const [detail, setDetail] = useState<Announcement | null>(null); const [detail, setDetail] = useState<Announcement | null>(null);
// 打开抽屉时请求公告列表
const loadAnnouncements = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data: AnnouncementListResponse = await fetchAnnouncements();
setList(data.filter((item: Announcement) => item.is_active));
} catch (err) {
setError((err as Error).message || '加载公告失败');
setList([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (open) {
loadAnnouncements();
}
}, [open, loadAnnouncements]);
// ---- 渲染 ---- // ---- 渲染 ----
return ( return (
@@ -101,17 +68,13 @@ export function AnnouncementDrawer({open, onClose}: AnnouncementDrawerProps) {
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<Spin description="加载中..." /> <Spin description="加载中..." />
</div> </div>
) : error ? ( ) : announcements.length === 0 ? (
<div className="flex items-center justify-center h-64">
<Empty description={error} />
</div>
) : list.length === 0 ? (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<Empty description="暂无公告" /> <Empty description="暂无公告" />
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-3 p-3"> <div className="flex flex-col gap-3 p-3">
{list.map((item:Announcement) => ( {announcements.map((item:Announcement) => (
<Tooltip <Tooltip
key={item.id} key={item.id}
title={ title={
@@ -132,10 +95,10 @@ export function AnnouncementDrawer({open, onClose}: AnnouncementDrawerProps) {
} }
extra={ extra={
<Tag <Tag
color={typeColorMap[item.type] || 'default'} color={EnumResolver.announcement.type.color(item.type as AnnouncementTypeValue)}
className="m-0! text-xs" className="m-0! text-xs"
> >
{typeLabelMap[item.type] || item.type} {EnumResolver.announcement.type.label(item.type as AnnouncementTypeValue)}
</Tag> </Tag>
} }
onClick={() => setDetail(item)} onClick={() => setDetail(item)}
@@ -177,10 +140,10 @@ export function AnnouncementDrawer({open, onClose}: AnnouncementDrawerProps) {
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Tag <Tag
color={typeColorMap[detail.type] || 'default'} color={EnumResolver.announcement.type.color(detail.type as AnnouncementTypeValue)}
className="m-0! text-xs" className="m-0! text-xs"
> >
{typeLabelMap[detail.type] || detail.type} {EnumResolver.announcement.type.label(detail.type as AnnouncementTypeValue)}
</Tag> </Tag>
<Text type="secondary" className="text-xs"> <Text type="secondary" className="text-xs">
{formatDate(detail.created_at)} {formatDate(detail.created_at)}

View File

@@ -0,0 +1,6 @@
// auth 模块 — 认证(登录/注册/密码/Token
export { LoginPage } from './views/AuthPage';
export { AuthAPI } from '@/services/modules/auth/auth';
export type { LoginResponseBody } from '@/services/modules/auth/auth';
export { initAuthRefresh } from '@/services/auth-token';
export { getAutoLoginFlag, clearAutoLoginFlag } from '@/services/auth-persistence';

View File

@@ -25,28 +25,5 @@ export interface RegisterFormValues {
referral_code: string; referral_code: string;
} }
// ---------- 字段配置(数据驱动 UI 渲染) ---------- // 从 ui/ 重新导出通用字段类型(保持向后兼容)
export type { FieldRule, InputType, FieldConfig } from '@/shared/components/types';
export interface FieldRule {
required?: boolean;
minLen?: number;
maxLen?: number;
pattern?: RegExp;
message?: string;
}
export type InputType = 'text' | 'password' | 'tel';
export interface FieldConfig {
name: string;
label: string;
placeholder: string;
inputType: InputType;
rules: FieldRule[];
/** 仅注册表单的选填字段 */
optional?: boolean;
/** 附带操作按钮标识 */
extraAction?: 'send-sms';
/** 冷却秒数(发送验证码按钮用) */
cooldown?: number;
}

View File

@@ -3,7 +3,7 @@
// 修改校验规则或字段属性只需改此文件,无需动 UI 代码 // 修改校验规则或字段属性只需改此文件,无需动 UI 代码
// ============================================================ // ============================================================
import type { FieldConfig } from '../types'; import type { FieldConfig } from '../model/types';
export const loginFields: FieldConfig[] = [ export const loginFields: FieldConfig[] = [
{ {

View File

@@ -3,7 +3,7 @@
// 修改校验规则或字段属性只需改此文件,无需动 UI 代码 // 修改校验规则或字段属性只需改此文件,无需动 UI 代码
// ============================================================ // ============================================================
import type { FieldConfig } from '../types'; import type { FieldConfig } from '../model/types';
export const registerFields: FieldConfig[] = [ export const registerFields: FieldConfig[] = [
{ {

View File

@@ -76,7 +76,7 @@ export function validateReferralCode(value: string): ValidateResult {
* *
* @returns * @returns
*/ */
export function validateField(value: string, rules: import('../types').FieldRule[]): string[] { export function validateField(value: string, rules: import('../model/types').FieldRule[]): string[] {
const errors: string[] = []; const errors: string[] = [];
for (const rule of rules) { for (const rule of rules) {
// required // required
@@ -134,7 +134,7 @@ export function runFieldChecks(
*/ */
export function validateForm<T extends Record<string, string>>( export function validateForm<T extends Record<string, string>>(
values: T, values: T,
fieldConfigs: import('../types').FieldConfig[], fieldConfigs: import('../model/types').FieldConfig[],
crossValidators?: Record<string, (value: string, allValues: T) => string | null>, crossValidators?: Record<string, (value: string, allValues: T) => string | null>,
): Record<string, string[]> { ): Record<string, string[]> {
const errors: Record<string, string[]> = {}; const errors: Record<string, string[]> = {};

View File

@@ -8,17 +8,18 @@
// ============================================================ // ============================================================
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { logger } from '@/shared/utils/bus';
import { Modal, Tabs, Typography, App } from 'antd'; import { Modal, Tabs, Typography, App } from 'antd';
import { useAppContext } from '@/components/AppProvider'; import { useAppContext } from '@/app/providers/AppProvider';
import { useTheme } from '@/components/ThemeProvider'; import { useTheme } from '@/app/providers/ThemeProvider';
import { LoginForm } from './components/LoginForm'; import { LoginForm } from './components/LoginForm';
import { RegisterForm } from './components/RegisterForm'; import { RegisterForm } from './components/RegisterForm';
import { ForgotPasswordModal } from './components/ForgotPasswordModal'; import { ForgotPasswordModal } from './components/ForgotPasswordModal';
import { useAuthState } from './hooks/use-auth-state'; import { useAuthState } from '../hooks/use-auth-state';
import { AuthAPI, type LoginResponseBody } from '@/services/modules/auth'; import { AuthAPI, type LoginResponseBody } from '@/services/modules/auth/auth';
import { getDeviceId } from '@/utils/device'; import { getDeviceId } from '@/shared/utils/env';
import type { LoginFormValues, RegisterFormValues } from './types'; import type { LoginFormValues, RegisterFormValues } from '../model/types';
const { Text } = Typography; const { Text } = Typography;
@@ -60,6 +61,7 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
message.success(`登录成功,欢迎 ${auth.display_name || auth.username}`); message.success(`登录成功,欢迎 ${auth.display_name || auth.username}`);
onClose(); onClose();
} catch (err) { } catch (err) {
logger.warn('auth', '登录失败', err instanceof Error ? err : undefined);
message.error((err as Error).message || '登录失败,请检查用户名和密码'); message.error((err as Error).message || '登录失败,请检查用户名和密码');
} finally { } finally {
setSubmitting(false); setSubmitting(false);
@@ -87,6 +89,7 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
message.success('注册成功,请登录'); message.success('注册成功,请登录');
setActiveTab('login'); setActiveTab('login');
} catch (err) { } catch (err) {
logger.warn('auth', '注册失败', err instanceof Error ? err : undefined);
message.error((err as Error).message || '注册失败'); message.error((err as Error).message || '注册失败');
} finally { } finally {
setSubmitting(false); setSubmitting(false);
@@ -101,6 +104,7 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
await AuthAPI.sendSms({ phone }); await AuthAPI.sendSms({ phone });
message.success('验证码已发送'); message.success('验证码已发送');
} catch (err) { } catch (err) {
logger.warn('auth', '验证码发送失败', err instanceof Error ? err : undefined);
message.error((err as Error).message || '验证码发送失败'); message.error((err as Error).message || '验证码发送失败');
} }
}, },

View File

@@ -9,12 +9,13 @@
// ============================================================ // ============================================================
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { logger } from '@/shared/utils/bus';
import { Modal, Input, Button, Typography, App } from 'antd'; import { Modal, Input, Button, Typography, App } from 'antd';
import { LockOutlined, KeyOutlined, SafetyOutlined } from '@ant-design/icons'; import { LockOutlined, KeyOutlined, SafetyOutlined } from '@ant-design/icons';
import { useTheme } from '@/components/ThemeProvider'; import { useTheme } from '@/app/providers/ThemeProvider';
import { AuthAPI } from '@/services/modules/auth'; import { AuthAPI } from '@/services/modules/auth/auth';
import { validatePassword, validateConfirmPassword, runFieldChecks } from '../config/validators'; import { validatePassword, validateConfirmPassword, runFieldChecks } from '../../settings/validators';
const { Text, Title } = Typography; const { Text, Title } = Typography;
@@ -69,6 +70,7 @@ export function ChangePasswordModal({ open, onClose }: ChangePasswordModalProps)
setConfirmPassword(''); setConfirmPassword('');
onClose(); onClose();
} catch (err) { } catch (err) {
logger.warn('auth', '密码修改失败', err instanceof Error ? err : undefined);
message.error((err as Error).message || '密码修改失败'); message.error((err as Error).message || '密码修改失败');
} finally { } finally {
setSubmitting(false); setSubmitting(false);

View File

@@ -10,6 +10,7 @@
// ============================================================ // ============================================================
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { logger } from '@/shared/utils/bus';
import { Modal, Input, Button, Typography, App } from 'antd'; import { Modal, Input, Button, Typography, App } from 'antd';
import { import {
PhoneOutlined, PhoneOutlined,
@@ -18,16 +19,16 @@ import {
KeyOutlined, KeyOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useTheme } from '@/components/ThemeProvider'; import { useTheme } from '@/app/providers/ThemeProvider';
import { AuthAPI } from '@/services/modules/auth'; import { AuthAPI } from '@/services/modules/auth/auth';
import { import {
validatePhone, validatePhone,
validateSmsCode, validateSmsCode,
validatePassword, validatePassword,
validateConfirmPassword, validateConfirmPassword,
runFieldChecks, runFieldChecks,
} from '../config/validators'; } from '../../settings/validators';
import { useSmsCooldown } from '../hooks/use-sms-cooldown'; import { useSmsCooldown } from '../../hooks/use-sms-cooldown';
const { Text, Title } = Typography; const { Text, Title } = Typography;
@@ -76,7 +77,7 @@ export function ForgotPasswordModal({ open, onClose }: ForgotPasswordModalProps)
startCooldown(); startCooldown();
AuthAPI.sendSms({ phone }) AuthAPI.sendSms({ phone })
.then(() => message.success('验证码已发送')) .then(() => message.success('验证码已发送'))
.catch((e) => message.error((e as Error).message || '验证码发送失败')); .catch((e) => { logger.warn('auth', '忘记密码-验证码发送失败', e instanceof Error ? e : undefined); message.error((e as Error).message || '验证码发送失败'); });
}, [phone, startCooldown, clearError, message]); }, [phone, startCooldown, clearError, message]);
// ---- 提交重置 ---- // ---- 提交重置 ----
@@ -102,6 +103,7 @@ export function ForgotPasswordModal({ open, onClose }: ForgotPasswordModalProps)
setConfirmPassword(''); setConfirmPassword('');
onClose(); onClose();
} catch (err) { } catch (err) {
logger.warn('auth', '密码重置失败', err instanceof Error ? err : undefined);
message.error((err as Error).message || '密码重置失败'); message.error((err as Error).message || '密码重置失败');
} finally { } finally {
setSubmitting(false); setSubmitting(false);

View File

@@ -6,10 +6,10 @@ import { useState, useCallback } from 'react';
import { Input, Button, Checkbox, Typography } from 'antd'; import { Input, Button, Checkbox, Typography } from 'antd';
import { UserOutlined, LockOutlined, EyeTwoTone, EyeInvisibleOutlined } from '@ant-design/icons'; import { UserOutlined, LockOutlined, EyeTwoTone, EyeInvisibleOutlined } from '@ant-design/icons';
import { loginFields } from '../config/login-fields'; import { loginFields } from '../../settings/login-fields';
import { validateForm } from '../config/validators'; import { validateForm } from '../../settings/validators';
import { LegalTextModal } from '@/components/ui/LegalTextModal'; import { LegalTextModal } from '@/shared/components/LegalTextModal';
import type { LoginFormValues } from '../types'; import type { LoginFormValues } from '../../model/types';
const { Text } = Typography; const { Text } = Typography;

View File

@@ -5,7 +5,7 @@
import React, { useState, useCallback, useRef } from 'react'; import React, { useState, useCallback, useRef } from 'react';
import { Input, Button, Checkbox, Typography } from 'antd'; import { Input, Button, Checkbox, Typography } from 'antd';
import { LegalTextModal } from '@/components/ui/LegalTextModal'; import { LegalTextModal } from '@/shared/components/LegalTextModal';
import { import {
UserOutlined, UserOutlined,
LockOutlined, LockOutlined,
@@ -17,9 +17,9 @@ import {
GiftOutlined, GiftOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { registerFields } from '../config/register-fields'; import { registerFields } from '../../settings/register-fields';
import { validateForm } from '../config/validators'; import { validateForm } from '../../settings/validators';
import type { RegisterFormValues } from '../types'; import type { RegisterFormValues } from '../../model/types';
const { Text } = Typography; const { Text } = Typography;

View File

@@ -0,0 +1,38 @@
// ============================================================
// episode-menu.ts — 集数选择器右键菜单定义
//
// 菜单结构:新建集数 | 统计集数 | —— | 在文件夹中打开
// ============================================================
import type { ContextMenuItems } from '@/shared/components/ContextMenu';
export interface EpisodeMenuOptions {
canCreate: boolean;
canOpenFolder: boolean;
onCreate: () => void;
onStats: () => void;
onOpenFolder: () => void;
}
export function getEpisodeMenuItems(opts: EpisodeMenuOptions): ContextMenuItems {
return [
{
key: 'new-episode',
label: '新建集数',
onClick: opts.onCreate,
disabled: !opts.canCreate,
},
{
key: 'stats-episode',
label: '统计集数',
onClick: opts.onStats,
},
'divider',
{
key: 'open-folder',
label: '在文件夹中打开',
onClick: opts.onOpenFolder,
disabled: !opts.canOpenFolder,
},
];
}

View File

@@ -0,0 +1,15 @@
// ============================================================
// menus — 无限画布右键菜单定义统一导出
// ============================================================
export { getProjectMenuItems } from './project-menu';
export type { ProjectMenuOptions } from './project-menu';
export { getEpisodeMenuItems } from './episode-menu';
export type { EpisodeMenuOptions } from './episode-menu';
export { getShotMenuItems } from './shot-menu';
export type { ShotMenuOptions } from './shot-menu';
export { getMediaCardMenuItems } from './media-card-menu';
export type { MediaCardMenuOptions } from './media-card-menu';

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