diff --git a/.codegraph/daemon.pid b/.codegraph/daemon.pid index 6258f3a..ed9bd47 100644 --- a/.codegraph/daemon.pid +++ b/.codegraph/daemon.pid @@ -1,6 +1,6 @@ { - "pid": 17460, + "pid": 1493920, "version": "0.9.9", "socketPath": "\\\\.\\pipe\\codegraph-aef859f0205bacea", - "startedAt": 1781769813327 + "startedAt": 1782701380684 } diff --git a/.env.example b/.env.example index 3009369..062ab23 100644 --- a/.env.example +++ b/.env.example @@ -25,11 +25,18 @@ UPDATE_API_URL=https://www.heixiu.com # OSS_ACCESS_KEY_ID=your-access-key-id # OSS_ACCESS_KEY_SECRET=your-access-key-secret -# OSS 内基路径(默认 releases) -# OSS_BASE_PATH=releases - # 预判 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) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a5e84d..2649fff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ > 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。 --- + +## 0.0.28(2026-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.27(2026-06-24) **无限画布 UI 重构 + 预览键盘控制 + 拖拽体验优化** diff --git a/CLAUDE.md b/CLAUDE.md index e279827..0b629f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,18 +9,19 @@ - 进行系统级别的操作时,需要考虑系统差异,并处理兼容性。如WINDOWS、MAC OS; - 处理业务时,需要同时考虑兜底机制和业务自定义错误捕获,比如后端在修改了某些数据的枚举值但没有通知时,需要能够正常转化对用户友好的提示信息; - 新增文件时,需要注意各个文件夹当前平铺的文件数量,如果过多需要考虑合并文件或者合并为子文件夹; -- 所有设计思路、代码实现均需要考虑以上四个条件。 +- 在编写代码时,尽量保持JSX文件只有HTML元素结构,减少style属性的使用。其他关于js操作应提取为单独的TS模块文件,如复杂页面交互逻辑、菜单组件的注册等; +- 所有设计思路、代码实现均需要考虑以上五个条件。 # 相关文档说明 - 根目录下的文档包括: - - README.md(git中的README文档), - - WORKLOG.md(工作日志文档), - - UpdateA.md(热更新相关的文档), - - TODO.md(未完善、未来要做的文档,或者是在开发中遇到需要优化的地方), - - PROJECT.md(项目文档), - - CHANGELOG.md(版本更新文档), - - CLAUDE.md + - README.md(git中的README文档), + - WORKLOG.md(工作日志文档), + - UpdateA.md(热更新相关的文档), + - TODO.md(未完善、未来要做的文档,或者是在开发中遇到需要优化的地方), + - PROJECT.md(项目文档), + - CHANGELOG.md(版本更新文档), + - CLAUDE.md - 永远不要修改CLAUDE.md文档 - 在各个文档中的更新需要保持精简。且职责划分保持严谨,除了架构、项目文档,已完成的事需要从TODO.md文档中移除。 - 在没有得到修改指令时,不要修改文档文件。 diff --git a/PROJECT.md b/PROJECT.md index 3d06d04..123025f 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -38,7 +38,7 @@ - 事件驱动(EDA): 1. 主进程与渲染进程通信基于 Electron IPC(ipcMain/ipcRenderer),定义明确的事件通道与载荷类型。 - 2. 渲染进程内跨组件通信优先使用 Context;需要解耦的场景使用自定义事件总线(src/utils/event-bus.ts), + 2. 渲染进程内跨组件通信优先使用 Context;需要解耦的场景使用自定义事件总线(src/shared/utils/bus.ts), 所有 emit 调用自动记录到日志文件用于审计追踪。 3. 自定义错误体系(RequestError / RefreshError)通过事件总线解耦:请求拦截器抛出自定义错误 → @@ -56,157 +56,123 @@ # 目录结构 -> 每个文件夹下均有 `README.md` 说明该目录的职责与代码边界(自动生成,不进 Git)。 +> 2026-06-29 重构:业务模块从 `pages/` + `services/modules/` 分散模式统一为 `modules/` 下的 +> VCHSM 五层架构(Views → Controllers → Hooks → Settings → Model)。 ```md -├── electron/ # Electron 主进程 + 预加载 -│ ├── main.ts # 入口编排:窗口创建 / IPC 注册 / 生命周期 -│ ├── preload.ts # contextBridge:向渲染进程暴露安全 API -│ ├── electron-env.d.ts # 类型声明(APP_ROOT、Window 扩展) -│ └── main/ # 主进程核心模块 -│ │ ├── load-env.ts # .env.{mode} + .env.local 加载器 -│ │ ├── logger.ts # 日志核心:JSON Lines / 每日轮转 / 批量冲刷 -│ │ ├── net-request.ts # Electron net.request 的 axios 风格封装 -│ │ ├── updater.ts # 自动更新:自定义 Provider + 平台 Updater -│ │ ├── menu/index.ts # 系统菜单(macOS Edit / Win 无菜单) -│ │ ├── ipc/ # IPC handler 集合 -│ │ │ ├── log-ipc.ts # 渲染进程日志 → 主进程日志桥接 -│ │ │ ├── safe-storage-ipc.ts # safeStorage 加密/解密 IPC handler -│ │ │ └── storage-ipc.ts # 沙箱文件存储 IPC handler(heixiu-data/) -│ │ └── utils/ # 平台判断 / 图标路径 -│ └── preload/ # 预加载脚本(contextBridge 桥接层) -│ ├── device.ts # 硬件指纹采集(wmic/powershell | ioreg/sysctl) -│ └── device_service.ts # 设备信息缓存层(机器码/IP/MAC) -├── src/ # 渲染进程(React 19 + Vite + TypeScript) -│ ├── main.tsx # React 挂载 + Provider 嵌套 + 全局错误捕获 -│ ├── App.tsx # 根组件:叠加层编排(登录/设置/页面调度) -│ ├── vite-env.d.ts # Vite 类型声明 -│ ├── components/ # 通用组件层 -│ │ ├── providers/ # 全局 Context Provider -│ │ │ ├── AppProvider.tsx # 认证状态(login/logout/Token 刷新) -│ │ │ ├── ThemeProvider.tsx # 亮/暗主题切换 + antd ConfigProvider -│ │ │ └── SettingsProvider.tsx # 本地持久化设置(存储路径等) -│ │ ├── frame/ # 应用框架(外层结构 + 页面调度) -│ │ │ ├── Layout.tsx # 全高 flex 列布局壳 -│ │ │ ├── PageDispatcher.tsx # 通用页面调度器(Modal/Drawer/FloatingPanel) -│ │ │ └── navbar/ # 导航栏(nav-config → NavBar → NavItem) -│ │ └── ui/ # 通用 UI 原子组件 -│ │ ├── FloatingPanel.tsx # 可拖拽非模态浮动面板 -│ │ ├── LegalTextModal.tsx # 法律/协议文本 Modal -│ │ ├── FormField.tsx # 通用表单字段渲染器(数据驱动,从 auth/components 提升) -│ │ └── types.ts # FieldConfig / FieldRule / InputType 通用类型 -│ ├── hooks/ # 自定义 Hooks -│ │ ├── use-async.ts # 通用异步基础设施(竞态/取消/门控) -│ │ ├── use-api.ts # 业务 API Hooks(Banner/模型/任务/账户/财务) -│ │ └── use-updater.ts # 更新状态机(单例 IPC 监听) -│ ├── infrastructure/ # 基础设施层 -│ │ └── storage/ # 本地加密数据库(core/ + stores/ + services/) -│ │ ├── index.ts # 统一入口:init/clear/stats + 全部 re-export -│ │ ├── ipc-file.ts # 渲染进程端文件操作桥接 -│ │ ├── core/ # 数据库核心(4 文件) -│ │ │ ├── db-core.ts # sql.js WASM 初始化 + 通用 CRUD -│ │ │ ├── db-encrypt.ts # 序列化 → safeStorage 加密 → IPC 写磁盘 -│ │ │ ├── schema.ts # 6 张业务表 DDL(含索引) -│ │ │ └── types.ts # 实体类型定义 -│ │ ├── stores/ # 实体 CRUD Store(5 文件) -│ │ │ ├── task-store.ts # 任务记录 -│ │ │ ├── order-store.ts # 订单记录(充值 + VIP) -│ │ │ ├── param-store.ts # 参数历史快照 -│ │ │ ├── media-store.ts # 媒体缓存元数据 -│ │ │ └── settings-store.ts # 应用设置 key-value -│ │ └── services/ # 高级服务(4 文件) -│ │ ├── media-loader.ts # 媒体下载与三级缓存 -│ │ ├── sync-manager.ts # 分页同步游标 -│ │ ├── data-sync.ts # 数据同步辅助(从 settings/sections 归位) -│ │ └── user-scope.ts # 用户作用域隔离(JWT user_id) -│ ├── pages/ # 业务页面(按功能模块组织) -│ │ ├── auth/ # 认证模块 -│ │ │ ├── index.tsx # LoginPage Modal(登录/注册 Tabs) -│ │ │ ├── components/ # 表单组件(LoginForm/RegisterForm/FormField/改密/忘记密码) -│ │ │ ├── config/ # 字段配置(login-fields/register-fields)+ 校验函数库 -│ │ │ └── hooks/ # use-auth-state(偏好)/ use-sms-cooldown(短信冷却) -│ │ ├── home/ # 首页主工作区 -│ │ │ ├── HomePage.tsx # 首页入口 -│ │ │ ├── HomeContent.tsx # 三列可拖拽面板 + HomeContext.Provider -│ │ │ ├── HomeContext.tsx # 共享状态(选中模型/任务/分页) -│ │ │ ├── panels/ # 三列面板容器(Left/Center/Right) -│ │ │ ├── features/ # 功能模块(Banner/ModelSelector/ModelInputForm/TaskHistory/OutputPreview) -│ │ │ ├── controls/ # Widget 控件体系(6 种 Qt→React 映射 + WIDGET_MAP) -│ │ │ │ ├── types.ts # WidgetProps 统一接口 -│ │ │ │ ├── index.ts # WIDGET_MAP 注册表 -│ │ │ │ ├── simple-widgets.tsx # 6 个简单控件(Checkbox/LineInput/TextInput/ComboBox/SpinBox/DoubleSpinBox) -│ │ │ │ ├── upload-widgets.tsx # 单文件上传(ImageInput/VideoInput,共享 enable_switch) -│ │ │ │ ├── list-upload-widgets.tsx # 多文件拖拽上传(ImageListInput/AudioListInput) -│ │ │ │ └── SeedanceContent.tsx # 复合内容编排 -│ │ │ └── hooks/ # useModelUI(param_schema → UIFieldConfig) -│ │ ├── panels/ # NavBar 触发的功能面板页 -│ │ │ ├── AccountInfo.tsx # 账户信息(用户/积分/套餐/统计) -│ │ │ ├── BillingInfo.tsx # 财务信息(余额 + 账单流水) -│ │ │ ├── OrdersPage.tsx # 订单明细(充值/VIP 双 Tab + 轮询) -│ │ │ ├── RechargePage.tsx # 积分充值(预设/自定义金额 + 支付追踪) -│ │ │ ├── VipPage.tsx # VIP 开通(激活码/套餐选购/权益预览) -│ │ │ └── WechatWorkPage.tsx # 企业微信二维码 -│ │ └── settings/ # 设置页面 -│ │ ├── SettingsPage.tsx # 设置 Modal 主页面 -│ │ └── sections/ # 6 个设置分区(主题/系统/更新/路径/密码/数据) -│ ├── services/ # 服务层(HTTP 请求封装) -│ │ ├── request.ts # Axios 实例:拦截器 + Token 注入 + 401 刷新队列 -│ │ ├── auth-token.ts # Token 生命周期(refresh_token 持久化/主动刷新) -│ │ ├── auth-persistence.ts # 登录表单偏好(用户名回填/记住密码/自动登录) -│ │ ├── types.ts # 通用 API 类型(ApiResponse/RequestError/RefreshError) -│ │ ├── cache/ # 客户端缓存 -│ │ │ └── model-cache.ts # 模型列表加密本地缓存 -│ │ └── modules/ # API 模块(按业务域拆分) -│ │ ├── auth.ts # 认证 API(登录/注册/刷新/用户/改密/短信) -│ │ ├── models.ts # 模型 API + 分类常量 -│ │ ├── task.ts # 任务 API(CRUD + 批量状态 + CSV + 轮询常量) -│ │ ├── banner.ts # Banner/轮播图 API -│ │ ├── billing.ts # 财务 API(余额/账单/导出) -│ │ ├── payments.ts # 支付 API(订单 CRUD + 状态映射) -│ │ ├── vip-api.ts # VIP API(激活/等级/订单) -│ │ └── announcement.ts # 公告 API + 类型标签颜色 -│ ├── theme/ # 主题系统 -│ │ ├── tokens.ts # TypeScript 设计令牌 -│ │ ├── tokens.css # CSS 设计令牌(Tailwind @theme + CSS 变量亮/暗) -│ │ ├── antd-theme.ts # Ant Design ConfigProvider 主题配置(亮/暗) -│ │ ├── transitions.css # 主题切换过渡(View Transitions API + CSS fallback) -│ │ ├── base.css # 全局重置(盒模型/滚动条/聚焦环/拖拽区域) -│ │ └── globals.css # 入口文件(按序导入以上 CSS) -│ └── utils/ # 渲染进程工具函数(7 文件,已按功能合并) -│ ├── env.ts # 运行时环境:平台检测 + 设备 ID(← platform + device) -│ ├── bus.ts # 通信层:事件总线 + IPC 守卫 + 日志(← event-bus + ipc + logger) -│ ├── display.ts # 展示/格式化:金额、日期、Logo、Nullable(← display + logo + type) -│ ├── enum-resolver.ts # 枚举外观模式(7 模块标签/颜色统一解析) -│ ├── pricing.ts # 成本计算(Python 六种定价模式移植) -│ ├── safe-storage.ts # 加密 localStorage 封装(safeStorage 桥接) -│ └── media-upload.ts # 媒体上传共享工具(← controls/media-upload-utils) -├── shared/ # 主进程 & 渲染进程共享 -│ ├── constants/ -│ │ ├── app.ts # 应用名称/版本类型/窗口标题 -│ │ ├── ipc-channels.ts # 全部 IPC 通道名称(三类命名空间) -│ │ └── version.ts # 从 package.json 读取版本号 -│ ├── types/ -│ │ ├── index.ts # 通用类型(Platform/Theme/Edition/Update/Window) -│ │ └── logging.ts # 日志类型(LogLevel/LogCategory/LogEntry) -│ └── utils/ -│ ├── index.ts # 纯工具函数(debounce/throttle/compareVersions/deepClone) -│ └── sanitize.ts # 日志脱敏(token/密码/邮箱/手机/ID/金额) -├── scripts/ # 构建与发布脚本 -│ ├── clean.mjs # 清理构建产物 -│ ├── generate-update-info.mjs # 生成 update-info.json(SHA512 + CHANGELOG) -│ ├── upload-oss.mjs # 上传安装包到阿里云 OSS -│ └── publish-release.mjs # 发布版本到后端 API -├── build.mjs # electron-builder 构建总管 -├── vite.config.ts # Vite 构建配置 -├── package.json # 含 electron-builder 打包配置 +├── electron/ # Electron 主进程 + 预加载 +│ ├── main.ts # 入口编排:窗口创建 / IPC 注册 / 生命周期 +│ ├── preload.ts # contextBridge:向渲染进程暴露安全 API +│ ├── electron-env.d.ts # 类型声明(APP_ROOT、Window 扩展) +│ └── main/ # 主进程核心模块 +│ │ ├── load-env.ts # .env.{mode} + .env.local 加载器 +│ │ ├── logger.ts # 日志核心:JSON Lines / 每日轮转 / 批量冲刷 +│ │ ├── net-request.ts # Electron net.request 的 axios 风格封装 +│ │ ├── updater.ts # 自动更新:自定义 Provider + 平台 Updater +│ │ ├── menu/index.ts # 系统菜单(macOS Edit / Win 无菜单) +│ │ ├── ipc/ # IPC handler 集合 +│ │ │ ├── log-ipc.ts # 渲染进程日志 → 主进程日志桥接 +│ │ │ ├── safe-storage-ipc.ts # safeStorage 加密/解密 IPC handler +│ │ │ ├── storage-ipc.ts # 沙箱文件存储 IPC handler(heixiu-data/) +│ │ │ └── canvas-ipc.ts # 画布文件操作 IPC handler +│ │ └── utils/ # 平台判断 / 图标路径 +│ └── preload/ # 预加载脚本(contextBridge 桥接层) +│ ├── device.ts # 硬件指纹采集 +│ └── device_service.ts # 设备信息缓存层 +│ +├── src/ # 渲染进程(React 19 + Vite + TypeScript) +│ │ +│ ├── app/ # ═══ 应用壳 ═══ +│ │ ├── App.tsx # 根组件:叠加层编排(登录/设置/页面调度) +│ │ ├── main.tsx # React 挂载 + Provider 嵌套 + 全局错误捕获 +│ │ ├── providers/ # 全局 Context Provider +│ │ │ ├── AppProvider.tsx # 认证状态(login/logout/Token 刷新) +│ │ │ ├── ThemeProvider.tsx # 亮/暗主题切换 + antd ConfigProvider +│ │ │ └── SettingsProvider.tsx # 本地持久化设置(存储路径等) +│ │ └── layout/ # 布局框架 +│ │ ├── Layout.tsx # 全高 flex 列布局壳(NavBar + 内容区) +│ │ └── 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.ts(Zustand store) +│ │ │ └── utils/ # scanner.ts / layout.ts / LocalFileDataSource.ts +│ │ └── settings/ V+C # 应用设置 +│ │ ├── views/ # SettingsPage.tsx + sections/(6 个设置分区) +│ │ └── controllers/ # settings-actions.ts +│ │ +│ └── shared/ # ═══ 跨模块共享 ═══ +│ ├── components/ # 通用 UI(FloatingPanel / FormField / LegalTextModal / ContextMenu) +│ ├── hooks/ # 全局 Hooks(useAnnouncements / useApi / useAsync / useUpdater) +│ ├── services/ # HTTP 客户端 http.ts + API 类型 +│ ├── utils/ # 工具函数(bus / env / enum-resolver / display / pricing / safe-storage) +│ ├── 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 / ipc-channels.ts / version.ts / keyboard.ts +│ ├── types/ # index.ts(Platform/Theme/Edition)/ logging.ts +│ └── utils/ # sanitize.ts(日志脱敏)+ index.ts(通用工具) +│ +├── scripts/ # 构建与发布脚本 +├── vite.config.ts # Vite 构建配置 +├── package.json # 含 electron-builder 打包配置 └── pnpm-workspace.yaml ``` +### 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+M,auth 用 V+C+H+S+M,canvas 额外有 stores/ + utils/。 + # 多窗口与平台判定 - 窗口管理由 `electron/main.ts` 统一负责:`createMainWindow()` 创建主窗口,`BrowserWindow` 通过 `contextIsolation: true` + `nodeIntegration: false` 运行沙箱化渲染进程。辅助窗口通过 `RENDERER_TO_MAIN.OPEN_SUB_WINDOW` IPC 通道按需创建。 -- 平台判定双端各有一份:`electron/main/utils/platform.ts`(主进程,直接使用 `process.platform`)和 `src/utils/platform.ts`(渲染进程,优先 `process.platform`,降级 `navigator.userAgent`)。 +- 平台判定双端各有一份:`electron/main/utils/platform.ts`(主进程,直接使用 `process.platform`)和 `src/shared/utils/env.ts`(渲染进程,优先 `process.platform`,降级 `navigator.userAgent`)。 - 打包配置 (`package.json` build 字段) 须针对平台设置不同的图标、签名策略、安装包格式。 @@ -238,7 +204,7 @@ ## 状态管理 - 使用 React Context + Provider 模式管理全局状态,按领域拆分:AppContext(平台/登录)、ThemeContext(主题)、 - SettingsContext(设置项)。各 Context 定义与 Provider 合并在 `components/providers/` 目录下,避免引入 Redux 等重量级方案。 + SettingsContext(设置项)。各 Context 定义与 Provider 合并在 `app/providers/` 目录下,避免引入 Redux 等重量级方案。 - Provider 在 main.tsx 中按层级嵌套挂载: AppProvider → ThemeProvider → SettingsProvider → AntdApp → App @@ -249,7 +215,7 @@ ## 日志系统 -- 双端日志:主进程直接写文件(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?)`。 - 文件存储:JSON Lines 格式(每行一个 JSON),路径 `{userData}/logs/app-YYYY-MM-DD.log`, @@ -275,9 +241,9 @@ - Linux → libsecret - 实现文件: - `electron/main/safe-storage-ipc.ts` — 主进程加密/解密 IPC 处理(`BIDIRECTIONAL.SAFESTORAGE_ENCRYPT / DECRYPT`) - - `src/utils/safe-storage.ts` — 渲染进程桥接层(`initSafeStore / getSafeStore / setSafeStore / clearSafeStore`) - - `src/services/request.ts` — token 存取通过 `getSafeStore / setSafeStore`,无需感知加密细节 - - `src/services/auth-token.ts` — refresh_token 同理通过 safeStorage 存取 + - `src/shared/utils/safe-storage.ts` — 渲染进程桥接层(`initSafeStore / getSafeStore / setSafeStore / clearSafeStore`) + - `src/shared/services/http.ts` — token 存取通过 `getSafeStore / setSafeStore`,无需感知加密细节 + - `src/modules/auth/controllers/token-manager.ts` — refresh_token 同理通过 safeStorage 存取 ## 双 Token 机制 @@ -293,15 +259,15 @@ - `shared/utils/sanitize.ts` 在日志写入前自动脱敏处理 - 覆盖字段:用户 ID(SHA256 哈希)、邮箱(`***@domain`)、手机号(掩码)、用户名称、Token(截断) -- 主进程 `electron/main/logger.ts` 和渲染进程 `src/utils/logger.ts` 均写入前调用 `sanitizeLogEntry()` +- 主进程 `electron/main/logger.ts` 和渲染进程 `src/shared/utils/bus.ts` 均写入前调用 `sanitizeLogEntry()` # 样式与主题 -- 优先使用 Tailwind 工具类完成布局与常规样式。自定义设计令牌在 `src/theme/tokens.css` 的 `@theme` 块中定义(Tailwind v4 语法),自动生成 `bg-primary-500`、`text-secondary` 等工具类。 +- 优先使用 Tailwind 工具类完成布局与常规样式。自定义设计令牌在 `src/shared/theme/tokens.css` 的 `@theme` 块中定义(Tailwind v4 语法),自动生成 `bg-primary-500`、`text-secondary` 等工具类。 - Ant Design 组件样式通过 ConfigProvider 的 theme 属性覆盖,确保与 Tailwind 主题令牌同步。 `ConfigProvider` 同时配置 `locale={zhCN}` 实现全局中文(分页器"条/页"等无需逐个组件设置)。 - 在 src/theme/ 中创建 antd-theme.ts,导出主题配置对象,引用 tokens.ts 中的设计变量。 + 在 src/shared/theme/ 中创建 antd-theme.ts,导出主题配置对象,引用 tokens.ts 中的设计变量。 组件级 token(如 Pagination.itemActiveColor / itemActiveBg)在 sharedComponents 中统一管理, 亮色/暗色主题共享,避免 CSS !important hack。 @@ -314,7 +280,7 @@ - 所有 IPC 通道名称定义在 shared/constants/ipc-channels.ts 中,分为三类: MAIN_TO_RENDERER / RENDERER_TO_MAIN / BIDIRECTIONAL。 -- 渲染进程通过 src/utils/ipc.ts 中的安全守卫访问 IPC(hasIpc 检查 → safeIpcOn / send / invoke), +- 渲染进程通过 src/shared/utils/bus.ts 中的安全守卫访问 IPC(hasIpc 检查 → safeIpcOn / send / invoke), 非 Electron 环境下静默跳过,保证浏览器预览不崩溃。 - 主进程事件处理函数需做好错误捕获与日志记录,渲染进程调用 IPC 时使用 try-catch 并处理超时。 @@ -355,8 +321,8 @@ | `checkForUpdates` | `electron/main/updater.ts` | 预检版本 → 无更新直发 IPC / 有更新交 electron-updater | | `downloadUpdate` | `electron/main/updater.ts` | 用户确认后触发下载(从 checkForUpdates 分离) | | `netRequest` | `electron/main/net-request.ts` | Electron net.request 的 axios 风格封装 | -| `useUpdater` | `src/hooks/use-updater.ts` | 渲染进程更新状态 Hook(单例 IPC 监听) | -| `UpdateSetting` | `src/pages/settings/blocks/UpdateSetting.tsx` | 设置页更新区块(含"查看详情"弹窗) | +| `useUpdater` | `src/shared/hooks/use-updater.ts` | 渲染进程更新状态 Hook(单例 IPC 监听) | +| `UpdateSetting` | `src/modules/settings/views/sections/UpdateSetting.tsx` | 设置页更新区块(含"查看详情"弹窗) | | `server.py` | `test-server/server.py` | 本地测试服务器,扫描 release/ 目录,语义版本排序 | ## 增量更新(差分下载) diff --git a/README.md b/README.md index 87293e9..690acfb 100644 --- a/README.md +++ b/README.md @@ -37,52 +37,60 @@ npm run build:win ### 打包 ```bash -npm run build:win # Windows 个人版 +npm run build:win # Windows 个人版 Stable 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:linux # Linux 个人版 ``` -### 构建总管 `build.mjs` +### 构建总管 `build/build.mjs` -所有打包命令最终调用 `build.mjs`,支持灵活组合操作: +所有打包命令最终调用 `build/build.mjs`,支持灵活组合操作: ```bash # 查看帮助 -node build.mjs --help +node build/build.mjs --help # 仅打包 -node build.mjs --win --personal +node build/build.mjs --win # 打包 + 上传 OSS + 发布到 API -node build.mjs --win --personal --upload --publish +node build/build.mjs --win --upload --publish -# 全流程(清理 → 构建 → 签名 → 上传 → 发布) -node build.mjs --win --enterprise --all +# Beta 渠道全流程(清理 → 构建 → 签名 → 上传 → 发布) +node build/build.mjs --win --channel beta --all # 带签名打包 -node build.mjs --win --personal --sign --cert ./cert.pfx --cert-password xxx - -# 先清理再构建 -node build.mjs --win --personal --clean --build +node build/build.mjs --win --sign --cert ./cert.pfx --cert-password xxx ``` -| 操作 | 说明 | -|-------------|---------------------------------------------------------| -| `--build` | 构建(默认:tsc + vite + electron-builder + update-info.json) | -| `--clean` | 构建前清理 release/ dist/ dist-electron/ | -| `--sign` | 代码签名(需配合 `--cert` 或 `CSC_LINK` 环境变量) | -| `--upload` | 上传安装包到 OSS | -| `--publish` | POST 版本信息到后端 API | -| `--all` | 全流程 | +| 操作 | 说明 | +|-------------|------------------------------------------------------------| +| `--build` | 构建(校验 → tsc → vite → electron-builder → update-info.json) | +| `--clean` | 构建前清理 release/ dist/ dist-electron/ | +| `--sign` | 代码签名(需配合 `--cert` 或 `CSC_LINK` 环境变量) | +| `--upload` | 上传安装包到 OSS | +| `--publish` | POST 版本信息到后端 API | +| `--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 npm run build:clean # 清理所有构建产物 -node scripts/upload-oss.mjs win32 personal 0.1.0 # 手动上传 OSS -node scripts/publish-release.mjs win32 personal 0.1.0 # 手动发布到 API -node scripts/generate-update-info.mjs win32 personal 0.1.0 # 重新生成 info +node build/scripts/upload-oss.mjs win32 0.1.0 # 手动上传 OSS +node build/scripts/publish-release.mjs win32 0.1.0 # 手动发布到 API +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/ -├ build.mjs # 构建总管 -├ CHANGELOG.md # 更新日志 -├ UpdateA.md # 发布手册(后端 API / 签名 / 发版流程) -├ package.json # 项目配置 -├ electron-builder.json5 # 打包配置 -├ electron/ # Electron 主进程 -│ ├ main.ts # 入口 -│ ├ preload.ts # 预加载脚本 -│ └ main/ -│ ├ updater.ts # 自动更新模块 -│ ├ menu/ # 菜单 -│ └ utils/ # 工具函数 -├ src/ # 渲染进程 -│ ├ App.tsx # 根组件 -│ ├ main.tsx # 渲染入口 -│ ├ components/ # 组件 -│ │ ├ navbar/ # 自定义导航栏 -│ │ └ LegalTextModal.tsx # 协议弹窗 -│ ├ pages/ -│ │ └ login/ # 登录 / 注册 -│ ├ hooks/ # Hooks -│ │ ├ use-theme.tsx # 主题切换 -│ │ ├ use-updater.ts # 更新状态 -│ │ └ ... -│ ├ theme/ # 主题配置 -│ │ ├ tokens.ts # 设计令牌 -│ │ ├ globals.css # Tailwind + CSS 变量 -│ │ └ adapters/ # Ant Design 适配 -│ ├ services/ # API 服务(axios) -│ ├ utils/ # 工具函数 -│ └ assets/ # 静态资源 -├ shared/ # 主进程 & 渲染进程共享 -│ ├ types/ # 类型定义 -│ ├ constants/ # 常量 -│ └ utils/ # 通用工具 -├ scripts/ # 构建辅助脚本 -│ ├ clean.mjs # 清理产物 -│ ├ generate-update-info.mjs # 生成 API 信息 JSON -│ ├ upload-oss.mjs # OSS 上传 -│ └ publish-release.mjs # 发布到 API -└ release/ # 构建产物 - └ {version}/ - ├ xxx-Setup.exe - └ xxx-update-info.json +├── build/ # 构建、打包与分发系统 +│ ├── build.mjs # 构建总管 +│ ├── electron-builder.js # electron-builder 配置 +│ └── scripts/ # 构建辅助脚本(6 个) +├── CHANGELOG.md # 更新日志 +├── UpdateA.md # 发布手册 +├── package.json # 项目配置 +│ +├── electron/ # Electron 主进程 +│ ├── main.ts # 入口:窗口创建 / IPC 注册 +│ ├── preload.ts # 预加载脚本 +│ └── main/ +│ ├── logger.ts # 日志核心 +│ ├── updater.ts # 自动更新 +│ ├── net-request.ts # HTTP 请求封装 +│ ├── menu/ # 系统菜单 +│ ├── ipc/ # IPC handler(log/safe-storage/storage/canvas) +│ └── utils/ # 平台判断 / 图标 +│ +├── src/ # 渲染进程(React 19 + Vite) +│ ├── app/ # 应用壳 +│ │ ├── App.tsx # 根组件 +│ │ ├── main.tsx # 渲染入口 +│ │ ├── providers/ # 全局 Provider(App/Theme/Settings) +│ │ └── layout/ # Layout.tsx + PageDispatcher.tsx +│ ├── modules/ # 7 个业务模块(VCHSM 五层架构) +│ │ ├── navbar/ # 导航栏 UI 壳 +│ │ ├── announcement/ # 公告(Badge + 轮询缓存) +│ │ ├── auth/ # 认证(登录/注册/密码/Token) +│ │ ├── home/ # 主工作区(左/中/右 三栏) +│ │ ├── account/ # 账户与支付(6 子功能) +│ │ ├── canvas/ # 无限画布 + 项目管理 +│ │ └── settings/ # 应用设置 +│ └── shared/ # 跨模块共享 +│ ├── components/ # 通用 UI +│ ├── hooks/ # 全局 Hooks +│ ├── services/ # HTTP 客户端 +│ ├── utils/ # 工具函数(bus/env/...) +│ ├── infrastructure/ # 本地加密数据库 +│ └── theme/ # 主题系统 +│ +├── shared/ # 主进程 & 渲染进程共享(@shared 别名) +│ ├── constants/ # IPC 通道 / 应用常量 +│ ├── types/ # Platform / Theme / Logging +│ └── utils/ # 脱敏 / 工具函数 +│ +├── release/ # 构建产物 +└── build/ # 构建系统(见上) ``` +> 详见 [PROJECT.md](./PROJECT.md) — 完整架构文档、VCHSM 五层说明及各模块文件清单。 + --- ## 环境变量 diff --git a/TODO.md b/TODO.md index 5f2f3b9..2636735 100644 --- a/TODO.md +++ b/TODO.md @@ -1,11 +1,15 @@ # TODO — 船长·HeiXiu 待办与优化清单 -> 最后更新:2026-06-22 +> 最后更新:2026-06-29 --- ## ✅ 近期完成 +- **VCHSM 五层模块化架构重构**:`pages/` + `services/modules/` 分散模式 → `modules/` 统一(7 个模块,每个独立 views/controllers/hooks/settings/model) +- **公告 Badge 角标 + 轮询缓存**:antd Badge + useAnnouncements Hook(15 分钟轮询 + localStorage 已读缓存) +- **日志全量审计**:~60+ 处 try/catch 补充 logger.warn,3 个 IPC handler try/catch 包裹 +- **存储路径可配置**:`setLogDirResolver()` / `setDataDirResolver()` 函数注入 - **构建时 EDITION 变量已移除**:版控全部收敛到后端 `account_type` 运行时判断,不再区分个人版/企业版构建产物 - **窗口标题动态版控**:登录后通过 IPC 追加 `·个人版`/`·企业版` 后缀 - **退出登录不再自动清空本地缓存**:`user_id` 隔离 + 手动清理按钮 @@ -51,9 +55,9 @@ ### 公告 - [x] 公告数据获取与展示(AnnouncementDrawer + fetchAnnouncements API) -- [ ] 公告已读/未读状态 -- [ ] 公告红点/角标提醒(有新公告时导航栏图标变化) -- [ ] 公告缓存(减少重复请求) +- [x] 公告已读/未读状态(localStorage 缓存已读 ID Set) +- [x] 公告 Badge 角标提醒(antd Badge + NavItem badgeCount prop) +- [x] 公告缓存 + 轮询(useAnnouncements Hook,15 分钟 setInterval 对比) ### 任务 @@ -105,10 +109,9 @@ 1. **任务列表导出 CSV**(`task.ts` 已定义 `exportTaskCSV` API,前端未对接下载按钮) 2. **文件上传端点确认**(`/api/v1/upload` 待后端确认,当前 useFileUpload 已完成前端集成) -3. **合规素材库页面**(功能待完善 — 按 QT 原版设计,以 Modal/Drawer 叠加而非路由跳转实现) -4. **各页面 nav 导航补齐**(后续页面优先使用叠加模式,遵循 QT 多窗口模型 → 见 WORKLOG 2026-06-07) -5. **leftWidth / rightWidth / taskPage 加 localStorage 持久化**(UX 优化:刷新页面后保持用户偏好;已通过路由去路由化解决组件卸载问题,但 +3. **各页面 nav 导航补齐**(后续页面优先使用叠加模式,遵循 QT 多窗口模型 → 见 WORKLOG 2026-06-07) +4. **leftWidth / rightWidth / taskPage 加 localStorage 持久化**(UX 优化:刷新页面后保持用户偏好;已通过路由去路由化解决组件卸载问题,但 localStorage 持久层仍未实现) -6. **轮播图关闭按钮 localStorage 持久化**(关闭按钮已实现,待做:localStorage 记录关闭时间戳,超过 N 天再显示) -7. **短信发送接入人机验证**(后期优化,需后端配合:`sendSms` 接口增加 `captcha_token` 参数 + 前端滑块/图形验证组件。当前仅靠 +5. **轮播图关闭按钮 localStorage 持久化**(关闭按钮已实现,待做:localStorage 记录关闭时间戳,超过 N 天再显示) +6. **短信发送接入人机验证**(后期优化,需后端配合:`sendSms` 接口增加 `captcha_token` 参数 + 前端滑块/图形验证组件。当前仅靠 60s 冷却按钮 + 后端限流,桌面端 ASAR 可解包 + DevTools 网络面板 + 代理抓包均能直接暴露 API,纯前端限制对脚本攻击无效) diff --git a/UpdateA.md b/UpdateA.md index b645b7b..3f7d6d5 100644 --- a/UpdateA.md +++ b/UpdateA.md @@ -38,63 +38,107 @@ ## 二、项目脚本速查 -### 总入口:`build.mjs` +### 总入口:`build/build.mjs` ```bash -node build.mjs --help # 查看完整帮助 -npm run build:win # 打包 Windows 个人版(快捷命令) +node build/build.mjs --help # 查看完整帮助 +npm run build:win # 打包 Windows 个人版 Stable(快捷命令) ``` ### npm 命令速查 ```bash # 构建(仅打包,不上传/发布) -npm run build:win # Windows 个人版 -npm run build:win:enterprise # Windows 企业版 -npm run build:mac # macOS 个人版 -npm run build:linux # Linux 个人版 +npm run build:win # Windows 个人版 Stable +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:enterprise # macOS 企业版 +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/ -node build.mjs --win --personal --help # 查看完整 CLI 指南 +npm run build:clean # 清理 release/ + dist/ + dist-electron/ # 独立脚本(需传参数) -node scripts/generate-update-info.mjs <平台> <版本类型> [版本号] [--url ] -node scripts/upload-oss.mjs <平台> <版本类型> [版本号] -node scripts/publish-release.mjs <平台> <版本类型> [版本号] [--url ] +node build/scripts/generate-update-info.mjs <平台> <版本> [渠道] [版本类型] [ossPrefix] +node build/scripts/upload-oss.mjs <平台> <版本> [渠道] [ossPrefix] +node build/scripts/publish-release.mjs <平台> <版本> [渠道] [版本类型] [ossPrefix] ``` -### `build.mjs` 操作组合 +### `build/build.mjs` 操作组合 -| 操作 | 说明 | -|-------------|------------------------------------------------------| -| `--build` | 构建(tsc + vite + electron-builder + update-info.json) | -| `--clean` | 清理旧产物 | -| `--sign` | 代码签名(配合 `--cert` 或环境变量) | -| `--upload` | 上传 OSS | -| `--publish` | POST 版本信息到后端 API | -| `--all` | 全流程 = clean + build + sign + upload + publish | +| 操作 | 说明 | +|-------------|------------------------------------------------------------| +| `--build` | 构建(校验 → tsc → vite → electron-builder → update-info.json) | +| `--clean` | 清理旧产物 | +| `--sign` | 代码签名(配合 `--cert` 或环境变量) | +| `--upload` | 上传 OSS | +| `--publish` | POST 版本信息到后端 API | +| `--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:快速打包(日常开发 / 内部测试) ```bash -# 1. 修改版本号 -# package.json → "version": "0.1.0" +# 1. 升级版本号 +npm run version:patch # package.json + CHANGELOG.md 自动更新 # 2. 写更新日志 -# CHANGELOG.md → 顶部新增 ## 0.1.0(2026-xx-xx)... +# CHANGELOG.md → 编辑自动生成的 ## 0.1.0(日期)条目 # 3. 打包 -npm run build:win && npm run build:win:enterprise +npm run build:win # 4. 产物在 release/0.1.0/ 下: # - xxx-Setup.exe(安装包) # - xxx-Setup.exe.blockmap(块映射,增量更新用) -# - xxx-update-info.json(所有 API 字段,downloadUrl 为预判 URL) +# - xxx-update-info.json(channel、SHA512、blockmap 等全字段) # 5. 手动上传 exe + .blockmap 到 OSS releases/0.1.0/ # 6. 将 update-info.json 内容录入数据库 releases 表 @@ -107,10 +151,10 @@ npm run build:win && npm run build:win:enterprise npm run build:win # 上传到 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 -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" # 将 update-info.json 录入数据库 @@ -125,12 +169,12 @@ OSS_BUCKET=heixiu \ OSS_ACCESS_KEY_ID=xxx \ OSS_ACCESS_KEY_SECRET=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 # 方式 1:build.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 友好) 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` | 客户端版本检查,返回最新版本信息(含 blockmap 用于增量更新) | -| `POST /api/v1/releases` | 前端构建脚本发布新版本,需 Bearer Token 鉴权 | +### GET /api/v1/update/check + +客户端版本检查,请求参数: + +| 参数 | 类型 | 说明 | +|------|------|------| +| `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 ` 鉴权。请求体包含 `channel`、`edition` 等字段。 --- @@ -239,22 +296,28 @@ cd test-server && python server.py --release-dir ../release --port 8000 ``` ele-heixiu/ -├ build.mjs ← 构建总管(npm run build 入口) -├ CHANGELOG.md ← 更新日志(打包时自动提取) -├ UpdateA.md ← 本文档 -├ package.json ← 仅保留简短的 npm 快捷命令 -├ scripts/ -│ ├ clean.mjs ← 清理产物 -│ ├ generate-update-info.mjs ← 生成 API 信息 JSON(含 blockmap 信息) -│ ├ upload-oss.mjs ← 上传 OSS(安装包 + blockmap) -│ └ publish-release.mjs ← 发布到 API(axios) -├ release/ ← 构建产物目录(按版本分) -│ └ 0.1.0/ +├ 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.json(SHA512 + blockmap + channel) +│ ├ upload-oss.mjs ← 上传 OSS(安装包 + blockmap) +│ ├ publish-release.mjs ← 发布到 API(axios) +│ └ bump-version.mjs ← 版本号管理(major/minor/patch/pre) +├ CHANGELOG.md ← 更新日志(打包时自动提取) +├ UpdateA.md ← 本文档 +├ package.json ← 不含 "build" 字段(已提取到 build/electron-builder.js) +├ release/ ← 构建产物目录 +│ ├ .build-meta.json ← 最近一次构建元数据 +│ └ 0.1.0/ ← Stable 版本 │ ├ xxx-Setup.exe -│ ├ xxx-Setup.exe.blockmap ← 差分更新块映射文件 +│ ├ xxx-Setup.exe.blockmap ← 差分更新块映射文件 │ └ xxx-update-info.json -├ dist/ ← Vite 前端产物(构建输入) -└ dist-electron/ ← Electron 主进程产物(构建输入) +├ dist/ ← Vite 前端产物 +└ dist-electron/ ← Electron 主进程产物 ``` --- diff --git a/build.mjs b/build.mjs deleted file mode 100644 index f35f8a4..0000000 --- a/build.mjs +++ /dev/null @@ -1,300 +0,0 @@ -// ============================================================ -// build.mjs — 构建总管 -// -// 用法:node build.mjs [选项] -// -// 平台(必选其一): -// --win Windows -// --mac macOS -// --linux Linux -// -// 操作(可选组合,默认只执行 --build): -// --build 仅构建(默认) -// --clean 构建前清理 release/ dist/ dist-electron/ -// --sign 代码签名 -// --upload 上传安装包到 OSS -// --publish 发布版本信息到后端 API -// --all 全流程(clean + build + sign + upload + publish) -// -// 签名(仅 --sign 时可用): -// --cert 证书文件路径 (.pfx) -// --cert-password

证书密码 -// -// 示例: -// node build.mjs --win # 仅打包 -// node build.mjs --win --upload --publish # 打包 + 上传 + 发布 -// node build.mjs --win --all # 全流程 -// node build.mjs --win --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, - 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 '--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); -} - -// 默认执行 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 = 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'; - - console.log('┌──────────────────────────────────────┐'); - console.log(`│ HeiXiu 构建 v${version} ${platform}`.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']); - - // 2b. Vite 构建 - console.log('[vite] 打包前端 + Electron...'); - await run('npx', ['vite', 'build']); - - // 2c. electron-builder 打包 - console.log(`[electron-builder] ${electronBuilderPlatform} 打包...`); - await run('npx', ['electron-builder', electronBuilderPlatform], { - env: { ...signEnv }, - }); - - // 2d. 生成 update-info.json - console.log('[info] 生成 update-info.json...'); - await runScript('generate-update-info', platform, version); - } - - // ── Step 3: 上传 OSS ── - if (opts.upload) { - step('3/ 上传 OSS'); - await runScript('upload-oss', platform, version); - } - - // ── Step 4: 发布到 API ── - if (opts.publish) { - step('4/ 发布版本到 API'); - await runScript('publish-release', platform, 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('操作(可选组合,不指定则默认 --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 ', '证书文件路径(.pfx / .p12)'); - row('--cert-password

', '证书密码'); - console.log(` ${dim('也可通过 CSC_LINK / CSC_KEY_PASSWORD 环境变量设置')}`); - - // 示例 - box('示例'); - console.log(` ${dim('# 日常构建')}`); - console.log(` ${bold('node build.mjs --win')}`); - console.log(` ${bold('npm run build:win')} ${dim('— 等效')}`); - console.log(''); - console.log(` ${dim('# 打包 + 上传 OSS + 发布到 API')}`); - console.log(` ${bold('node build.mjs --win --upload --publish')}`); - console.log(''); - console.log(` ${dim('# 全流程(清理 → 构建 → 签名 → 上传 → 发布)')}`); - console.log(` ${bold('node build.mjs --win --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 --build')}`); - console.log(` ${bold('npm run build:mac')} ${dim('→ node build.mjs --mac --build')}`); - console.log(` ${bold('npm run build:linux')} ${dim('→ node build.mjs --linux --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); -}); diff --git a/build/build.mjs b/build/build.mjs new file mode 100644 index 0000000..b78acb3 --- /dev/null +++ b/build/build.mjs @@ -0,0 +1,431 @@ +// ============================================================ +// build.mjs — 构建总管 +// +// 用法:node build/build.mjs [选项] +// +// 平台(必选其一): +// --win Windows +// --mac macOS +// --linux Linux +// +// 版本 & 渠道: +// --edition 版本类型:personal(默认)| enterprise +// --channel 发布渠道:stable(默认)| beta | alpha +// --version 覆盖 package.json 中的版本号 +// +// 操作(可选组合,默认只执行 --build): +// --build 仅构建(默认) +// --clean 构建前清理 release/ dist/ dist-electron/ +// --sign 代码签名 +// --upload 上传安装包到 OSS +// --publish 发布版本信息到后端 API +// --all 全流程(clean + build + sign + upload + publish) +// +// 签名(仅 --sign 时可用): +// --cert 证书文件路径 (.pfx) +// --cert-password

证书密码 +// +// 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)'); + row('--channel ', '发布渠道(默认 stable)'); + row('--version ', '覆盖 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 ', '证书文件路径(.pfx / .p12)'); + row('--cert-password

', '证书密码'); + 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); +}); diff --git a/build/electron-builder.js b/build/electron-builder.js new file mode 100644 index 0000000..3535c26 --- /dev/null +++ b/build/electron-builder.js @@ -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; diff --git a/build/scripts/bump-version.mjs b/build/scripts/bump-version.mjs new file mode 100644 index 0000000..461366f --- /dev/null +++ b/build/scripts/bump-version.mjs @@ -0,0 +1,153 @@ +// ============================================================ +// bump-version.mjs — 版本号管理与 CHANGELOG 模板生成 +// +// 用法: +// node build/scripts/bump-version.mjs [--no-changelog] +// node build/scripts/bump-version.mjs set +// node build/scripts/bump-version.mjs pre +// +// 示例: +// 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 [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 '); + 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 '); + 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 | pre `); + 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}`); diff --git a/scripts/clean.mjs b/build/scripts/clean.mjs similarity index 94% rename from scripts/clean.mjs rename to build/scripts/clean.mjs index d693753..2f29c6e 100644 --- a/scripts/clean.mjs +++ b/build/scripts/clean.mjs @@ -8,7 +8,7 @@ import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = join(__dirname, '..'); +const ROOT = join(__dirname, '..', '..'); const dirs = ['release', 'dist', 'dist-electron']; diff --git a/scripts/generate-update-info.mjs b/build/scripts/generate-update-info.mjs similarity index 68% rename from scripts/generate-update-info.mjs rename to build/scripts/generate-update-info.mjs index e1b2dfe..d359494 100644 --- a/scripts/generate-update-info.mjs +++ b/build/scripts/generate-update-info.mjs @@ -1,18 +1,14 @@ // ============================================================ -// 打包后自动生成 update-info.json +// 打包后生成 update-info.json(含渠道/版本类型/SHA512/blockmap) // -// 用法: -// node scripts/generate-update-info.mjs [version] [--url ] +// 用法(由 build.mjs 调用): +// node build/scripts/generate-update-info.mjs // -// 两种用法: -// 方式 A(预判 URL):打包后自动拼接 URL → 适用于 OSS 路径可预测时 -// node scripts/generate-update-info.mjs win32 -// -// 方式 B(指定 URL):上传后用实际 URL 重新生成 → 适用于上传后才知道 URL 时 -// node scripts/generate-update-info.mjs win32 0.1.0 --url "https://oss.../xxx.exe" +// 也支持手动调用: +// node build/scripts/generate-update-info.mjs [channel] [edition] [ossPrefix] [releaseDirName] [--url ] [--blockmap-url ] // // 环境变量(可选): -// UPDATE_DOWNLOAD_BASE — 方式 A 的 URL 基地址,默认 https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases +// UPDATE_DOWNLOAD_BASE — OSS 下载基地址 // UPDATE_FORCE — 是否强制更新,默认 false // UPDATE_MIN_VERSION — 最低兼容版本,默认 0.0.0 // UPDATE_CHANGELOG — 覆盖 CHANGELOG.md 中的日志内容 @@ -24,13 +20,12 @@ import { fileURLToPath } from 'node:url'; import { createHash } from 'node:crypto'; const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = join(__dirname, '..'); +const ROOT = join(__dirname, '..', '..'); // ---------- 参数解析 ---------- const args = process.argv.slice(2); -// 提取 --url 和 --blockmap-url 参数 let urlOverride = ''; let blockmapUrlOverride = ''; const positional = []; @@ -45,26 +40,26 @@ for (let i = 0; i < args.length; i++) { } const platform = positional[0]; +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) { - console.error('用法: node scripts/generate-update-info.mjs [version] [--url ]'); - console.error('示例: node scripts/generate-update-info.mjs win32'); - console.error(' node scripts/generate-update-info.mjs win32 0.1.0 --url "https://..."'); +if (!platform || !version) { + console.error('用法: node build/scripts/generate-update-info.mjs [channel] [edition] [ossPrefix] [releaseDirName]'); process.exit(1); } -const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')); -const version = positional[1] || pkg.version; -const productName = pkg.build?.productName || '船长·HeiXiu'; +const productName = '船长·HeiXiu'; // ---------- 查找安装包 ---------- -const releaseDir = join(ROOT, 'release', version); +const releaseDir = join(ROOT, 'release', releaseDirName); let installerPath; try { const files = readdirSync(releaseDir); - // 按扩展名找安装包 const exts = platform === 'win32' ? ['.exe'] : platform === 'darwin' ? ['.dmg'] : ['.AppImage']; const match = files.find((f) => exts.some((ext) => f.endsWith(ext))); if (!match) { @@ -83,7 +78,6 @@ try { const fileBuffer = readFileSync(installerPath); const stats = statSync(installerPath); -// SHA512(Base64 编码,与 certutil 输出一致) const sha512 = createHash('sha512').update(fileBuffer).digest('base64').replace(/\r?\n/g, ''); const fileSize = stats.size; @@ -103,19 +97,16 @@ try { blockMapSize = blockmapStats.size; if (blockmapUrlOverride) { - // 方式 B:使用指定的 blockmap URL blockMapUrl = blockmapUrlOverride; } else { - // 方式 A:根据基地址 + 版本号 + 文件名拼接 - const downloadBase = process.env.UPDATE_DOWNLOAD_BASE || 'https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases'; - blockMapUrl = `${downloadBase.replace(/\/+$/, '')}/${version}/${encodeURI(blockmapFile)}`; + const downloadBase = process.env.UPDATE_DOWNLOAD_BASE || 'https://heixiu.oss-cn-hangzhou.aliyuncs.com'; + blockMapUrl = `${downloadBase.replace(/\/+$/, '')}/${ossPrefix}/${encodeURI(blockmapFile)}`; } console.log(`Blockmap: ${blockmapFile} (${(blockMapSize / 1024).toFixed(2)} KB)`); console.log(`Blockmap URL: ${blockMapUrl}`); } } catch { - // blockmap 不存在不影响主流程 blockMapUrl = undefined; blockMapSize = undefined; } @@ -126,13 +117,11 @@ const fileName = installerPath.split(/[/\\]/).pop(); let downloadUrl; if (urlOverride) { - // 方式 B:使用指定的 URL downloadUrl = urlOverride; console.log(`下载 URL(手动指定): ${downloadUrl}`); } else { - // 方式 A:根据基地址 + 版本号 + 文件名拼接(预判 URL) - const downloadBase = process.env.UPDATE_DOWNLOAD_BASE || 'https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases'; - downloadUrl = `${downloadBase.replace(/\/+$/, '')}/${version}/${encodeURI(fileName)}`; + const downloadBase = process.env.UPDATE_DOWNLOAD_BASE || 'https://heixiu.oss-cn-hangzhou.aliyuncs.com'; + downloadUrl = `${downloadBase.replace(/\/+$/, '')}/${ossPrefix}/${encodeURI(fileName)}`; console.log(`下载 URL(自动拼接): ${downloadUrl}`); } @@ -140,23 +129,23 @@ if (urlOverride) { let changelog; if (process.env.UPDATE_CHANGELOG) { - // 环境变量覆盖(适用于 CI/CD 流水线) changelog = process.env.UPDATE_CHANGELOG; } else { - // 从 CHANGELOG.md 提取最新版本的日志 const changelogPath = join(ROOT, 'CHANGELOG.md'); try { const changelogContent = readFileSync(changelogPath, 'utf-8'); - // 匹配最新版本块:## x.y.z(日期) ... ## 或 EOF + // 精确匹配当前版本块 + const escapedVersion = version.replace(/\./g, '\\.'); 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) { changelog = versionSection[0].trim(); + console.log(`更新日志: 已从 CHANGELOG.md 提取 v${version} 条目`); } else { changelog = `## ${version}\n\n更新内容待补充`; + console.warn(`⚠️ CHANGELOG.md 中未找到 v${version} 条目,使用占位内容`); } - console.log(`更新日志: 已从 CHANGELOG.md 提取`); } catch { changelog = `## ${version}\n\n更新内容待补充`; } @@ -182,14 +171,21 @@ const updateInfo = { forceUpdate, minimumVersion, + // --- 渠道与版本类型(灰度测试关键字段)--- + channel, + edition, + // --- 增量更新 --- ...(blockMapUrl && { blockMapUrl }), ...(blockMapSize != null && { blockMapSize }), - // --- 附加:方便后端录入 --- + // --- 附加元数据 --- _meta: { platform, + channel, + edition, fileName, + ossPrefix, blockmap: blockMapUrl ? { url: blockMapUrl, size: blockMapSize } : null, generatedAt: new Date().toISOString(), }, diff --git a/build/scripts/pre-build-check.mjs b/build/scripts/pre-build-check.mjs new file mode 100644 index 0000000..08a4246 --- /dev/null +++ b/build/scripts/pre-build-check.mjs @@ -0,0 +1,135 @@ +// ============================================================ +// pre-build-check.mjs — 构建前校验 +// +// 用法: +// node build/scripts/pre-build-check.mjs +// +// 检查项: +// 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 [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 \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('✅ 构建前校验全部通过'); diff --git a/scripts/publish-release.mjs b/build/scripts/publish-release.mjs similarity index 71% rename from scripts/publish-release.mjs rename to build/scripts/publish-release.mjs index e8dada1..4723e56 100644 --- a/scripts/publish-release.mjs +++ b/build/scripts/publish-release.mjs @@ -1,8 +1,11 @@ // ============================================================ // 发布版本信息到后端 API // -// 用法: -// node scripts/publish-release.mjs [version] [--url ] +// 用法(由 build.mjs 调用): +// node build/scripts/publish-release.mjs +// +// 也支持手动调用: +// node build/scripts/publish-release.mjs [channel] [edition] [ossPrefix] [--url ] [--json ] // // 环境变量: // UPDATE_API_URL — API 基地址(默认 https://www.heixiu.com) @@ -15,7 +18,7 @@ import { fileURLToPath } from 'node:url'; import axios from 'axios'; const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = join(__dirname, '..'); +const ROOT = join(__dirname, '..', '..'); // ---------- 参数 ---------- @@ -35,16 +38,17 @@ for (let i = 0; i < args.length; i++) { } const platform = positional[0]; +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) { - console.error('用法: node scripts/publish-release.mjs [version] [--url ]'); - console.error('示例: node scripts/publish-release.mjs win32 0.1.0'); +if (!platform || !version) { + console.error('用法: node build/scripts/publish-release.mjs [channel] [edition] [ossPrefix] [releaseDirName]'); process.exit(1); } -const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')); -const version = positional[1] || pkg.version; - // ---------- 读取 update-info.json ---------- let updateInfo; @@ -57,14 +61,17 @@ if (jsonOverride) { process.exit(1); } } else { - const releaseDir = join(ROOT, 'release', version); + const releaseDir = join(ROOT, 'release', releaseDirName); let infoFile; try { const all = readdirSync(releaseDir); infoFile = all.find((f) => f.includes(platform) && f.endsWith('-update-info.json'), ); - if (!infoFile) console.error(`未找到 ${platform} 的 update-info.json`); + if (!infoFile) { + console.error(`未找到 ${platform} 的 update-info.json(在 ${releaseDir} 中)`); + process.exit(1); + } } catch (err) { console.error(`错误: ${releaseDir} — ${err.message}`); process.exit(1); @@ -80,6 +87,8 @@ const apiKey = process.env.UPDATE_API_KEY || ''; const payload = { platform, + channel, + edition, latestVersion: updateInfo.latestVersion || version, downloadUrl: updateInfo.downloadUrl, fileSize: updateInfo.fileSize, @@ -88,6 +97,15 @@ const payload = { releaseDate: updateInfo.releaseDate, forceUpdate: updateInfo.forceUpdate || false, 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`; @@ -96,7 +114,10 @@ console.log('━━━━━━━━━━━━━━━━━━━━━━ console.log('📡 发布版本到 API'); console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); console.log(`POST ${endpoint}`); -console.log(`版本: ${version} (${platform})`); +console.log(`版本: ${version}`); +console.log(`平台: ${platform}`); +console.log(`渠道: ${channel}`); +console.log(`版本类型: ${edition}`); console.log(`URL: ${payload.downloadUrl}\n`); try { diff --git a/build/scripts/upload-oss.mjs b/build/scripts/upload-oss.mjs new file mode 100644 index 0000000..daa7db1 --- /dev/null +++ b/build/scripts/upload-oss.mjs @@ -0,0 +1,242 @@ +// ============================================================ +// 上传安装包 + blockmap 文件到阿里云 OSS +// +// 用法(由 build.mjs 调用): +// node build/scripts/upload-oss.mjs +// +// 也支持手动调用: +// node build/scripts/upload-oss.mjs [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 [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 || ''; + const exampleEndpoint = OSS_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); +} + +// ============================================================ +// 模式 B:Node.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}"`); +} diff --git a/electron/main.ts b/electron/main.ts index 256119f..161ce35 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -9,10 +9,10 @@ import {buildWindowTitle} from '../shared/constants/app'; import {loadEnvFile} from './main/load-env'; import {initUpdater, registerUpdateIpcHandlers} from './main/updater'; import {setupAppMenu} from './main/menu'; -import {initLogger, flushLogger, logger} from './main/logger'; +import {initLogger, flushLogger, logger, setLogDirResolver} from './main/logger'; import {registerLogIpcHandlers} from './main/ipc/log-ipc'; import {registerSafeStorageIpcHandlers} from './main/ipc/safe-storage-ipc'; -import {registerStorageIpcHandlers} from './main/ipc/storage-ipc'; +import {registerStorageIpcHandlers, setDataDirResolver} from './main/ipc/storage-ipc'; import {registerCanvasIpcHandlers} from './main/ipc/canvas-ipc'; import {BIDIRECTIONAL, RENDERER_TO_MAIN, MAIN_TO_RENDERER} from '../shared/constants/ipc-channels'; @@ -229,16 +229,17 @@ app.on('before-quit', () => { }); app.whenReady().then(() => { + // ── 存储路径配置 ── + // 修改以下两行即可将日志/数据存储到任意位置 + setLogDirResolver(() => path.join(app.getPath('userData'), 'logs')); + setDataDirResolver(() => path.join(app.getPath('userData'), 'heixiu-data')); + initLogger(); registerLogIpcHandlers(); registerSafeStorageIpcHandlers(); registerStorageIpcHandlers(); registerCanvasIpcHandlers(); - // 注册 local-media:// 自定义协议(用于全屏预览加载本地文件) - // 支持两种 URL 格式: - // 本地路径:local-media:///C:/Users/... (authority 为空,路径以 / 开头) - // UNC 路径:local-media://192.168.110.250/share/... (authority 为服务器主机名) protocol.handle('local-media', (request) => { const urlObj = new URL(request.url); let filePath: string; @@ -264,7 +265,7 @@ app.whenReady().then(() => { // 否则会干扰 Chromium 内部的 file:// 协议处理,导致请求失败。 const rangeHeader = request.headers.get('Range'); if (rangeHeader) { - return net.fetch(fileUrl, { headers: { Range: rangeHeader } }); + return net.fetch(fileUrl, {headers: {Range: rangeHeader}}); } return net.fetch(fileUrl); }); @@ -285,51 +286,64 @@ app.whenReady().then(() => { // 文件对话框 IPC 处理(供渲染进程选择文件夹) ipcMain.handle(BIDIRECTIONAL.FILE_DIALOG, async (_event, options: Electron.OpenDialogOptions) => { - const parentWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : canvasWindow; - if (!parentWindow) return {canceled: true, filePaths: []}; - return dialog.showOpenDialog(parentWindow, { - title: options?.title || '选择文件夹', - defaultPath: options?.defaultPath || app.getPath('home'), - properties: options?.properties || ['openDirectory'], - }); + try { + const parentWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : canvasWindow; + if (!parentWindow) return {canceled: true, filePaths: []}; + return dialog.showOpenDialog(parentWindow, { + title: options?.title || '选择文件夹', + defaultPath: options?.defaultPath || app.getPath('home'), + 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) => { - currentEdition = edition; - if (!mainWindow) return; - const title = edition - ? buildWindowTitle(currentPlatform, undefined, edition as 'personal' | 'enterprise') - : buildWindowTitle(currentPlatform); - mainWindow.setTitle(title); + 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 }) => { - const existingAlive = canvasWindow && !canvasWindow.isDestroyed(); - logger.info('canvas-window', '收到 OPEN_CANVAS_WINDOW IPC', { - projectPath: payload?.projectPath || '(未指定)', - existingAlive, - }); + 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); + 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); } }); diff --git a/electron/main/ipc/canvas-ipc.ts b/electron/main/ipc/canvas-ipc.ts index 0154a7c..a3ffca9 100644 --- a/electron/main/ipc/canvas-ipc.ts +++ b/electron/main/ipc/canvas-ipc.ts @@ -13,7 +13,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { BIDIRECTIONAL } from '../../../shared/constants/ipc-channels'; import { logger } from '../logger'; - +import sharp from "sharp"; // ---------- 类型 ---------- type StageType = 'Storyboard' | 'Keyframe' | 'Video' | 'Audio' | 'LipSync' | 'Assets'; @@ -169,14 +169,6 @@ async function generateThumbnailImpl( }; } - // ── 优先级 3:sharp 实时缩放 ── - let sharp: typeof import('sharp') | null = null; - try { - sharp = require('sharp'); - } catch { - /* sharp 未安装 */ - } - if (sharp) { const image = sharp(filePath); const metadata = await image.metadata(); @@ -487,6 +479,7 @@ export function registerCanvasIpcHandlers(): void { 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 }; } }, @@ -500,6 +493,7 @@ export function registerCanvasIpcHandlers(): void { 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 }; } }, @@ -513,6 +507,7 @@ export function registerCanvasIpcHandlers(): void { const result = await generateThumbnailImpl(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 }; } }, @@ -530,6 +525,7 @@ export function registerCanvasIpcHandlers(): void { 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 }; } }, @@ -540,13 +536,6 @@ export function registerCanvasIpcHandlers(): void { BIDIRECTIONAL.CANVAS_GET_IMAGE_SIZE, async (_event, params: { filePath: string }) => { try { - // 尝试 sharp - let sharp: typeof import('sharp') | null = null; - try { - sharp = require('sharp'); - } catch { - /* 未安装 */ - } if (sharp) { const metadata = await sharp(params.filePath).metadata(); return { @@ -559,6 +548,7 @@ export function registerCanvasIpcHandlers(): void { } return { success: false, error: 'sharp 未安装,无法获取图片尺寸' }; } catch (err) { + logger.warn('canvas', '获取图片尺寸失败', err instanceof Error ? err : undefined); return { success: false, error: (err as Error).message }; } }, diff --git a/electron/main/ipc/storage-ipc.ts b/electron/main/ipc/storage-ipc.ts index 3ac9371..7155477 100644 --- a/electron/main/ipc/storage-ipc.ts +++ b/electron/main/ipc/storage-ipc.ts @@ -21,9 +21,16 @@ import { logger } from '../logger'; // ---------- 内部工具 ---------- +let dataDirResolver: (() => string) | null = null; + +/** 注入自定义数据目录解析器(在 registerStorageIpcHandlers() 之前调用) */ +export function setDataDirResolver(resolver: () => string): void { + dataDirResolver = resolver; +} + /** 获取 heixiu-data 根目录 */ function getDataDir(): string { - return path.join(app.getPath('userData'), 'heixiu-data'); + return dataDirResolver ? dataDirResolver() : path.join(app.getPath('userData'), 'heixiu-data'); } /** @@ -143,6 +150,7 @@ export function registerStorageIpcHandlers(): void { 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 }; } }); diff --git a/electron/main/logger.ts b/electron/main/logger.ts index ef23207..5e701ae 100644 --- a/electron/main/logger.ts +++ b/electron/main/logger.ts @@ -28,6 +28,7 @@ import { sanitizeLogEntry } from '../../shared/utils/sanitize'; let writeStream: WriteStream | null = null; let currentDate = ''; let logDir = ''; +let logDirResolver: (() => string) | null = null; const writeQueue: string[] = []; let isWriting = false; const MAX_DAYS = 7; @@ -46,7 +47,7 @@ function getLogFilePath(date: string): string { function ensureDir(): void { if (logDir) return; try { - logDir = join(app.getPath('userData'), 'logs'); + logDir = logDirResolver ? logDirResolver() : join(app.getPath('userData'), 'logs'); } catch { // app.getPath 尚不可用(app.ready 之前),静默跳过 return; @@ -216,6 +217,11 @@ export const logger = { }, }; +/** 注入自定义日志目录解析器(在 initLogger() 之前调用) */ +export function setLogDirResolver(resolver: () => string): void { + logDirResolver = resolver; +} + /** 预热日志模块(在 app.whenReady() 中调用) */ export function initLogger(): void { ensureDir(); diff --git a/electron/main/net-request.ts b/electron/main/net-request.ts index 2745482..850f6fe 100644 --- a/electron/main/net-request.ts +++ b/electron/main/net-request.ts @@ -16,6 +16,7 @@ // ============================================================ import { net } from 'electron'; +import { logger } from './logger'; // ---------- 类型 ---------- @@ -128,9 +129,7 @@ export async function request( try { void new URL(fullUrl); } catch (urlErr) { - console.error('[net-request] 非法 URL:', JSON.stringify(fullUrl)); - console.error('[net-request] URL 字符码:', [...fullUrl].map(c => c.charCodeAt(0)).join(',')); - console.error('[net-request] config:', JSON.stringify(config)); + logger.warn('request', `非法 URL: ${fullUrl}`, urlErr instanceof Error ? urlErr : undefined); reject(new RequestError(`非法 URL: ${(urlErr as Error).message}`)); return; } diff --git a/electron/main/updater.ts b/electron/main/updater.ts index 0c15123..9eb79ad 100644 --- a/electron/main/updater.ts +++ b/electron/main/updater.ts @@ -1,7 +1,8 @@ // 自动更新 — HeiXiuProvider + electron-updater 增量下载 // 流程图:调用自有 API → 获取版本信息 → electron-updater 差分下载 → SHA512 校验 → 安装 -import {BrowserWindow, ipcMain} from 'electron'; +import {app, BrowserWindow, ipcMain} from 'electron'; +import {createHash} from 'node:crypto'; import {URL} from 'node:url'; import { @@ -19,6 +20,49 @@ import {APP_VERSION} from '../../shared/constants/version'; import {logger} from './logger'; import {netRequest} from './net-request'; +// ---------- 构建时注入的渠道信息 ---------- + +/** 构建渠道(由 build.mjs 通过 VITE_BUILD_CHANNEL 注入,Vite 编译时内联) */ +const BUILD_CHANNEL: string = + (typeof import.meta !== 'undefined' && (import.meta as Record).env + ? ((import.meta as Record).env as Record).VITE_BUILD_CHANNEL + : undefined) + || process.env.VITE_BUILD_CHANNEL + || 'stable'; + +/** 构建版本类型(由 build.mjs 通过 VITE_BUILD_EDITION 注入) */ +const BUILD_EDITION: string = + (typeof import.meta !== 'undefined' && (import.meta as Record).env + ? ((import.meta as Record).env as Record).VITE_BUILD_EDITION + : undefined) + || process.env.VITE_BUILD_EDITION + || 'personal'; + +// ---------- 设备指纹(稳定的机器标识,用于灰度分组)---------- + +let _deviceFingerprint: string | null = null; + +/** 获取设备指纹(基于机器特定信息的 SHA-256 前 16 位) */ +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; +} + // ---------- IPC 通道(对渲染进程暴露,与旧版兼容)---------- export const UPDATE_CHANNELS = { @@ -77,6 +121,9 @@ class HeiXiuProvider extends Provider { params: { platform: process.platform, version: APP_VERSION, + channel: BUILD_CHANNEL, + edition: BUILD_EDITION, + deviceFingerprint: getDeviceFingerprint(), }, }, ); @@ -242,7 +289,12 @@ let platformUpdater: NsisUpdater | MacUpdater | AppImageUpdater | null = null; export function initUpdater(mainWindow: BrowserWindow): void { updateWindow = mainWindow; - console.log('[updater] initUpdater 被调用,API URL:', getApiBaseUrl()); + 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()}`); // if (import.meta.env.DEV) { // logger.info('updater', '开发模式,跳过自动检查更新', { apiUrl: getApiBaseUrl() }); diff --git a/index.html b/index.html index fa9a45c..dd24cda 100644 --- a/index.html +++ b/index.html @@ -17,6 +17,6 @@

- + diff --git a/infinite_canvas_140.md b/infinite_canvas_140.md new file mode 100644 index 0000000..3524245 --- /dev/null +++ b/infinite_canvas_140.md @@ -0,0 +1,802 @@ +# Infinite Canvas — PySide6 → React/Electron 重构方案 + +> 基于 `infinite_canvas_140/` 源码分析,映射到当前 React + TypeScript + Electron 技术栈。 +> 重点关注:**文件夹 → 媒体文件映射逻辑**(核心差异点) + +--- + +## 一、技术栈映射 + +| PySide6(原版) | React/Electron(目标) | 说明 | +|------------------------|--------------------------------------|-------------------------------| +| `QGraphicsView` | 虚拟滚动 `` / `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` | 图片内存缓存 | +| 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 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; + + // ── 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; +} +``` + +--- + +## 四、组件树 + +``` + ← 路由 /canvas(Drawer 叠加模式) + ├─ ← 阶段切换 Tab(左上角浮层) + ├─ ← 项目/集数/镜头/关键词/审核筛选(顶部浮层) + ├─ ← 模式切换(右下角浮层) + │ + ├─ ← 核心画布:CSS transform 驱动的虚拟视口 + │ │ transform: scale(zoom) translate(panOffset) + │ │ + │ ├─ ×N ← 占位符(single 模式下空镜头行) + │ ├─ ×N ← 行标签(single 模式下左侧镜头号) + │ │ + │ └─ ×N ← 核心渲染单元 = 原版 StoryboardItem + │ ├─ props: { item, x, y, w, h, behaviors[] } + │ ├─ ← 棋盘格背景(图片加载前) + │ ├─ ← 多级 LOD 图片 + │ ├─ ← 审核角标(pending/approved/rework/deprecated) + │ ├─ ← 选择框(SELECT 模式下可见) + │ └─ ← 右键菜单 + │ + ├─ ← 分页器(底部居中浮层) + ├─ ← 缩放控件(右下角浮层) + └─ ← 快速编辑面板(选中 item 时右侧弹出) +``` + +### 4.1 为什么用 CSS Transform 而不是 `` + +| 方案 | 优点 | 缺点 | +|---------------|---------------------------|-------------------------------| +| `` | 像素级控制,无 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(); // (path, level) 去重 + private pending: Array = []; // 等待队列 + private maxConcurrent = 10; // 最大并发 + + request(path: string, level: number): Promise { + 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 → + → 扫描完成 → 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 组件 + `` / `