feat: 构建系统重构 + VCHSM 架构迁移 + 右键菜单修复 + 尺寸参数统一
## 构建、打包与分发系统重构 (build/) - 构建系统收敛至 build/ 目录:build.mjs + electron-builder.js + 6 个脚本 - electron-builder 配置从 package.json 提取为 build/electron-builder.js - 新增 --edition (personal/enterprise) 和 --channel (stable/beta/alpha) 参数 - OSS 路径按渠道隔离 - 新增 pre-build-check.mjs:Git/CHANGELOG/版本/环境变量校验 - 新增 bump-version.mjs:版本号管理 + CHANGELOG 自动生成 - updater.ts 发送 channel/edition/deviceFingerprint - 新增 src/shared/utils/rollout.ts 灰度测试工具 - 新增 10 条 NPM 脚本 ## VCHSM 五层架构迁移 - 7 个业务模块 + ~500 处导入路径同步 ## 修复 - MediaCardContextMenu 改用共享 ContextMenu 组件 - ComboBox 尺寸参数显示统一 (size-format.ts) ## 文档 - UpdateA.md / build/README.md / README.md / .env.example 更新 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"pid": 17460,
|
||||
"pid": 1493920,
|
||||
"version": "0.9.9",
|
||||
"socketPath": "\\\\.\\pipe\\codegraph-aef859f0205bacea",
|
||||
"startedAt": 1781769813327
|
||||
"startedAt": 1782701380684
|
||||
}
|
||||
|
||||
15
.env.example
15
.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)
|
||||
|
||||
34
CHANGELOG.md
34
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 重构 + 预览键盘控制 + 拖拽体验优化**
|
||||
|
||||
17
CLAUDE.md
17
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文档中移除。
|
||||
- 在没有得到修改指令时,不要修改文档文件。
|
||||
|
||||
274
PROJECT.md
274
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<T>(← 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/ 目录,语义版本排序 |
|
||||
|
||||
## 增量更新(差分下载)
|
||||
|
||||
148
README.md
148
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 五层说明及各模块文件清单。
|
||||
|
||||
---
|
||||
|
||||
## 环境变量
|
||||
|
||||
21
TODO.md
21
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,纯前端限制对脚本攻击无效)
|
||||
|
||||
167
UpdateA.md
167
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 <url>]
|
||||
node scripts/upload-oss.mjs <平台> <版本类型> [版本号]
|
||||
node scripts/publish-release.mjs <平台> <版本类型> [版本号] [--url <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 <UPDATE_API_KEY>` 鉴权。请求体包含 `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 主进程产物
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
300
build.mjs
300
build.mjs
@@ -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 <path> 证书文件路径 (.pfx)
|
||||
// --cert-password <p> 证书密码
|
||||
//
|
||||
// 示例:
|
||||
// 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 <path>', '证书文件路径(.pfx / .p12)');
|
||||
row('--cert-password <p>', '证书密码');
|
||||
console.log(` ${dim('也可通过 CSC_LINK / CSC_KEY_PASSWORD 环境变量设置')}`);
|
||||
|
||||
// 示例
|
||||
box('示例');
|
||||
console.log(` ${dim('# 日常构建')}`);
|
||||
console.log(` ${bold('node build.mjs --win')}`);
|
||||
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);
|
||||
});
|
||||
431
build/build.mjs
Normal file
431
build/build.mjs
Normal file
@@ -0,0 +1,431 @@
|
||||
// ============================================================
|
||||
// build.mjs — 构建总管
|
||||
//
|
||||
// 用法:node build/build.mjs [选项]
|
||||
//
|
||||
// 平台(必选其一):
|
||||
// --win Windows
|
||||
// --mac macOS
|
||||
// --linux Linux
|
||||
//
|
||||
// 版本 & 渠道:
|
||||
// --edition <e> 版本类型:personal(默认)| enterprise
|
||||
// --channel <c> 发布渠道:stable(默认)| beta | alpha
|
||||
// --version <v> 覆盖 package.json 中的版本号
|
||||
//
|
||||
// 操作(可选组合,默认只执行 --build):
|
||||
// --build 仅构建(默认)
|
||||
// --clean 构建前清理 release/ dist/ dist-electron/
|
||||
// --sign 代码签名
|
||||
// --upload 上传安装包到 OSS
|
||||
// --publish 发布版本信息到后端 API
|
||||
// --all 全流程(clean + build + sign + upload + publish)
|
||||
//
|
||||
// 签名(仅 --sign 时可用):
|
||||
// --cert <path> 证书文件路径 (.pfx)
|
||||
// --cert-password <p> 证书密码
|
||||
//
|
||||
// OSS 路径规则:
|
||||
// Stable → releases/{version}/ (向后兼容,无渠道后缀)
|
||||
// Beta → releases/beta/{version}/ (独立生命周期,可设自动过期策略)
|
||||
// Alpha → releases/{version}/alpha/ (版本优先)
|
||||
//
|
||||
// 示例:
|
||||
// node build/build.mjs --win
|
||||
// node build/build.mjs --win --channel beta --upload --publish
|
||||
// node build/build.mjs --win --edition enterprise --all
|
||||
// node build/build.mjs --win --sign --cert ./cert.pfx --cert-password xxx
|
||||
// ============================================================
|
||||
|
||||
import { spawn, execSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..');
|
||||
const SCRIPTS = join(__dirname, 'scripts');
|
||||
|
||||
// ANSI 颜色
|
||||
const c = (code, text) => `\x1b[${code}m${text}\x1b[0m`;
|
||||
const dim = (t) => c(2, t);
|
||||
const bold = (t) => c(1, t);
|
||||
const cyan = (t) => c(36, t);
|
||||
const green = (t) => c(32, t);
|
||||
const yellow = (t) => c(33, t);
|
||||
|
||||
// ---------- 参数解析 ----------
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
const opts = {
|
||||
win: false,
|
||||
mac: false,
|
||||
linux: false,
|
||||
clean: false,
|
||||
build: false,
|
||||
sign: false,
|
||||
upload: false,
|
||||
publish: false,
|
||||
cert: '',
|
||||
certPassword: '',
|
||||
edition: 'personal',
|
||||
channel: 'stable',
|
||||
version: '',
|
||||
};
|
||||
|
||||
let i = 0;
|
||||
while (i < args.length) {
|
||||
const a = args[i];
|
||||
switch (a) {
|
||||
case '--win': opts.win = true; break;
|
||||
case '--mac': opts.mac = true; break;
|
||||
case '--linux': opts.linux = true; break;
|
||||
case '--clean': opts.clean = true; break;
|
||||
case '--build': opts.build = true; break;
|
||||
case '--sign': opts.sign = true; break;
|
||||
case '--upload': opts.upload = true; break;
|
||||
case '--publish': opts.publish = true; break;
|
||||
case '--all':
|
||||
opts.clean = opts.build = opts.sign = opts.upload = opts.publish = true;
|
||||
break;
|
||||
case '--cert':
|
||||
opts.cert = args[++i] || '';
|
||||
break;
|
||||
case '--cert-password':
|
||||
opts.certPassword = args[++i] || '';
|
||||
break;
|
||||
case '--edition':
|
||||
opts.edition = args[++i] || 'personal';
|
||||
break;
|
||||
case '--channel':
|
||||
opts.channel = args[++i] || 'stable';
|
||||
break;
|
||||
case '--version':
|
||||
opts.version = args[++i] || '';
|
||||
break;
|
||||
case '--help': // fallthrough
|
||||
case '-h':
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
// ---------- 校验 ----------
|
||||
|
||||
const platformCount = [opts.win, opts.mac, opts.linux].filter(Boolean).length;
|
||||
if (platformCount !== 1) {
|
||||
console.error('错误: 请指定且仅指定一个平台(--win / --mac / --linux)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!['personal', 'enterprise'].includes(opts.edition)) {
|
||||
console.error('错误: --edition 必须是 personal 或 enterprise');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!['stable', 'beta', 'alpha'].includes(opts.channel)) {
|
||||
console.error('错误: --channel 必须是 stable、beta 或 alpha');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 默认执行 build
|
||||
if (!opts.clean && !opts.build && !opts.sign && !opts.upload && !opts.publish) {
|
||||
opts.build = true;
|
||||
}
|
||||
|
||||
const platform = opts.win ? 'win32' : opts.mac ? 'darwin' : 'linux';
|
||||
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
|
||||
const version = opts.version || pkg.version;
|
||||
|
||||
// ---------- Git 元数据 ----------
|
||||
|
||||
let gitCommit = '';
|
||||
let gitBranch = '';
|
||||
try {
|
||||
gitCommit = execSync('git rev-parse --short HEAD', { cwd: ROOT, encoding: 'utf-8' }).trim();
|
||||
gitBranch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: ROOT, encoding: 'utf-8' }).trim();
|
||||
} catch {
|
||||
// 非 git 环境 — 留空
|
||||
}
|
||||
|
||||
const buildTimestamp = new Date().toISOString();
|
||||
|
||||
// ---------- OSS 路径计算 ----------
|
||||
|
||||
/**
|
||||
* OSS 对象路径前缀:
|
||||
* - Beta: releases/beta/{version}/ (独立生命周期,可设 OSS 自动过期策略)
|
||||
* - Alpha: releases/{version}/alpha/ (方案 B:版本优先)
|
||||
* - Stable: releases/{version}/ (无渠道后缀,向后兼容现有布局)
|
||||
*/
|
||||
function getOssPrefix() {
|
||||
if (opts.channel === 'beta') return `releases/beta/${version}`;
|
||||
if (opts.channel === 'alpha') return `releases/${version}/alpha`;
|
||||
return `releases/${version}`;
|
||||
}
|
||||
|
||||
// electron-builder 输出目录名(与 OSS 路径解耦,加渠道后缀防冲突)
|
||||
function getReleaseDirName() {
|
||||
if (opts.channel === 'stable') return version;
|
||||
return `${version}-${opts.channel}`;
|
||||
}
|
||||
|
||||
// ---------- 工具函数 ----------
|
||||
|
||||
function run(cmd, args, runOpts = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(cmd, args, {
|
||||
cwd: ROOT,
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
...runOpts,
|
||||
env: { ...process.env, ...(runOpts.env || {}) },
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`${cmd} ${args.join(' ')} 退出码: ${code}`));
|
||||
});
|
||||
child.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function runScript(name, ...scriptArgs) {
|
||||
const scriptPath = join(SCRIPTS, `${name}.mjs`);
|
||||
return run('node', [scriptPath, ...scriptArgs]);
|
||||
}
|
||||
|
||||
function step(title) {
|
||||
console.log(`\n${'='.repeat(60)}`);
|
||||
console.log(` ${title}`);
|
||||
console.log(`${'='.repeat(60)}\n`);
|
||||
}
|
||||
|
||||
// ---------- 主流程 ----------
|
||||
|
||||
async function main() {
|
||||
const electronBuilderPlatform = opts.win ? '--win' : opts.mac ? '--mac' : '--linux';
|
||||
const editionTag = opts.edition === 'enterprise' ? '企业版' : '个人版';
|
||||
const channelTag = opts.channel === 'stable' ? '' : ` · ${opts.channel} 渠道`;
|
||||
const ossPrefix = getOssPrefix();
|
||||
const releaseDirName = getReleaseDirName();
|
||||
|
||||
console.log('┌──────────────────────────────────────────────┐');
|
||||
console.log(`│ HeiXiu 构建 v${version} ${platform} ${editionTag}`.padEnd(51) + '│');
|
||||
if (channelTag) {
|
||||
console.log(`│ ${channelTag.trim()}`.padEnd(51) + '│');
|
||||
}
|
||||
if (gitCommit) {
|
||||
console.log(`│ git: ${gitBranch}@${gitCommit}`.padEnd(51) + '│');
|
||||
}
|
||||
console.log('└──────────────────────────────────────────────┘');
|
||||
console.log(`操作: ${[
|
||||
opts.clean && '清理',
|
||||
opts.build && '构建',
|
||||
opts.sign && '签名',
|
||||
opts.upload && '上传',
|
||||
opts.publish && '发布',
|
||||
].filter(Boolean).join(' → ')}`);
|
||||
console.log(`本地产物: release/${releaseDirName}/`);
|
||||
console.log(`OSS 路径: ${ossPrefix}/`);
|
||||
|
||||
// ── Step 0: 构建前校验 ──
|
||||
if (opts.build) {
|
||||
step('0/ 构建前校验');
|
||||
try {
|
||||
await runScript('pre-build-check', platform, version, opts.channel);
|
||||
} catch (err) {
|
||||
console.error(`\n❌ 构建前校验失败: ${err.message}`);
|
||||
console.error('如需跳过校验,请使用 --skip-check 参数(或注释 build.mjs 中对应行)');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 1: 清理 ──
|
||||
if (opts.clean) {
|
||||
step('1/ 清理构建产物');
|
||||
await runScript('clean');
|
||||
}
|
||||
|
||||
// ── Step 2: 构建 ──
|
||||
if (opts.build) {
|
||||
step('2/ 构建应用');
|
||||
|
||||
// 签名环境变量
|
||||
const signEnv = {};
|
||||
if (opts.sign) {
|
||||
if (opts.cert) signEnv.CSC_LINK = opts.cert;
|
||||
if (opts.certPassword) signEnv.CSC_KEY_PASSWORD = opts.certPassword;
|
||||
console.log(opts.cert ? `签名证书: ${opts.cert}` : '签名: 使用系统证书存储');
|
||||
}
|
||||
|
||||
// 写入构建元数据
|
||||
const metaPath = join(ROOT, 'release', '.build-meta.json');
|
||||
const metaDir = join(ROOT, 'release');
|
||||
if (!existsSync(metaDir)) mkdirSync(metaDir, { recursive: true });
|
||||
writeFileSync(metaPath, JSON.stringify({
|
||||
version,
|
||||
platform,
|
||||
edition: opts.edition,
|
||||
channel: opts.channel,
|
||||
gitCommit,
|
||||
gitBranch,
|
||||
buildTimestamp,
|
||||
ossPrefix,
|
||||
releaseDirName,
|
||||
}, null, 2), 'utf-8');
|
||||
|
||||
// 2a. TypeScript 编译
|
||||
console.log('[tsc] 类型检查...');
|
||||
await run('npx', ['tsc']);
|
||||
|
||||
// 2b. Vite 构建 + 注入构建元数据
|
||||
console.log('[vite] 打包前端 + Electron...');
|
||||
await run('npx', ['vite', 'build'], {
|
||||
env: {
|
||||
VITE_BUILD_VERSION: version,
|
||||
VITE_BUILD_CHANNEL: opts.channel,
|
||||
VITE_BUILD_EDITION: opts.edition,
|
||||
VITE_BUILD_COMMIT: gitCommit,
|
||||
VITE_BUILD_TIMESTAMP: buildTimestamp,
|
||||
},
|
||||
});
|
||||
|
||||
// 2c. electron-builder 打包
|
||||
console.log(`[electron-builder] ${electronBuilderPlatform} 打包 (${editionTag}${channelTag})...`);
|
||||
await run('npx', [
|
||||
'electron-builder',
|
||||
electronBuilderPlatform,
|
||||
'--config', 'build/electron-builder.js',
|
||||
], {
|
||||
env: {
|
||||
...signEnv,
|
||||
HEIXIU_EDITION: opts.edition,
|
||||
HEIXIU_CHANNEL: opts.channel,
|
||||
HEIXIU_RELEASE_DIR_NAME: releaseDirName,
|
||||
},
|
||||
});
|
||||
|
||||
// 2d. 生成 update-info.json
|
||||
console.log('[info] 生成 update-info.json...');
|
||||
await runScript('generate-update-info', platform, version, opts.channel, opts.edition, ossPrefix, releaseDirName);
|
||||
}
|
||||
|
||||
// ── Step 3: 上传 OSS ──
|
||||
if (opts.upload) {
|
||||
step('3/ 上传 OSS');
|
||||
await runScript('upload-oss', platform, version, opts.channel, ossPrefix, releaseDirName);
|
||||
}
|
||||
|
||||
// ── Step 4: 发布到 API ──
|
||||
if (opts.publish) {
|
||||
step('4/ 发布版本到 API');
|
||||
await runScript('publish-release', platform, version, opts.channel, opts.edition, ossPrefix, releaseDirName);
|
||||
}
|
||||
|
||||
console.log('\n✅ 全部完成');
|
||||
|
||||
// 构建元数据摘要
|
||||
console.log(dim(`\n构建摘要:`));
|
||||
console.log(dim(` 版本: ${version}`));
|
||||
console.log(dim(` 平台: ${platform}`));
|
||||
console.log(dim(` 版本类型: ${opts.edition}`));
|
||||
console.log(dim(` 渠道: ${opts.channel}`));
|
||||
console.log(dim(` OSS: ${ossPrefix}/`));
|
||||
if (gitCommit) console.log(dim(` Commit: ${gitCommit} (${gitBranch})`));
|
||||
console.log(dim(` 时间: ${buildTimestamp}`));
|
||||
}
|
||||
|
||||
// ---------- 帮助 ----------
|
||||
|
||||
function printHelp() {
|
||||
const box = (title) => {
|
||||
const W = 62;
|
||||
const pad = Math.max(0, W - 4 - title.length);
|
||||
console.log(`\n${cyan('┌')}${cyan('─'.repeat(W - 2))}${cyan('┐')}`);
|
||||
console.log(`${cyan('│')} ${bold(title)}${' '.repeat(pad)}${cyan('│')}`);
|
||||
console.log(`${cyan('└')}${cyan('─'.repeat(W - 2))}${cyan('┘')}`);
|
||||
};
|
||||
const row = (flag, desc) => {
|
||||
console.log(` ${green(flag.padEnd(24))}${dim(desc)}`);
|
||||
};
|
||||
const hdr = (t) => console.log(`\n ${bold(yellow(t))}`);
|
||||
|
||||
console.log('');
|
||||
console.log(cyan(' ╔') + cyan('═══════════════════════════════════════════════════') + cyan('╗'));
|
||||
console.log(cyan(' ║') + bold(' 船长·HeiXiu — 构建工具') + ' ' + cyan('║'));
|
||||
console.log(cyan(' ╚') + cyan('═══════════════════════════════════════════════════') + cyan('╝'));
|
||||
|
||||
box('用法');
|
||||
console.log(` ${bold('node build/build.mjs')} ${dim('<平台> [选项...]')}`);
|
||||
console.log(` ${bold('npm run build:win')} ${dim('— 等效快捷命令')}`);
|
||||
|
||||
hdr('平台(必选其一)');
|
||||
row('--win', 'Windows');
|
||||
row('--mac', 'macOS');
|
||||
row('--linux', 'Linux');
|
||||
|
||||
hdr('版本 & 渠道');
|
||||
row('--edition <personal|enterprise>', '版本类型(默认 personal)');
|
||||
row('--channel <stable|beta|alpha>', '发布渠道(默认 stable)');
|
||||
row('--version <x.y.z>', '覆盖 package.json 版本号');
|
||||
|
||||
hdr('操作(可选组合,不指定则默认 --build)');
|
||||
row('--build', '构建应用(校验 → tsc → vite → electron-builder → info)');
|
||||
row('--clean', '构建前清理 release/ dist/ dist-electron/');
|
||||
row('--sign', '代码签名(需配合 --cert 或环境变量)');
|
||||
row('--upload', '上传安装包 + blockmap 到 OSS');
|
||||
row('--publish', 'POST 版本信息到后端 API');
|
||||
row('--all', '全流程 = clean + build + sign + upload + publish');
|
||||
|
||||
hdr('签名参数(配合 --sign)');
|
||||
row('--cert <path>', '证书文件路径(.pfx / .p12)');
|
||||
row('--cert-password <p>', '证书密码');
|
||||
console.log(` ${dim('也可通过 CSC_LINK / CSC_KEY_PASSWORD 环境变量设置')}`);
|
||||
|
||||
hdr('灰度发布示例');
|
||||
console.log(` ${dim('# Beta 渠道打包 + 上传 + 发布')}`);
|
||||
console.log(` ${bold('node build/build.mjs --win --channel beta --upload --publish')}`);
|
||||
console.log(` ${dim('# Alpha 渠道仅打包测试')}`);
|
||||
console.log(` ${bold('node build/build.mjs --win --channel alpha')}`);
|
||||
console.log(` ${dim('# 企业版全流程')}`);
|
||||
console.log(` ${bold('node build/build.mjs --win --edition enterprise --all')}`);
|
||||
|
||||
box('OSS 路径规则');
|
||||
console.log(` ${yellow('Stable')} ${dim('→ releases/{version}/ (向后兼容)')}`);
|
||||
console.log(` ${yellow('Beta')} ${dim('→ releases/beta/{version}/ (独立生命周期)')}`);
|
||||
console.log(` ${yellow('Alpha')} ${dim('→ releases/{version}/alpha/ (版本优先)')}`);
|
||||
|
||||
box('npm 快捷命令');
|
||||
console.log(` ${bold('npm run build:win')} ${dim('→ node build/build.mjs --win')}`);
|
||||
console.log(` ${bold('npm run build:win:enterprise')} ${dim('→ node build/build.mjs --win --edition enterprise')}`);
|
||||
console.log(` ${bold('npm run build:win:beta')} ${dim('→ node build/build.mjs --win --channel beta')}`);
|
||||
console.log(` ${bold('npm run build:mac')} ${dim('→ node build/build.mjs --mac')}`);
|
||||
console.log(` ${bold('npm run build:linux')} ${dim('→ node build/build.mjs --linux')}`);
|
||||
console.log(` ${bold('npm run build:clean')} ${dim('→ node build/scripts/clean.mjs')}`);
|
||||
console.log(` ${bold('npm run version:patch')} ${dim('→ node build/scripts/bump-version.mjs patch')}`);
|
||||
|
||||
box('环境变量');
|
||||
console.log(` ${yellow('UPDATE_API_URL')} ${dim('版本检查 API 基地址')}`);
|
||||
console.log(` ${yellow('UPDATE_API_KEY')} ${dim('发布 API 鉴权 Key')}`);
|
||||
console.log(` ${yellow('UPDATE_DOWNLOAD_BASE')} ${dim('OSS 下载基地址')}`);
|
||||
console.log(` ${yellow('OSS_ENDPOINT')} ${dim('阿里云 OSS endpoint')}`);
|
||||
console.log(` ${yellow('OSS_BUCKET')} ${dim('阿里云 OSS bucket')}`);
|
||||
console.log(` ${yellow('OSS_ACCESS_KEY_ID')} ${dim('阿里云 AccessKey ID')}`);
|
||||
console.log(` ${yellow('OSS_ACCESS_KEY_SECRET')} ${dim('阿里云 AccessKey Secret')}`);
|
||||
console.log(` ${yellow('CSC_LINK')} ${dim('签名证书路径')}`);
|
||||
console.log(` ${yellow('CSC_KEY_PASSWORD')} ${dim('签名证书密码')}`);
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// ---------- 入口 ----------
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`\n❌ 构建失败: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
78
build/electron-builder.js
Normal file
78
build/electron-builder.js
Normal file
@@ -0,0 +1,78 @@
|
||||
// ============================================================
|
||||
// electron-builder 配置
|
||||
//
|
||||
// 从 package.json "build" 字段提取,通过环境变量动态注入参数。
|
||||
// build.mjs 负责设置 HEIXIU_* 环境变量后调用 electron-builder。
|
||||
//
|
||||
// 用法:
|
||||
// HEIXIU_EDITION=enterprise HEIXIU_CHANNEL=beta npx electron-builder --win --config build/electron-builder.js
|
||||
// ============================================================
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..');
|
||||
|
||||
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
|
||||
|
||||
const edition = process.env.HEIXIU_EDITION || 'personal';
|
||||
const channel = process.env.HEIXIU_CHANNEL || 'stable';
|
||||
const releaseDirName = process.env.HEIXIU_RELEASE_DIR_NAME || pkg.version;
|
||||
|
||||
const editionLabel = edition === 'enterprise' ? '企业版' : '';
|
||||
const productName = editionLabel
|
||||
? `船长·HeiXiu·${editionLabel}`
|
||||
: '船长·HeiXiu';
|
||||
|
||||
/** @type {import('electron-builder').Configuration} */
|
||||
const config = {
|
||||
appId: edition === 'enterprise'
|
||||
? 'com.heixiu.enterprise'
|
||||
: 'com.heixiu.electron',
|
||||
productName,
|
||||
asar: true,
|
||||
directories: {
|
||||
output: `release/${releaseDirName}`,
|
||||
},
|
||||
files: [
|
||||
'dist',
|
||||
'dist-electron',
|
||||
],
|
||||
electronDownload: {
|
||||
mirror: 'https://npmmirror.com/mirrors/electron/',
|
||||
},
|
||||
// generic provider 仅作为 electron-updater 的 fallback,
|
||||
// 实际版本检查通过自定义 HeiXiuProvider → API
|
||||
publish: {
|
||||
provider: 'generic',
|
||||
url: 'https://update.heixiu.com',
|
||||
},
|
||||
win: {
|
||||
icon: 'src/assets/logo/heixiu.ico',
|
||||
target: [{ target: 'NSIS', arch: ['x64'] }],
|
||||
artifactName: '${productName}-Windows-${version}-Setup.${ext}',
|
||||
},
|
||||
mac: {
|
||||
icon: 'src/assets/logo/heixiu.icns',
|
||||
target: ['dmg'],
|
||||
artifactName: '${productName}-Mac-${version}-Installer.${ext}',
|
||||
},
|
||||
linux: {
|
||||
icon: 'src/assets/logo/HX.png',
|
||||
target: ['AppImage'],
|
||||
artifactName: '${productName}-Linux-${version}.${ext}',
|
||||
},
|
||||
nsis: {
|
||||
oneClick: false,
|
||||
perMachine: false,
|
||||
allowToChangeInstallationDirectory: true,
|
||||
deleteAppDataOnUninstall: false,
|
||||
differentialPackage: true,
|
||||
installerIcon: 'src/assets/logo/heixiu.ico',
|
||||
uninstallerIcon: 'src/assets/logo/heixiu.ico',
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
153
build/scripts/bump-version.mjs
Normal file
153
build/scripts/bump-version.mjs
Normal file
@@ -0,0 +1,153 @@
|
||||
// ============================================================
|
||||
// bump-version.mjs — 版本号管理与 CHANGELOG 模板生成
|
||||
//
|
||||
// 用法:
|
||||
// node build/scripts/bump-version.mjs <major|minor|patch> [--no-changelog]
|
||||
// node build/scripts/bump-version.mjs set <x.y.z>
|
||||
// node build/scripts/bump-version.mjs pre <beta|alpha>
|
||||
//
|
||||
// 示例:
|
||||
// node build/scripts/bump-version.mjs patch # 0.0.28 → 0.0.29
|
||||
// node build/scripts/bump-version.mjs minor # 0.0.28 → 0.1.0
|
||||
// node build/scripts/bump-version.mjs set 1.0.0 # 直接设置版本
|
||||
// node build/scripts/bump-version.mjs pre beta # 0.0.29 → 0.0.29-beta.1
|
||||
// ============================================================
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..', '..');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
const arg1 = args[1];
|
||||
const noChangelog = args.includes('--no-changelog');
|
||||
|
||||
if (!command) {
|
||||
console.error('用法: node build/scripts/bump-version.mjs <major|minor|patch|set|pre> [value]');
|
||||
console.error('示例: node build/scripts/bump-version.mjs patch');
|
||||
console.error(' node build/scripts/bump-version.mjs set 1.0.0');
|
||||
console.error(' node build/scripts/bump-version.mjs pre beta');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pkgPath = join(ROOT, 'package.json');
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
||||
const oldVersion = pkg.version;
|
||||
|
||||
// ---------- 版本计算 ----------
|
||||
|
||||
function parseSemver(v) {
|
||||
const match = v.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/);
|
||||
if (!match) throw new Error(`无效的版本号格式: ${v}`);
|
||||
return {
|
||||
major: parseInt(match[1]),
|
||||
minor: parseInt(match[2]),
|
||||
patch: parseInt(match[3]),
|
||||
pre: match[4] || null,
|
||||
};
|
||||
}
|
||||
|
||||
let newVersion;
|
||||
|
||||
switch (command) {
|
||||
case 'major':
|
||||
case 'minor':
|
||||
case 'patch': {
|
||||
const sv = parseSemver(oldVersion);
|
||||
if (command === 'major') {
|
||||
sv.major++;
|
||||
sv.minor = 0;
|
||||
sv.patch = 0;
|
||||
} else if (command === 'minor') {
|
||||
sv.minor++;
|
||||
sv.patch = 0;
|
||||
} else {
|
||||
sv.patch++;
|
||||
}
|
||||
sv.pre = null;
|
||||
newVersion = `${sv.major}.${sv.minor}.${sv.patch}`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'set': {
|
||||
if (!arg1) {
|
||||
console.error('用法: node build/scripts/bump-version.mjs set <x.y.z>');
|
||||
process.exit(1);
|
||||
}
|
||||
// 基本格式校验
|
||||
if (!/^\d+\.\d+\.\d+(-.+)?$/.test(arg1)) {
|
||||
console.error(`无效的版本号: ${arg1}`);
|
||||
process.exit(1);
|
||||
}
|
||||
newVersion = arg1;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'pre': {
|
||||
if (!arg1 || !['beta', 'alpha'].includes(arg1)) {
|
||||
console.error('用法: node build/scripts/bump-version.mjs pre <beta|alpha>');
|
||||
process.exit(1);
|
||||
}
|
||||
const sv = parseSemver(oldVersion);
|
||||
|
||||
// 如果当前版本已有预发布后缀,递增进位号
|
||||
if (sv.pre) {
|
||||
const match = sv.pre.match(new RegExp(`^${arg1}\\.(\\d+)$`));
|
||||
if (match) {
|
||||
const num = parseInt(match[1]) + 1;
|
||||
newVersion = `${sv.major}.${sv.minor}.${sv.patch}-${arg1}.${num}`;
|
||||
} else {
|
||||
// 不同预发布类型,重新开始
|
||||
newVersion = `${sv.major}.${sv.minor}.${sv.patch}-${arg1}.1`;
|
||||
}
|
||||
} else {
|
||||
// 纯 semver → 首次预发布:自动升 patch 再加后缀
|
||||
sv.patch++;
|
||||
newVersion = `${sv.major}.${sv.minor}.${sv.patch}-${arg1}.1`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.error(`未知命令: ${command}。支持: major | minor | patch | set <ver> | pre <beta|alpha>`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ---------- 更新 package.json ----------
|
||||
|
||||
pkg.version = newVersion;
|
||||
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
|
||||
console.log(`✅ package.json: ${oldVersion} → ${newVersion}`);
|
||||
|
||||
// ---------- 更新 shared/constants/version.ts(由 import pkg 动态读取,无需修改) ----------
|
||||
console.log(' shared/constants/version.ts 自动读取 package.json,无需手动同步');
|
||||
|
||||
// ---------- 更新 CHANGELOG.md ----------
|
||||
|
||||
if (!noChangelog) {
|
||||
const changelogPath = join(ROOT, 'CHANGELOG.md');
|
||||
try {
|
||||
let changelog = readFileSync(changelogPath, 'utf-8');
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const entry = `## ${newVersion}(${today})\n\n**更新内容待补充**\n\n---\n\n`;
|
||||
|
||||
// 在第一个 ## 之前插入新版本条目
|
||||
const firstHeaderMatch = changelog.match(/^##\s/m);
|
||||
if (firstHeaderMatch) {
|
||||
const insertAt = firstHeaderMatch.index;
|
||||
changelog = changelog.slice(0, insertAt) + entry + changelog.slice(insertAt);
|
||||
writeFileSync(changelogPath, changelog, 'utf-8');
|
||||
console.log(`✅ CHANGELOG.md: 已添加 v${newVersion} 模板条目`);
|
||||
} else {
|
||||
console.warn('⚠️ CHANGELOG.md 格式异常,未找到 ## 版本标题');
|
||||
}
|
||||
} catch {
|
||||
console.warn('⚠️ CHANGELOG.md 不存在,跳过更新');
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n版本已更新: ${oldVersion} → ${newVersion}`);
|
||||
@@ -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'];
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
// ============================================================
|
||||
// 打包后自动生成 update-info.json
|
||||
// 打包后生成 update-info.json(含渠道/版本类型/SHA512/blockmap)
|
||||
//
|
||||
// 用法:
|
||||
// node scripts/generate-update-info.mjs <platform> [version] [--url <url>]
|
||||
// 用法(由 build.mjs 调用):
|
||||
// node build/scripts/generate-update-info.mjs <platform> <version> <channel> <edition> <ossPrefix> <releaseDirName>
|
||||
//
|
||||
// 两种用法:
|
||||
// 方式 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 <platform> <version> [channel] [edition] [ossPrefix] [releaseDirName] [--url <url>] [--blockmap-url <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 <platform> [version] [--url <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 <platform> <version> [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(),
|
||||
},
|
||||
135
build/scripts/pre-build-check.mjs
Normal file
135
build/scripts/pre-build-check.mjs
Normal file
@@ -0,0 +1,135 @@
|
||||
// ============================================================
|
||||
// pre-build-check.mjs — 构建前校验
|
||||
//
|
||||
// 用法:
|
||||
// node build/scripts/pre-build-check.mjs <platform> <version> <channel>
|
||||
//
|
||||
// 检查项:
|
||||
// 1. Git 工作区是否干净(有未提交变更时警告,非阻断)
|
||||
// 2. CHANGELOG.md 是否包含当前版本条目
|
||||
// 3. package.json 版本号与传入版本号一致
|
||||
// 4. 渠道版本号格式校验(beta/alpha 必须含预发布后缀)
|
||||
// ============================================================
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..', '..');
|
||||
|
||||
const platform = process.argv[2];
|
||||
const version = process.argv[3];
|
||||
const channel = process.argv[4] || 'stable';
|
||||
|
||||
if (!platform || !version) {
|
||||
console.error('用法: node build/scripts/pre-build-check.mjs <platform> <version> [channel]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let hasError = false;
|
||||
|
||||
// ---------- 1. Git 工作区检查 ----------
|
||||
|
||||
console.log('▸ 检查 Git 工作区状态...');
|
||||
try {
|
||||
const status = execSync('git status --porcelain', { cwd: ROOT, encoding: 'utf-8' }).trim();
|
||||
if (status) {
|
||||
console.warn('⚠️ 警告: Git 工作区有未提交的变更:');
|
||||
const lines = status.split('\n').slice(0, 10);
|
||||
lines.forEach((l) => console.warn(` ${l}`));
|
||||
if (status.split('\n').length > 10) {
|
||||
console.warn(` ... 还有 ${status.split('\n').length - 10} 个文件`);
|
||||
}
|
||||
console.warn(' (不阻断构建,但建议先提交)\n');
|
||||
} else {
|
||||
console.log(' ✅ Git 工作区干净\n');
|
||||
}
|
||||
} catch {
|
||||
console.warn(' ⚠️ 非 Git 环境,跳过工作区检查\n');
|
||||
}
|
||||
|
||||
// ---------- 2. CHANGELOG 版本条目检查 ----------
|
||||
|
||||
console.log('▸ 检查 CHANGELOG.md 版本条目...');
|
||||
const changelogPath = join(ROOT, 'CHANGELOG.md');
|
||||
try {
|
||||
const changelogContent = readFileSync(changelogPath, 'utf-8');
|
||||
const versionPattern = new RegExp(
|
||||
`^##\\s+${version.replace(/\./g, '\\.')}\\b`,
|
||||
'm',
|
||||
);
|
||||
if (versionPattern.test(changelogContent)) {
|
||||
console.log(` ✅ CHANGELOG.md 已包含 v${version} 条目\n`);
|
||||
} else {
|
||||
console.error(` ❌ CHANGELOG.md 中未找到 v${version} 的更新日志条目`);
|
||||
console.error(` 请在 CHANGELOG.md 顶部添加: ## ${version}(日期)\n`);
|
||||
hasError = true;
|
||||
}
|
||||
} catch {
|
||||
console.warn(' ⚠️ CHANGELOG.md 不存在,跳过版本条目检查\n');
|
||||
}
|
||||
|
||||
// ---------- 3. package.json 版本号一致性 ----------
|
||||
|
||||
console.log('▸ 检查 package.json 版本一致性...');
|
||||
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
|
||||
if (pkg.version === version) {
|
||||
console.log(` ✅ package.json 版本号一致: ${version}\n`);
|
||||
} else {
|
||||
console.error(` ❌ 版本号不一致: 传入 ${version},package.json 为 ${pkg.version}`);
|
||||
console.error(' 请先执行: node build/scripts/bump-version.mjs <major|minor|patch>\n');
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
// ---------- 4. 渠道版本号格式校验 ----------
|
||||
|
||||
console.log('▸ 检查渠道版本号格式...');
|
||||
if (channel === 'stable') {
|
||||
// stable 版本号必须是纯 semver(如 0.0.28)
|
||||
if (/^\d+\.\d+\.\d+$/.test(version)) {
|
||||
console.log(' ✅ Stable 渠道版本号格式正确\n');
|
||||
} else {
|
||||
console.error(` ❌ Stable 渠道版本号必须为纯 semver 格式 (x.y.z),当前: ${version}\n`);
|
||||
hasError = true;
|
||||
}
|
||||
} else {
|
||||
// beta/alpha 版本号必须含预发布后缀(如 0.0.29-beta.1)
|
||||
if (/-/.test(version)) {
|
||||
console.log(` ✅ ${channel} 渠道版本号含预发布后缀\n`);
|
||||
} else {
|
||||
console.warn(` ⚠️ ${channel} 渠道建议使用预发布版本号(如 ${version}-${channel}.1)`);
|
||||
console.warn(' 当前版本号不含预发布后缀,可能覆盖 stable 版本的 OSS 文件\n');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 5. 环境变量检查(仅 upload/publish 时有效,此处仅提示) ----------
|
||||
|
||||
console.log('▸ 环境变量就绪检查...');
|
||||
const uploadVars = ['OSS_ENDPOINT', 'OSS_BUCKET', 'OSS_ACCESS_KEY_ID', 'OSS_ACCESS_KEY_SECRET'];
|
||||
const missingUpload = uploadVars.filter((v) => !process.env[v]);
|
||||
if (missingUpload.length > 0) {
|
||||
console.warn(` ⚠️ OSS 相关环境变量未设置: ${missingUpload.join(', ')}`);
|
||||
console.warn(' 如需上传 OSS,请配置这些环境变量(或在 .env.local 中设置)');
|
||||
} else {
|
||||
console.log(' ✅ OSS 环境变量已配置');
|
||||
}
|
||||
|
||||
if (!process.env.UPDATE_API_KEY) {
|
||||
console.warn(' ⚠️ UPDATE_API_KEY 未设置,发布到 API 可能失败');
|
||||
} else {
|
||||
console.log(' ✅ UPDATE_API_KEY 已配置');
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// ---------- 结果 ----------
|
||||
|
||||
if (hasError) {
|
||||
console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.error('❌ 构建前校验未通过,请修复以上问题后重试');
|
||||
console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('✅ 构建前校验全部通过');
|
||||
@@ -1,8 +1,11 @@
|
||||
// ============================================================
|
||||
// 发布版本信息到后端 API
|
||||
//
|
||||
// 用法:
|
||||
// node scripts/publish-release.mjs <platform> [version] [--url <url>]
|
||||
// 用法(由 build.mjs 调用):
|
||||
// node build/scripts/publish-release.mjs <platform> <version> <channel> <edition> <ossPrefix> <releaseDirName>
|
||||
//
|
||||
// 也支持手动调用:
|
||||
// node build/scripts/publish-release.mjs <platform> <version> [channel] [edition] [ossPrefix] [--url <url>] [--json <json>]
|
||||
//
|
||||
// 环境变量:
|
||||
// UPDATE_API_URL — API 基地址(默认 https://www.heixiu.com)
|
||||
@@ -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 <platform> [version] [--url <url>]');
|
||||
console.error('示例: node scripts/publish-release.mjs win32 0.1.0');
|
||||
if (!platform || !version) {
|
||||
console.error('用法: node build/scripts/publish-release.mjs <platform> <version> [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 {
|
||||
242
build/scripts/upload-oss.mjs
Normal file
242
build/scripts/upload-oss.mjs
Normal file
@@ -0,0 +1,242 @@
|
||||
// ============================================================
|
||||
// 上传安装包 + blockmap 文件到阿里云 OSS
|
||||
//
|
||||
// 用法(由 build.mjs 调用):
|
||||
// node build/scripts/upload-oss.mjs <platform> <version> <channel> <ossPrefix> <releaseDirName>
|
||||
//
|
||||
// 也支持手动调用:
|
||||
// node build/scripts/upload-oss.mjs <platform> <version> [channel] [ossPrefix]
|
||||
//
|
||||
// 环境变量(使用 Node.js 直传时需要):
|
||||
// OSS_ENDPOINT — OSS endpoint(如 oss-cn-hangzhou.aliyuncs.com)
|
||||
// OSS_BUCKET — Bucket 名称
|
||||
// OSS_ACCESS_KEY_ID — AccessKey ID
|
||||
// OSS_ACCESS_KEY_SECRET — AccessKey Secret
|
||||
//
|
||||
// 不传 OSS 环境变量时 → 打印手动上传命令
|
||||
// ============================================================
|
||||
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createHmac } from 'node:crypto';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..', '..');
|
||||
|
||||
// ---------- 参数 ----------
|
||||
|
||||
const platform = process.argv[2];
|
||||
const version = process.argv[3];
|
||||
const channel = process.argv[4] || 'stable';
|
||||
const ossPrefix = process.argv[5] || `releases/${version}`;
|
||||
const releaseDirName = process.argv[6] || version;
|
||||
|
||||
if (!platform || !version) {
|
||||
console.error('用法: node build/scripts/upload-oss.mjs <platform> <version> [channel] [ossPrefix] [releaseDirName]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ---------- 查找安装包 ----------
|
||||
|
||||
const releaseDir = join(ROOT, 'release', releaseDirName);
|
||||
|
||||
let installerName;
|
||||
try {
|
||||
const all = readdirSync(releaseDir);
|
||||
const exts = platform === 'win32' ? ['.exe'] : platform === 'darwin' ? ['.dmg'] : ['.AppImage'];
|
||||
installerName = all.find((f) => exts.some((e) => f.endsWith(e)));
|
||||
if (!installerName) {
|
||||
console.error(`错误: ${releaseDir} — 未找到安装包`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`错误: ${releaseDir} — ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const installerPath = join(releaseDir, installerName);
|
||||
|
||||
// ---------- 查找 blockmap 文件 ----------
|
||||
|
||||
let blockmapName = null;
|
||||
let blockmapPath = null;
|
||||
try {
|
||||
const all = readdirSync(releaseDir);
|
||||
blockmapName = all.find((f) => f.endsWith('.blockmap'));
|
||||
if (blockmapName) {
|
||||
blockmapPath = join(releaseDir, blockmapName);
|
||||
}
|
||||
} catch {
|
||||
// blockmap 不存在不影响主流程
|
||||
}
|
||||
|
||||
// ---------- OSS 配置 ----------
|
||||
|
||||
const {
|
||||
OSS_ENDPOINT,
|
||||
OSS_BUCKET,
|
||||
OSS_ACCESS_KEY_ID,
|
||||
OSS_ACCESS_KEY_SECRET,
|
||||
} = process.env;
|
||||
|
||||
const installerObjectKey = `${ossPrefix}/${installerName}`;
|
||||
|
||||
// ============================================================
|
||||
// 模式 A:无 OSS 配置 → 打印手动命令
|
||||
// ============================================================
|
||||
|
||||
if (!OSS_ENDPOINT || !OSS_BUCKET || !OSS_ACCESS_KEY_ID) {
|
||||
const exampleBucket = OSS_BUCKET || '<bucket>';
|
||||
const exampleEndpoint = OSS_ENDPOINT || '<endpoint>';
|
||||
const downloadUrl = `https://${exampleBucket}.${exampleEndpoint}/${installerObjectKey}`;
|
||||
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('📦 上传安装包 + blockmap(手动)');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log(`渠道: ${channel}`);
|
||||
console.log(`OSS 路径: ${ossPrefix}/`);
|
||||
console.log(`文件: ${installerPath}`);
|
||||
console.log(`大小: ${(readFileSync(installerPath).length / 1024 / 1024).toFixed(2)} MB\n`);
|
||||
|
||||
if (blockmapPath) {
|
||||
console.log(`Blockmap: ${blockmapPath}`);
|
||||
console.log(`Blockmap 大小: ${(readFileSync(blockmapPath).length / 1024).toFixed(2)} KB\n`);
|
||||
}
|
||||
|
||||
console.log('方式 1 — ossutil CLI(推荐):');
|
||||
console.log(` ossutil cp "${installerPath}" oss://${exampleBucket}/${installerObjectKey}`);
|
||||
if (blockmapPath) {
|
||||
const blockmapKey = `${ossPrefix}/${blockmapName}`;
|
||||
console.log(` ossutil cp "${blockmapPath}" oss://${exampleBucket}/${blockmapKey}`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
console.log('方式 2 — 阿里云控制台:');
|
||||
console.log(` 登录 OSS 控制台 → ${exampleBucket} → 上传 → 目标路径: ${installerObjectKey}`);
|
||||
if (blockmapPath) {
|
||||
console.log(` 同时上传 ${blockmapName} 到相同目录`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
const blockmapUrl = blockmapPath
|
||||
? `https://${exampleBucket}.${exampleEndpoint}/${ossPrefix}/${blockmapName}`
|
||||
: '';
|
||||
|
||||
console.log('上传后执行以下命令,用实际 URL 重新生成 info:');
|
||||
if (blockmapUrl) {
|
||||
console.log(` node build/scripts/generate-update-info.mjs ${platform} ${version} ${channel} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`);
|
||||
console.log('');
|
||||
console.log('或直接发布到后端 API:');
|
||||
console.log(` node build/scripts/publish-release.mjs ${platform} ${version} ${channel} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`);
|
||||
} else {
|
||||
console.log(` node build/scripts/generate-update-info.mjs ${platform} ${version} ${channel} --url "${downloadUrl}"`);
|
||||
console.log('');
|
||||
console.log('或直接发布到后端 API:');
|
||||
console.log(` node build/scripts/publish-release.mjs ${platform} ${version} ${channel} --url "${downloadUrl}"`);
|
||||
}
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 模式 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}"`);
|
||||
}
|
||||
100
electron/main.ts
100
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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
// ============================================================
|
||||
|
||||
import { net } from 'electron';
|
||||
import { logger } from './logger';
|
||||
|
||||
// ---------- 类型 ----------
|
||||
|
||||
@@ -128,9 +129,7 @@ export async function request<T = unknown>(
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>).env
|
||||
? ((import.meta as Record<string, unknown>).env as Record<string, string>).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<string, unknown>).env
|
||||
? ((import.meta as Record<string, unknown>).env as Record<string, string>).VITE_BUILD_EDITION
|
||||
: undefined)
|
||||
|| process.env.VITE_BUILD_EDITION
|
||||
|| 'personal';
|
||||
|
||||
// ---------- 设备指纹(稳定的机器标识,用于灰度分组)----------
|
||||
|
||||
let _deviceFingerprint: string | null = null;
|
||||
|
||||
/** 获取设备指纹(基于机器特定信息的 SHA-256 前 16 位) */
|
||||
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<UpdateInfo> {
|
||||
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() });
|
||||
|
||||
@@ -17,6 +17,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script type="module" src="/src/app/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
802
infinite_canvas_140.md
Normal file
802
infinite_canvas_140.md
Normal file
@@ -0,0 +1,802 @@
|
||||
# Infinite Canvas — PySide6 → React/Electron 重构方案
|
||||
|
||||
> 基于 `infinite_canvas_140/` 源码分析,映射到当前 React + TypeScript + Electron 技术栈。
|
||||
> 重点关注:**文件夹 → 媒体文件映射逻辑**(核心差异点)
|
||||
|
||||
---
|
||||
|
||||
## 一、技术栈映射
|
||||
|
||||
| PySide6(原版) | React/Electron(目标) | 说明 |
|
||||
|------------------------|--------------------------------------|-------------------------------|
|
||||
| `QGraphicsView` | 虚拟滚动 `<canvas>` / `position:absolute` 布局 | 无限画布渲染 |
|
||||
| `QGraphicsScene` | Zustand Store (items 集合) | 场景中所有 item 的状态 |
|
||||
| `QGraphicsItem` | React 组件 + `absolute` 定位 | 单个缩略图卡片 |
|
||||
| `QThreadPool` + `QRunnable` | Web Worker / Electron IPC | 后台任务(扫描/加载) |
|
||||
| `Signal.connect` | Event Bus (`bus.ts`) 已就绪 | 事件解耦 |
|
||||
| `QSharedMemory` | `app.getSingleInstanceLock()` | 单实例检测 |
|
||||
| `QPixmapCache` | `Blob URL` + `Map<cache>` | 图片内存缓存 |
|
||||
| sidecar JSON 文件 | 同方案(独立的 `.meta.json` 文件) | 元数据旁加载 |
|
||||
| `os.walk` 扫描 | Electron `fs.readdir` / IPC | 文件系统遍历 |
|
||||
|
||||
---
|
||||
|
||||
## 二、文件夹 → 媒体文件映射机制(重点)
|
||||
|
||||
### 2.1 目录结构约定
|
||||
|
||||
原版项目的核心映射逻辑在 `ScanCoordinator._build_scan_roots()` 和 `_group_key_for_path()` 中。
|
||||
目录结构遵循固定的 AIGC 生产管线约定:
|
||||
|
||||
```
|
||||
工作区根目录 (ROOT_STORAGE)
|
||||
└── 项目A/
|
||||
├── shots/ ← 分镜/关键帧/视频/音频/口型
|
||||
│ ├── E01/ ← 集数 (episode)
|
||||
│ │ ├── SC001/ ← 镜头号 (shot)
|
||||
│ │ │ ├── Storyboard/ ← 阶段:分镜
|
||||
│ │ │ │ ├── E01_SC001_001.jpg
|
||||
│ │ │ │ ├── E01_SC001_001.meta.json ← 旁加载元数据
|
||||
│ │ │ │ └── ...
|
||||
│ │ │ ├── Keyframe/ ← 阶段:关键帧
|
||||
│ │ │ │ └── ...
|
||||
│ │ │ ├── Video/ ← 阶段:视频
|
||||
│ │ │ │ └── ...
|
||||
│ │ │ ├── Audio/ ← 阶段:音频
|
||||
│ │ │ │ └── ...
|
||||
│ │ │ └── LipSync/ ← 阶段:口型
|
||||
│ │ │ └── ...
|
||||
│ │ └── SC002/
|
||||
│ │ └── ...
|
||||
│ └── E02/
|
||||
│ └── ...
|
||||
└── assets/ ← 资产
|
||||
├── chr/ ← 角色
|
||||
│ ├── 角色名A/
|
||||
│ │ ├── image001.png
|
||||
│ │ ├── image001.meta.json ← 包含 tags, description
|
||||
│ │ └── ...
|
||||
│ └── ...
|
||||
├── prp/ ← 道具
|
||||
└── set/ ← 场景
|
||||
```
|
||||
|
||||
### 2.2 路径 → 阶段/分组 的解析算法
|
||||
|
||||
这是原版的核心逻辑,决定了一个文件属于哪个 **阶段(Stage)** 和哪个 **分组(Group)**。
|
||||
|
||||
```
|
||||
┌─────────────── 解析入口函数 ──────────────────────────────┐
|
||||
|
||||
detectStage(filePath: string): StageProfile
|
||||
├─ 路径含有 "/Storyboard/" → Stage.Storyboard
|
||||
│ ├─ includes: ["jpg","jpeg","png","webp","bmp","gif"]
|
||||
│ ├─ uses_episode: true
|
||||
│ ├─ uses_shot_query: true
|
||||
│ ├─ audit_enabled: true
|
||||
│ └─ sidecar_fields: ["remark", "audit_status"]
|
||||
│
|
||||
├─ 路径含有 "/Keyframe/" → Stage.Keyframe
|
||||
│ ├─ includes: ["jpg","jpeg","png","webp","bmp","gif"]
|
||||
│ ├─ uses_episode: true
|
||||
│ ├─ uses_shot_query: true
|
||||
│ ├─ audit_enabled: true
|
||||
│ └─ sidecar_fields: ["description","remark","audit_status"]
|
||||
│
|
||||
├─ 路径含有 "/Video/" → Stage.Video
|
||||
│ ├─ includes: ["mp4","webm","mov","avi","mkv"]
|
||||
│ ├─ uses_episode: true
|
||||
│ ├─ uses_shot_query: true
|
||||
│ ├─ audit_enabled: true
|
||||
│ └─ sidecar_fields: ["description","remark","audit_status"]
|
||||
│
|
||||
├─ 路径含有 "/Audio/" → Stage.Audio
|
||||
│ ├─ includes: ["mp3","wav","ogg","flac","aac","m4a"]
|
||||
│ ├─ uses_episode: true
|
||||
│ └─ sidecar_fields: ["remark"]
|
||||
│
|
||||
├─ 路径含有 "/LipSync/" → Stage.LipSync
|
||||
│ ├─ includes: ["mp3","wav","ogg","flac","aac","m4a"]
|
||||
│ ├─ uses_episode: true
|
||||
│ └─ sidecar_fields: ["remark"]
|
||||
│
|
||||
└─ 路径含有 "/assets/" → Stage.Assets
|
||||
├─ includes: ["jpg","jpeg","png","webp","bmp","gif","mp4","webm"]
|
||||
└─ sidecar_fields: ["description","tags"]
|
||||
```
|
||||
|
||||
```
|
||||
┌─────────────── 分组键提取 ──────────────────────────────┐
|
||||
|
||||
groupKeyForPath(filePath: string): string
|
||||
│
|
||||
├─ 若 stage === Assets:
|
||||
│ ├─ 向上找 assets/ 目录
|
||||
│ ├─ assets 下一层 → type (chr/prp/set)
|
||||
│ ├─ type 下一层 → name (角色名/道具名/场景名)
|
||||
│ └─ 返回 "{type}/{name}" 例: "chr/主角A"
|
||||
│
|
||||
└─ 若 stage ≠ Assets:
|
||||
├─ 向上找到 shots/ 目录
|
||||
├─ shots 下一层 → episode (E01)
|
||||
├─ episode 下一层 → shot (SC001)
|
||||
└─ 返回 "{episode}/{shot}" 例: "E01/SC001"
|
||||
```
|
||||
|
||||
### 2.3 扫描根目录计算
|
||||
|
||||
```
|
||||
buildScanRoots(projectPath, episodePath, stage): string[]
|
||||
│
|
||||
├─ 若 stage === Assets:
|
||||
│ ├─ assetTypeFilter === "全部" → [project/assets/]
|
||||
│ ├─ "角色" → [project/assets/chr/]
|
||||
│ ├─ "道具" → [project/assets/prp/]
|
||||
│ └─ "场景" → [project/assets/set/]
|
||||
│
|
||||
└─ 若 stage !== Assets:
|
||||
├─ 若指定了 episodePath → episodes = [episodePath]
|
||||
├─ 否则 → episodes = project/shots/ 下的所有子目录
|
||||
└─ 对每个 episode:
|
||||
├─ 列出所有 shot_name 子目录
|
||||
├─ 若 shot_query 不为空 → 过滤匹配的 shot
|
||||
└─ 对每个 shot:追加 {shot_dir}/{stage}/
|
||||
```
|
||||
|
||||
### 2.4 Electron 中的 IPC 实现要点
|
||||
|
||||
由于浏览器没有 `os.walk`,文件扫描必须走 Electron 主进程:
|
||||
|
||||
```
|
||||
渲染进程 主进程
|
||||
───────── ────────
|
||||
useDirectoryScan(projectPath, ipcMain.handle('fs:scan-dir',
|
||||
stage, filters) (event, {roots, exts, recursive}) => {
|
||||
→ contextBridge const results = []
|
||||
.scanDirectory({roots, for (const root of roots) {
|
||||
exts, recursive}) walkSync(root, (file) => {
|
||||
← Promise<CatalogItem[]> if exts.has(ext) {
|
||||
→ dispatch(setCatalog(items)) results.push({
|
||||
path: file,
|
||||
metadata: readSidecar(file)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
return results
|
||||
})
|
||||
|
||||
⚠️ 安全注意:
|
||||
- 扫描根目录必须从用户选择的 workspace root 派生,不能接受任意路径
|
||||
- 使用 contextBridge 暴露白名单方法,禁止渲染进程直接访问 fs 模块
|
||||
- 大目录扫描应分批 emit(每 80 个一批),避免主进程阻塞
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、React 状态架构
|
||||
|
||||
### 3.1 Store 设计 (Zustand)
|
||||
|
||||
对应原版 `CatalogModel` + `SelectionModel` + `CanvasStateMachine` 三者:
|
||||
|
||||
```typescript
|
||||
// stores/useCanvasStore.ts
|
||||
|
||||
interface CanvasState {
|
||||
// ── Catalog(原 CatalogModel._items)──
|
||||
items: CatalogItem[]; // 全量文件列表
|
||||
totalCount: number;
|
||||
|
||||
// ── Selection(原 SelectionModel)──
|
||||
selectedIds: Set<number>;
|
||||
|
||||
// ── Stage & Filters ──
|
||||
currentStage: StageType; // Storyboard | Keyframe | Video | Audio | LipSync | Assets
|
||||
keywordQuery: string;
|
||||
tagQuery: string;
|
||||
auditFilter: AuditFilter; // all | pending | approved | rework | deprecated
|
||||
assetTypeFilter: AssetType; // 全部 | 角色 | 道具 | 场景
|
||||
shotQuery: string;
|
||||
|
||||
// ── Layout ──
|
||||
layoutMode: 'grid' | 'single';
|
||||
cols: number;
|
||||
rowsPerPage: number;
|
||||
singleRowsPerPage: number;
|
||||
showShotPlaceholders: boolean;
|
||||
|
||||
// ── Pagination ──
|
||||
currentPage: number;
|
||||
pageEpoch: number; // 防止过期回调污染的版本号
|
||||
|
||||
// ── Canvas transform ──
|
||||
zoom: number; // 当前缩放倍率
|
||||
panOffset: { x: number; y: number };
|
||||
|
||||
// ── Interaction mode ──
|
||||
mode: 'BROWSE' | 'SELECT' | 'REORDER';
|
||||
|
||||
// ── Meta ──
|
||||
isScanning: boolean;
|
||||
scanProgress: { found: number; total: number | null };
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 CatalogItem 实体
|
||||
|
||||
```typescript
|
||||
interface CatalogItem {
|
||||
id: number; // 全局唯一序号
|
||||
path: string; // 文件系统绝对路径
|
||||
fileName: string; // 文件名(不含路径)
|
||||
ext: string; // 扩展名(小写)
|
||||
stage: StageType; // 所属阶段
|
||||
episodeName: string; // 集数名(从路径提取)
|
||||
shotName: string; // 镜头名(从路径提取)
|
||||
groupKey: string; // 分组键("E01/SC001" 或 "chr/主角A")
|
||||
metadata: MediaMetadata | null; // 旁加载元数据
|
||||
}
|
||||
|
||||
interface MediaMetadata {
|
||||
// 通用字段
|
||||
remark?: string;
|
||||
description?: string; // AIGC Prompt
|
||||
tags?: string[];
|
||||
original_name?: string;
|
||||
|
||||
// 审核字段(仅 Storyboard / Keyframe / Video)
|
||||
audit_status?: 'pending' | 'approved' | 'rework' | 'deprecated';
|
||||
|
||||
// 媒体属性(扫描时填充)
|
||||
width?: number;
|
||||
height?: number;
|
||||
duration?: number; // 视频/音频时长
|
||||
fileSize?: number;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、组件树
|
||||
|
||||
```
|
||||
<InfiniteCanvasPage> ← 路由 /canvas(Drawer 叠加模式)
|
||||
├─ <CanvasStageOverlay> ← 阶段切换 Tab(左上角浮层)
|
||||
├─ <CanvasFilterBar> ← 项目/集数/镜头/关键词/审核筛选(顶部浮层)
|
||||
├─ <CanvasModeBar> ← 模式切换(右下角浮层)
|
||||
│
|
||||
├─ <CanvasViewport> ← 核心画布:CSS transform 驱动的虚拟视口
|
||||
│ │ transform: scale(zoom) translate(panOffset)
|
||||
│ │
|
||||
│ ├─ <ShotPlaceholder>×N ← 占位符(single 模式下空镜头行)
|
||||
│ ├─ <RowLabel>×N ← 行标签(single 模式下左侧镜头号)
|
||||
│ │
|
||||
│ └─ <MediaCard>×N ← 核心渲染单元 = 原版 StoryboardItem
|
||||
│ ├─ props: { item, x, y, w, h, behaviors[] }
|
||||
│ ├─ <CheckerboardBackground /> ← 棋盘格背景(图片加载前)
|
||||
│ ├─ <LODImage /> ← 多级 LOD 图片
|
||||
│ ├─ <AuditBadge /> ← 审核角标(pending/approved/rework/deprecated)
|
||||
│ ├─ <SelectionCheckbox /> ← 选择框(SELECT 模式下可见)
|
||||
│ └─ <MediaCardContextMenu /> ← 右键菜单
|
||||
│
|
||||
├─ <PagerOverlay> ← 分页器(底部居中浮层)
|
||||
├─ <ZoomOverlay> ← 缩放控件(右下角浮层)
|
||||
└─ <QuickEditPanel> ← 快速编辑面板(选中 item 时右侧弹出)
|
||||
```
|
||||
|
||||
### 4.1 为什么用 CSS Transform 而不是 `<canvas>`
|
||||
|
||||
| 方案 | 优点 | 缺点 |
|
||||
|---------------|---------------------------|-------------------------------|
|
||||
| `<canvas>` | 像素级控制,无 DOM 开销 | 无法使用 React 组件、无障碍差 |
|
||||
| **CSS Transform** | 天然 React 友好、CSS 动画 | 大量 item 时 DOM 节点多 |
|
||||
|
||||
选择 CSS Transform + **虚拟化**:只渲染视口内的 item,离屏 item 不挂载 DOM。
|
||||
这等价于原版的分页机制——用户只会看到当前页的 ~200-400 个 item。
|
||||
|
||||
---
|
||||
|
||||
## 五、LOD 图片加载策略
|
||||
|
||||
### 5.1 缩略图尺寸分级
|
||||
|
||||
```
|
||||
Level 0: 512px — 缩略图(快速扫描/小缩放)
|
||||
Level 1: 1024px — 中等(1:1 显示)
|
||||
Level 2: 2048px — 高清(放大查看)
|
||||
Level 3: 4096px — 原图(仅在 MediaPreviewWindow 中使用)
|
||||
```
|
||||
|
||||
### 5.2 浏览器端的实现方式
|
||||
|
||||
原版用 `QThreadPool` + `QImage.scaled()` 生成缩略图。浏览器端有两种策略:
|
||||
|
||||
**策略 A:服务端缩略图(推荐)**
|
||||
```
|
||||
GET /api/v1/media/thumbnail?path={path}&size=512
|
||||
→ 后端生成缩略图 → 返回 JPEG/WebP
|
||||
→ 优势:不阻塞渲染进程,支持 CDN 缓存
|
||||
```
|
||||
|
||||
**策略 B:本地缩略图(Electron 离线模式)**
|
||||
```
|
||||
IPC → 主进程 sharp 库缩放 → base64 → Blob URL
|
||||
→ 优势:离线可用,不依赖后端
|
||||
```
|
||||
|
||||
### 5.3 加载调度器设计
|
||||
|
||||
对应原版 `LoadScheduler`:
|
||||
|
||||
```typescript
|
||||
// hooks/useMediaLoader.ts
|
||||
|
||||
class LoadScheduler {
|
||||
private inflight = new Set<string>(); // (path, level) 去重
|
||||
private pending: Array<LoadRequest> = []; // 等待队列
|
||||
private maxConcurrent = 10; // 最大并发
|
||||
|
||||
request(path: string, level: number): Promise<BlobURL> {
|
||||
const key = `${path}@${level}`;
|
||||
if (this.inflight.has(key)) {
|
||||
// 已在加载中 → 返回同一个 Promise(复用)
|
||||
return this.pending.find(r => r.key === key)!.promise;
|
||||
}
|
||||
if (this.inflight.size >= this.maxConcurrent) {
|
||||
// 排队等待
|
||||
return this.enqueue(key, path, level);
|
||||
}
|
||||
return this.startLoad(key, path, level);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 视口感知的加载优先级
|
||||
|
||||
```typescript
|
||||
// 在 CanvasViewport 的 scroll/resize 事件中:
|
||||
function updateVisibleItems() {
|
||||
const visible = getVisibleItemIds(viewportRect, allItemRects);
|
||||
// 1. 取消已离开视口的加载请求
|
||||
cancelOutOfView(visible);
|
||||
// 2. 视口中心的 item 优先加载高分辨率
|
||||
const center = viewportCenter();
|
||||
visible.sort((a, b) => distance(center, a) - distance(center, b));
|
||||
// 3. 可见 → level 基于 zoom 决定,不可见 → 不加载
|
||||
for (const item of visible) {
|
||||
const level = zoomToLODLevel(currentZoom);
|
||||
scheduler.request(item.path, level);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、数据流全链路
|
||||
|
||||
### 6.1 打开工作区 → 扫描 → 显示
|
||||
|
||||
```
|
||||
用户选择工作区 (Electron dialog.showOpenDialog)
|
||||
→ IPC: 'fs:open-workspace'
|
||||
→ 主进程验证目录结构(是否有 shots/ 和 assets/)
|
||||
→ 返回 projectList
|
||||
→ store.setCurrentWorkspace(rootDir)
|
||||
|
||||
用户选择项目/集数/阶段
|
||||
→ CanvasFilterBar.onChange
|
||||
→ store.setFilters({project, episode, stage})
|
||||
→ useDirectoryScan hook 自动触发
|
||||
|
||||
useDirectoryScan(stage, scanRoots, filters):
|
||||
→ 防抖 200ms(避免连续点选触发多次扫描)
|
||||
→ IPC: 'fs:scan-dir'({roots, exts, recursive})
|
||||
→ 主进程 walkSync:
|
||||
for each file:
|
||||
if ext in allowedExts:
|
||||
if passesFilters(file, keyword, tag, audit):
|
||||
readSidecar(file) // 并行读取 .meta.json
|
||||
mainWindow.webContents.send('scan:found', {path, metadata})
|
||||
→ 分批 emit(每 80 个一批,30ms 间隔)
|
||||
→ 渲染进程收到 → store.addItems(batch)
|
||||
→ 若 item 在当前页 → 创建 MediaCard 组件
|
||||
→ MediaCard 挂载 → useMediaLoader.request(path) → 异步加载缩略图
|
||||
→ onLoad → setBlobURL → <img src={blobURL} />
|
||||
→ 扫描完成 → store.setScanFinished(true)
|
||||
```
|
||||
|
||||
### 6.2 分页切换
|
||||
|
||||
```
|
||||
PagerOverlay.onClick(page)
|
||||
→ store.requestPage(page)
|
||||
→ 防抖 100ms
|
||||
→ store.gotoPage(page):
|
||||
1. pageEpoch += 1 ← 过期标记
|
||||
2. 中止所有 inflight 加载
|
||||
3. 清空当前页 DOM(卸载 MediaCard 组件)
|
||||
4. 计算 pageBounds(page) ← grid: [start, end];
|
||||
single: [group_start, group_end]
|
||||
5. 创建新的 MediaCard 组件
|
||||
6. 触发 LOD 加载
|
||||
7. 更新 PagerOverlay 显示
|
||||
```
|
||||
|
||||
### 6.3 文件导入(拖放/粘贴)
|
||||
|
||||
```
|
||||
CanvasViewport.onDrop(files)
|
||||
→ 提取文件路径
|
||||
→ 若非 Assets 阶段:
|
||||
ShotPickerDialog: 选择目标集数/镜头
|
||||
→ IPC: 'fs:import-file'({src, destDir, prefix})
|
||||
→ 主进程 fs.copyFile + 创建 .meta.json
|
||||
→ 若 Assets 阶段:
|
||||
AssetImportDialog: 选择资产类型/名称 + 标签
|
||||
→ IPC: 'fs:import-asset'({src, projectPath, assetType, assetName, tags})
|
||||
→ integrationBus.files_changed.emit(importedPaths)
|
||||
→ 触发增量扫描(仅扫描新增文件)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、布局算法
|
||||
|
||||
### 7.1 Grid 模式
|
||||
|
||||
```typescript
|
||||
function gridPosition(itemId: number, page: number, cols: number): Position {
|
||||
const start = (page - 1) * cols * rowsPerPage;
|
||||
const localIdx = itemId - start;
|
||||
const row = Math.floor(localIdx / cols);
|
||||
const col = localIdx % cols;
|
||||
return {
|
||||
x: col * (ITEM_W + ITEM_SPACING),
|
||||
y: row * (ITEM_H + ITEM_SPACING),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 Single 模式(按镜头号分组,每行横向排列)
|
||||
|
||||
```typescript
|
||||
function singlePosition(itemId: number, page: number): Position {
|
||||
const groupIdx = itemToGroup[itemId]; // ← groupKey 映射
|
||||
const groupsOnPage = getGroupBounds(page); // [startGroup, endGroup)
|
||||
const row = groupIdx - groupsOnPage[0];
|
||||
const col = groupItems[groupIdx].indexOf(itemId);
|
||||
return {
|
||||
x: ROW_LABEL_WIDTH + SPACING + col * (ITEM_W + ITEM_SPACING),
|
||||
y: row * (ITEM_H + ITEM_SPACING),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、与现有项目的集成
|
||||
|
||||
### 8.1 路由
|
||||
|
||||
无限画布作为一个 Drawer 叠加层,通过事件总线打开(符合 QT 多窗口模式):
|
||||
|
||||
```typescript
|
||||
// 某个入口按钮:
|
||||
emit(EVENTS.OPEN_PAGE, {
|
||||
target: '/infinite-canvas',
|
||||
modalType: 'drawer',
|
||||
props: {
|
||||
workspaceRoot: '/path/to/project',
|
||||
initialStage: 'Keyframe',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 8.2 复用现有基础设施
|
||||
|
||||
| 现有模块 | 用在哪 |
|
||||
|-----------------------------------|----------------------------|
|
||||
| `src/utils/bus.ts` | 事件解耦(替代 Qt Signal) |
|
||||
| `src/infrastructure/storage/` | 媒体缓存元数据持久化(sql.js)|
|
||||
| `src/services/request.ts` | CDN 缩略图下载(带 Auth) |
|
||||
| `src/utils/media-upload.ts` | buildAccept / 类型校验 |
|
||||
| `src/hooks/use-async.ts` | useAsyncData 加载状态管理 |
|
||||
| `src/components/frame/PageDispatcher.tsx` | Drawer 模式打开画布 |
|
||||
|
||||
### 8.3 新增模块
|
||||
|
||||
```
|
||||
src/pages/infinite-canvas/
|
||||
├── CanvasPage.tsx ← 页面入口(Drawer 内容)
|
||||
├── stores/
|
||||
│ └── useCanvasStore.ts ← Zustand Store(上文设计)
|
||||
├── hooks/
|
||||
│ ├── useDirectoryScan.ts ← IPC 扫描 Hook
|
||||
│ ├── useMediaLoader.ts ← LOD 加载调度
|
||||
│ └── useCanvasInteraction.ts ← 拖拽/缩放/平移交互
|
||||
├── components/
|
||||
│ ├── CanvasViewport.tsx ← 核心画布(CSS Transform + 虚拟化)
|
||||
│ ├── MediaCard.tsx ← 缩略图卡片
|
||||
│ ├── MediaCardContextMenu.tsx ← 右键菜单
|
||||
│ ├── MediaPreview.tsx ← 全屏预览(Lightbox)
|
||||
│ ├── ShotPlaceholder.tsx ← 空镜头占位符
|
||||
│ ├── RowLabel.tsx ← 行标签
|
||||
│ ├── PagerOverlay.tsx ← 分页器浮层
|
||||
│ ├── ZoomOverlay.tsx ← 缩放控件
|
||||
│ ├── StageTabs.tsx ← 阶段切换
|
||||
│ ├── FilterBar.tsx ← 筛选栏
|
||||
│ ├── QuickEditPanel.tsx ← 快速编辑
|
||||
│ ├── ModeSwitch.tsx ← 模式切换
|
||||
│ └── AssetImportDialog.tsx ← 资产导入对话框
|
||||
└── electron/
|
||||
├── scanner-ipc.ts ← 主进程文件扫描 IPC handler
|
||||
└── thumbnail-worker.ts ← 缩略图生成 Worker
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、实施路径(分阶段)
|
||||
|
||||
### Phase 1:目录浏览(文件夹 → 文件映射核心)
|
||||
- [ ] Electron IPC 目录扫描(`scanner-ipc.ts`)
|
||||
- [ ] `buildScanRoots()` + `groupKeyForPath()` 算法实现
|
||||
- [ ] `useCanvasStore` 基础状态
|
||||
- [ ] 静态列表展示扫描结果(先做表格验证,不走画布)
|
||||
|
||||
### Phase 2:画布渲染
|
||||
- [ ] `CanvasViewport` CSS Transform 虚拟视口
|
||||
- [ ] `MediaCard` + 棋盘格背景
|
||||
- [ ] LOD 缩略图加载
|
||||
- [ ] 分页(Grid 模式)
|
||||
|
||||
### Phase 3:交互完善
|
||||
- [ ] BROWSE / SELECT / REORDER 模式
|
||||
- [ ] 右键菜单 + ContextMenuBuilder
|
||||
- [ ] 拖放导入
|
||||
- [ ] 快速编辑面板
|
||||
|
||||
### Phase 4:性能优化
|
||||
- [ ] 虚拟化(只渲染视口内 item)
|
||||
- [ ] Web Worker 缩略图生成
|
||||
- [ ] 防抖/节流优化
|
||||
- [ ] 增量扫描(watchdog)
|
||||
|
||||
---
|
||||
|
||||
## 十、关键差异记录
|
||||
|
||||
| 原版 (PySide6) | 重构版 (React/Electron) | 理由 |
|
||||
|----------------------------------------|---------------------------------------|-------------------------------------|
|
||||
| `QThreadPool` 后台线程扫描 | Electron IPC → 主进程 `fs` 操作 | 浏览器沙箱不能直接操作文件系统 |
|
||||
| `QImage.scaled()` 本地缩略图 | 服务端 API 或 `sharp` 在主进程生成 | 浏览器 Image 解码有限 |
|
||||
| `QGraphicsItem.paint()` 自绘 | React 组件 + `<img>` / `<video>` | React 声明式渲染更易维护 |
|
||||
| QGraphicsScene 自动脏区域管理 | 虚拟化 + React.memo + useMemo | 手动控制渲染范围 |
|
||||
| sidecar 是独立 `.json` 文件 | **同方案** | 保持兼容,不改变存储格式 |
|
||||
| `QSharedMemory` 单实例锁 | `app.requestSingleInstanceLock()` | Electron 标准 API |
|
||||
| `QOpenGLWidget` 硬件加速 | 浏览器 GPU 合成(无需额外操作) | CSS transform 天然触发 GPU 合成 |
|
||||
|
||||
---
|
||||
|
||||
## 十一、补充注意事项
|
||||
|
||||
### 11.1 旁加载元数据 JSON 格式(sidecar)
|
||||
|
||||
原版 `MetadataService` 将元数据和图片文件**同目录、同名、不同扩展名**存放:
|
||||
|
||||
```
|
||||
SC001_Storyboard_001.jpg ← 图片
|
||||
SC001_Storyboard_001.json ← 元数据(同目录)
|
||||
```
|
||||
|
||||
**JSON 结构(`_default_sidecar_metadata`):**
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"favorite": false,
|
||||
"remark": "",
|
||||
"description": "",
|
||||
"tags": [],
|
||||
"create_time": "2026-06-22 14:30:00",
|
||||
"original_name": "SC001_Storyboard_001.jpg",
|
||||
"audit_status": "pending"
|
||||
}
|
||||
```
|
||||
|
||||
⚠️ **关键设计**:
|
||||
- `audit_status` 仅对 **Storyboard / Keyframe / Video** 阶段有效,默认值 `"pending"`
|
||||
- 审核状态流转:`pending → approved → rework → deprecated`
|
||||
- **JSON 文件与图片文件是一一对应关系**,删除图片时需要同时清理 JSON
|
||||
- 重构时必须**读写兼容**已有 JSON 格式(`schema_version` 用于未来迁移)
|
||||
|
||||
### 11.2 审核状态汇聚算法
|
||||
|
||||
原版 `MetadataService` 有两个汇聚函数,用于计算"镜头"和"集数"层的总体审核状态:
|
||||
|
||||
```
|
||||
computeStageAuditStatus(stageDir):
|
||||
对该阶段目录下所有文件:
|
||||
├─ 任一 audit_status === "rework" → 返回 "rework"
|
||||
├─ 全部 audit_status === "approved" → 返回 "approved"
|
||||
└─ 否则 → 返回 "pending"
|
||||
|
||||
computeShotAuditStatus(shotDir):
|
||||
收集 [Storyboard, Keyframe, Video] 三个阶段的状态:
|
||||
├─ 任一阶段 "rework" → 返回 "rework"
|
||||
├─ 任一阶段 "pending" → 返回 "pending"
|
||||
└─ 全部 "approved" → 返回 "approved"
|
||||
```
|
||||
|
||||
Web 端需要在扫描完成后**批量计算**这些汇聚值,缓存到 Store 中供 UI 角标使用。
|
||||
|
||||
### 11.3 Shot 名称匹配算法(`shot_query` 过滤)
|
||||
|
||||
```
|
||||
shotNameMatchesQuery(shotName: string, query: string): boolean
|
||||
├─ query 为空 → 匹配所有
|
||||
├─ query.toUpperCase() in shotName.toUpperCase() → 匹配
|
||||
├─ query 为纯数字 → 提取 shotName 中所有数字位后比较 → 匹配
|
||||
│ 例: query="1" 匹配 "SC001"、"SC010"、"EP01_SHOT_1"
|
||||
└─ 不匹配 → 过滤掉该镜头目录
|
||||
```
|
||||
|
||||
这个算法是用户在筛选栏输入 `1` 就能过滤出所有编号含 1 的镜头的关键。
|
||||
|
||||
### 11.4 CanvasLauncher 上下文推导
|
||||
|
||||
原版 `CanvasLauncher.launch()` 在打开无限画布时,会根据当前 Tab 和选中路径**自动推导** projectPath 和 episodePath:
|
||||
|
||||
```
|
||||
若 tab 为 Storyboard/Keyframe/Video/Audio/LipSync:
|
||||
├─ episodePath 为空 → 从 currentShotPath 向上取一级(父目录)
|
||||
└─ projectPath 为空 → 沿 episodePath 向上找到 "shots" 的父目录
|
||||
|
||||
若 tab 为 Assets:
|
||||
└─ 只需要 projectPath,不需要 episodePath
|
||||
```
|
||||
|
||||
重构时 `CanvasPage` 的打开入口也需要实现同样的推导逻辑,确保用户在 Tab 中选中镜头后点击"无限画布"能直接定位到正确目录。
|
||||
|
||||
### 11.5 Behavior 链在 React 中的落地
|
||||
|
||||
原版的 `IItemBehavior` 协议链是运行时组合的。React 中建议**编译时组合**:
|
||||
|
||||
```typescript
|
||||
// 原版:运行时循环调用
|
||||
// for (const behavior of behaviors) {
|
||||
// if (behavior.onMousePress(item, event)) break;
|
||||
// }
|
||||
|
||||
// React 版:编译时 HOC / Hook 组合
|
||||
function MediaCard({ item, mode, ... }: MediaCardProps) {
|
||||
// 每个行为是一个 Hook,按需启用
|
||||
const selection = useSelectionBehavior(item, { enabled: mode !== 'BROWSE' });
|
||||
const contextMenu = useContextMenuBehavior(item, { builder: menuBuilder });
|
||||
const drag = useDragBehavior(item, { enabled: mode !== 'REORDER' });
|
||||
const preview = usePreviewBehavior(item);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={selection.handleClick}
|
||||
onContextMenu={contextMenu.handleContextMenu}
|
||||
onDragStart={drag.handleDragStart}
|
||||
onDoubleClick={preview.handleDoubleClick}
|
||||
// ...
|
||||
>
|
||||
{/* ... */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 11.6 跨窗口通信(Canvas ↔ 宿主 Tab)
|
||||
|
||||
原版通过 `IntegrationBus`(全局单例 Signal)实现画布修改后**通知宿主 Tab 刷新**:
|
||||
|
||||
```
|
||||
无限画布中修改了 sidecar 元数据
|
||||
→ MetadataService.update_sidecar_metadata()
|
||||
→ EventBus.sidecar_changed.emit([path])
|
||||
→ IntegrationBus.sidecar_changed.emit([path]) ← 全局信号
|
||||
→ CanvasLauncher 中的 refresh_timer (250ms 防抖)
|
||||
→ 宿主 Tab 调用 refresh_callback(tabName)
|
||||
→ Tab 重新加载数据
|
||||
```
|
||||
|
||||
React 版中,这个链路可以用**事件总线 + 防抖**实现:
|
||||
|
||||
```typescript
|
||||
// 画布中:
|
||||
emit(EVENTS.SIDECAR_CHANGED, { paths: [changedPath] });
|
||||
|
||||
// CanvasLauncher/PageDispatcher 中:
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const handler = () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => refreshCurrentTab(), 250);
|
||||
};
|
||||
on(EVENTS.SIDECAR_CHANGED, handler);
|
||||
return () => off(EVENTS.SIDECAR_CHANGED, handler);
|
||||
}, []);
|
||||
```
|
||||
|
||||
同样,`files_changed`(文件导入后)也需要通知宿主刷新。
|
||||
|
||||
### 11.7 文件变更检测(增量扫描)
|
||||
|
||||
原版通过 `integration_bus.files_changed` 支持外部文件变化后触发 `refresh_all()`。重构时需要实现:
|
||||
|
||||
- **方案 A**:Electron 主进程使用 `fs.watch`(或 `chokidar`)监听工作区目录变化 → IPC 通知渲染进程
|
||||
- **方案 B**:每次画布窗口获得焦点时,对比文件列表指纹(最后修改时间),有变化才重新扫描
|
||||
- ⚠️ **推荐方案 B**:`fs.watch` 在大目录下有性能问题,而且用户可能通过其他程序修改文件
|
||||
|
||||
### 11.8 DOM 渲染数量的硬上限
|
||||
|
||||
原版通过**分页**自然限制同时渲染的 item 数量(200-400 个)。React 版即使做虚拟化,也应该设置硬上限:
|
||||
|
||||
| 布局模式 | Max items/页 | 说明 |
|
||||
|---------|-------------|------|
|
||||
| Grid | `cols × rows`(默认 9×16=144) | 网格布局 |
|
||||
| Single | `singleRowsPerPage × 平均每组文件数` | 可能达到数百个 |
|
||||
|
||||
- ⚠️ 如果 `singleRowsPerPage` 设为 9999("显示全部"),渲染 10000+ 个 MediaCard 会导致浏览器卡死
|
||||
- 建议强制上限:**单页最多渲染 500 个 item**,超过时自动分割
|
||||
|
||||
### 11.9 Blob URL 生命周期管理
|
||||
|
||||
原版 QPixmap 由 Qt 自动管理。React 版的 `URL.createObjectURL(blob)` 必须显式释放:
|
||||
|
||||
```typescript
|
||||
// MediaCard 组件:
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// 组件卸载时释放 blob URL
|
||||
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
||||
};
|
||||
}, [blobUrl]);
|
||||
|
||||
// 切页时批量释放:
|
||||
function gotoPage(newPage: number) {
|
||||
// 👇 必须在 scene.clear() 前释放所有 blob URL
|
||||
for (const item of currentPageItems) {
|
||||
if (item.blobUrl) URL.revokeObjectURL(item.blobUrl);
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
⚠️ **不释放的后果**:每个 blob URL 占用内存直到页面关闭,浏览数百页后可能耗尽浏览器内存。
|
||||
|
||||
### 11.10 棋盘格背景的 CSS 实现
|
||||
|
||||
原版用 `QPainter` 绘制 2×2 像素棋盘格。CSS 实现:
|
||||
|
||||
```css
|
||||
.media-card-checkerboard {
|
||||
background-image:
|
||||
linear-gradient(45deg, #e0e0e0 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #e0e0e0 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #e0e0e0 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #e0e0e0 75%);
|
||||
background-size: 16px 16px;
|
||||
background-position: 0 0, 0 8px, 8px -8px, -8px 0px;
|
||||
}
|
||||
```
|
||||
|
||||
比原版的 QImage 缓存方案简单得多,且纯 CSS 零内存开销。
|
||||
|
||||
### 11.11 树浏览器(ShotBrowser)与画布的联动
|
||||
|
||||
原版 `AIGCManagerApp` 左侧的 `ShotBrowser`(项目→集数→镜头三层树)通过 `shotSelected` 信号驱动右侧 Tab 加载。重构时这块逻辑可以复用项目已有的 `ModelSelector` / 树形组件模式:
|
||||
|
||||
```
|
||||
左侧 ShotBrowser(树组件) 右侧画布
|
||||
───────────────────── ────────
|
||||
选择项目 → setProject(projectPath) → FilterBar 更新项目下拉
|
||||
选择集数 → setEpisode(episodePath) → FilterBar 更新集数下拉
|
||||
选择镜头 → setShot(shotPath) → 按镜头筛选 + 切换 StageTab
|
||||
```
|
||||
|
||||
树节点通过 `computeShotAuditStatus()` 计算审核状态角标颜色(绿=全通过、黄=待审核、红=需返工)。
|
||||
645
package-lock.json
generated
645
package-lock.json
generated
@@ -14,6 +14,7 @@
|
||||
"electron-updater": "^6.8.3",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"sharp": "^0.35.2",
|
||||
"sql.js": "^1.14.1",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
@@ -24,6 +25,7 @@
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/react": "^19.2.16",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/sharp": "^0.31.1",
|
||||
"@types/sql.js": "^1.4.11",
|
||||
"@typescript-eslint/eslint-plugin": "^8.60.1",
|
||||
"@typescript-eslint/parser": "^8.60.1",
|
||||
@@ -574,9 +576,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/get/node_modules/undici": {
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.0.tgz",
|
||||
"integrity": "sha512-+t2Z/GwkZQDtu00813aP66ygViGtPHKhhoFZpQKpKrE+9jIgES+Zw+mFNaDWOVRKiuJjuqKHzD3B1sfGg8+ZOQ==",
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
|
||||
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -1069,6 +1071,564 @@
|
||||
"url": "https://github.com/sponsors/nzakas"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz",
|
||||
"integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz",
|
||||
"integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz",
|
||||
"integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz",
|
||||
"integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz",
|
||||
"integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz",
|
||||
"integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz",
|
||||
"integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz",
|
||||
"integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz",
|
||||
"integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz",
|
||||
"integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz",
|
||||
"integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz",
|
||||
"integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz",
|
||||
"integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz",
|
||||
"integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz",
|
||||
"integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz",
|
||||
"integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz",
|
||||
"integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz",
|
||||
"integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz",
|
||||
"integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz",
|
||||
"integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz",
|
||||
"integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz",
|
||||
"integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==",
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.11.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32/node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz",
|
||||
"integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz",
|
||||
"integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz",
|
||||
"integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz",
|
||||
"integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
@@ -2711,6 +3271,16 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sharp": {
|
||||
"version": "0.31.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/sharp/-/sharp-0.31.1.tgz",
|
||||
"integrity": "sha512-5nWwamN9ZFHXaYEincMSuza8nNfOof8nmO+mcI+Agx1uMUk4/pQnNIcix+9rLPXzKrm1pS34+6WRDbDV0Jn7ag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sql.js": {
|
||||
"version": "1.4.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/sql.js/-/sql.js-1.4.11.tgz",
|
||||
@@ -4215,7 +4785,6 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -5101,16 +5670,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -7459,10 +8028,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
|
||||
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
|
||||
"dev": true,
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -7510,6 +8078,50 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz",
|
||||
"integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.1.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
"semver": "^7.8.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.35.2",
|
||||
"@img/sharp-darwin-x64": "0.35.2",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.2",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.1",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.1",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.1",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.1",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.1",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.1",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.1",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.1",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.1",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.1",
|
||||
"@img/sharp-linux-arm": "0.35.2",
|
||||
"@img/sharp-linux-arm64": "0.35.2",
|
||||
"@img/sharp-linux-ppc64": "0.35.2",
|
||||
"@img/sharp-linux-riscv64": "0.35.2",
|
||||
"@img/sharp-linux-s390x": "0.35.2",
|
||||
"@img/sharp-linux-x64": "0.35.2",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.2",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.2",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.2",
|
||||
"@img/sharp-win32-arm64": "0.35.2",
|
||||
"@img/sharp-win32-ia32": "0.35.2",
|
||||
"@img/sharp-win32-x64": "0.35.2"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -7908,7 +8520,6 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
@@ -7947,9 +8558,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "6.26.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz",
|
||||
"integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==",
|
||||
"version": "6.27.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz",
|
||||
"integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
190
package.json
190
package.json
@@ -1,118 +1,76 @@
|
||||
{
|
||||
"name": "ele-heixiu",
|
||||
"private": true,
|
||||
"version": "0.0.18",
|
||||
"description": "船长·HeiXiu — 桌面效率工作台",
|
||||
"author": "HeiXiu 杨烨",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "chcp 65001 > null && cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ vite",
|
||||
"build": "node build.mjs --win --build",
|
||||
"build:win": "node build.mjs --win --build",
|
||||
"build:mac": "node build.mjs --mac --build",
|
||||
"build:linux": "node build.mjs --linux --build",
|
||||
"build:clean": "node scripts/clean.mjs",
|
||||
"upload:oss": "node scripts/upload-oss.mjs",
|
||||
"publish:release": "node scripts/publish-release.mjs",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,css}\" \"electron/**/*.ts\" \"shared/**/*.ts\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\" \"electron/**/*.ts\" \"shared/**/*.ts\"",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-buddy/ide-toolbox": "^2.5.0",
|
||||
"antd": "^6.4.3",
|
||||
"axios": "^1.16.1",
|
||||
"electron-updater": "^6.8.3",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"sql.js": "^1.14.1",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.heixiu.electron",
|
||||
"productName": "船长·HeiXiu",
|
||||
"asar": true,
|
||||
"directories": {
|
||||
"output": "release/${version}"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"dist-electron"
|
||||
],
|
||||
"electronDownload": {
|
||||
"mirror": "https://npmmirror.com/mirrors/electron/"
|
||||
},
|
||||
"publish": {
|
||||
"provider": "generic",
|
||||
"url": "https://update.heixiu.com"
|
||||
},
|
||||
"win": {
|
||||
"icon": "src/assets/logo/heixiu.ico",
|
||||
"target": [
|
||||
{
|
||||
"target": "NSIS",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "${productName}-Windows-${version}-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"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/electron": "^1.4.38",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/react": "^19.2.16",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/sql.js": "^1.4.11",
|
||||
"@typescript-eslint/eslint-plugin": "^8.60.1",
|
||||
"@typescript-eslint/parser": "^8.60.1",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "42.3.0",
|
||||
"electron-builder": "^26.8.1",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"postcss": "^8.5.15",
|
||||
"prettier": "^3.8.3",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "8.0.16",
|
||||
"vite-plugin-electron": "^1.0.0",
|
||||
"vite-plugin-electron-renderer": "^1.0.0"
|
||||
},
|
||||
"main": "dist-electron/main.js"
|
||||
"name": "ele-heixiu",
|
||||
"private": true,
|
||||
"version": "0.0.28",
|
||||
"description": "船长·HeiXiu — 桌面效率工作台",
|
||||
"author": "HeiXiu 杨烨",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "chcp 65001 > null && cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ vite",
|
||||
"build": "node build/build.mjs --win --build",
|
||||
"build:win": "node build/build.mjs --win --build",
|
||||
"build:win:enterprise": "node build/build.mjs --win --edition enterprise --build",
|
||||
"build:win:beta": "node build/build.mjs --win --channel beta --build",
|
||||
"build:win:alpha": "node build/build.mjs --win --channel alpha --build",
|
||||
"build:mac": "node build/build.mjs --mac --build",
|
||||
"build:mac:enterprise": "node build/build.mjs --mac --edition enterprise --build",
|
||||
"build:linux": "node build/build.mjs --linux --build",
|
||||
"build:linux:enterprise": "node build/build.mjs --linux --edition enterprise --build",
|
||||
"build:clean": "node build/scripts/clean.mjs",
|
||||
"upload:oss": "node build/scripts/upload-oss.mjs",
|
||||
"publish:release": "node build/scripts/publish-release.mjs",
|
||||
"version:patch": "node build/scripts/bump-version.mjs patch",
|
||||
"version:minor": "node build/scripts/bump-version.mjs minor",
|
||||
"version:major": "node build/scripts/bump-version.mjs major",
|
||||
"version:pre-beta": "node build/scripts/bump-version.mjs pre beta",
|
||||
"version:pre-alpha": "node build/scripts/bump-version.mjs pre alpha",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,css}\" \"electron/**/*.ts\" \"shared/**/*.ts\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\" \"electron/**/*.ts\" \"shared/**/*.ts\"",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-buddy/ide-toolbox": "^2.5.0",
|
||||
"antd": "^6.4.3",
|
||||
"axios": "^1.16.1",
|
||||
"electron-updater": "^6.8.3",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"sharp": "^0.35.2",
|
||||
"sql.js": "^1.14.1",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/electron": "^1.4.38",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/react": "^19.2.16",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/sharp": "^0.31.1",
|
||||
"@types/sql.js": "^1.4.11",
|
||||
"@typescript-eslint/eslint-plugin": "^8.60.1",
|
||||
"@typescript-eslint/parser": "^8.60.1",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "42.3.0",
|
||||
"electron-builder": "^26.8.1",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"postcss": "^8.5.15",
|
||||
"prettier": "^3.8.3",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "8.0.16",
|
||||
"vite-plugin-electron": "^1.0.0",
|
||||
"vite-plugin-electron-renderer": "^1.0.0"
|
||||
},
|
||||
"main": "dist-electron/main.js"
|
||||
}
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
// ============================================================
|
||||
// 上传安装包 + blockmap 文件到阿里云 OSS
|
||||
//
|
||||
// 用法:
|
||||
// node scripts/upload-oss.mjs <platform> [version]
|
||||
//
|
||||
// 环境变量(使用 Node.js 直传时需要):
|
||||
// OSS_ENDPOINT — OSS endpoint(如 oss-cn-hangzhou.aliyuncs.com)
|
||||
// OSS_BUCKET — Bucket 名称
|
||||
// OSS_ACCESS_KEY_ID — AccessKey ID
|
||||
// OSS_ACCESS_KEY_SECRET — AccessKey Secret
|
||||
// OSS_BASE_PATH — Bucket 内基路径,默认 releases
|
||||
//
|
||||
// 不传 OSS 环境变量时 → 打印手动上传命令(ossutil / 控制台)
|
||||
//
|
||||
// 示例:
|
||||
// # 仅打印上传命令
|
||||
// node scripts/upload-oss.mjs win32 0.1.0
|
||||
//
|
||||
// # 直接上传
|
||||
// OSS_BUCKET=heixiu OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com \
|
||||
// OSS_ACCESS_KEY_ID=xxx OSS_ACCESS_KEY_SECRET=xxx \
|
||||
// node scripts/upload-oss.mjs win32 0.1.0
|
||||
// ============================================================
|
||||
|
||||
import {readFileSync, readdirSync} from 'node:fs';
|
||||
import {join, dirname} from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {createHmac} from 'node:crypto';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..');
|
||||
|
||||
// ---------- 参数 ----------
|
||||
|
||||
const platform = process.argv[2];
|
||||
|
||||
if (!platform) {
|
||||
console.error('用法: node scripts/upload-oss.mjs <platform> [version]');
|
||||
console.error('示例: node scripts/upload-oss.mjs win32 0.1.0');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
|
||||
const version = process.argv[3] || pkg.version;
|
||||
|
||||
// ---------- 查找安装包 ----------
|
||||
|
||||
const releaseDir = join(ROOT, 'release', version);
|
||||
|
||||
let installerName;
|
||||
try {
|
||||
const all = readdirSync(releaseDir);
|
||||
const exts = platform === 'win32' ? ['.exe'] : platform === 'darwin' ? ['.dmg'] : ['.AppImage'];
|
||||
installerName = all.find((f) => exts.some((e) => f.endsWith(e)));
|
||||
if (!installerName) {
|
||||
console.error(`错误: ${releaseDir} — 未找到安装包`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`错误: ${releaseDir} — ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const installerPath = join(releaseDir, installerName);
|
||||
|
||||
// ---------- 查找 blockmap 文件 ----------
|
||||
|
||||
let blockmapName = null;
|
||||
let blockmapPath = null;
|
||||
try {
|
||||
const all = readdirSync(releaseDir);
|
||||
blockmapName = all.find((f) => f.endsWith('.blockmap'));
|
||||
if (blockmapName) {
|
||||
blockmapPath = join(releaseDir, blockmapName);
|
||||
}
|
||||
} catch {
|
||||
// blockmap 不存在不影响主流程
|
||||
}
|
||||
|
||||
// ---------- OSS 配置 ----------
|
||||
|
||||
const {
|
||||
OSS_ENDPOINT,
|
||||
OSS_BUCKET,
|
||||
OSS_ACCESS_KEY_ID,
|
||||
OSS_ACCESS_KEY_SECRET,
|
||||
} = process.env;
|
||||
|
||||
const basePath = (process.env.OSS_BASE_PATH || 'releases').replace(/^\/+|\/+$/g, '');
|
||||
const objectKey = `${basePath}/${version}/${installerName}`;
|
||||
|
||||
// ============================================================
|
||||
// 模式 A:无 OSS 配置 → 打印手动命令
|
||||
// ============================================================
|
||||
|
||||
if (!OSS_ENDPOINT || !OSS_BUCKET || !OSS_ACCESS_KEY_ID) {
|
||||
const exampleBucket = OSS_BUCKET || '<bucket>';
|
||||
const exampleEndpoint = OSS_ENDPOINT || '<endpoint>';
|
||||
const downloadUrl = `https://${exampleBucket}.${exampleEndpoint}/${objectKey}`;
|
||||
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('📦 上传安装包 + blockmap(手动)');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log(`文件: ${installerPath}`);
|
||||
console.log(`大小: ${(readFileSync(installerPath).length / 1024 / 1024).toFixed(2)} MB\n`);
|
||||
|
||||
if (blockmapPath) {
|
||||
console.log(`Blockmap: ${blockmapPath}`);
|
||||
console.log(`Blockmap 大小: ${(readFileSync(blockmapPath).length / 1024).toFixed(2)} KB\n`);
|
||||
}
|
||||
|
||||
console.log('方式 1 — ossutil CLI(推荐):');
|
||||
console.log(` ossutil cp "${installerPath}" oss://${exampleBucket}/${objectKey}`);
|
||||
if (blockmapPath) {
|
||||
const blockmapKey = `${basePath}/${version}/${blockmapName}`;
|
||||
console.log(` ossutil cp "${blockmapPath}" oss://${exampleBucket}/${blockmapKey}`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
console.log('方式 2 — 阿里云控制台:');
|
||||
console.log(` 登录 OSS 控制台 → ${exampleBucket} → 上传 → 目标路径: ${objectKey}`);
|
||||
if (blockmapPath) {
|
||||
console.log(` 同时上传 ${blockmapName} 到相同目录`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
const blockmapUrl = blockmapPath
|
||||
? `https://${exampleBucket}.${exampleEndpoint}/${basePath}/${version}/${blockmapName}`
|
||||
: '';
|
||||
|
||||
console.log('上传后执行以下命令,用实际 URL 重新生成 info:');
|
||||
if (blockmapUrl) {
|
||||
console.log(` node scripts/generate-update-info.mjs ${platform} ${version} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`);
|
||||
console.log('');
|
||||
console.log('或直接发布到后端 API:');
|
||||
console.log(` node scripts/publish-release.mjs ${platform} ${version} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`);
|
||||
} else {
|
||||
console.log(` node scripts/generate-update-info.mjs ${platform} ${version} --url "${downloadUrl}"`);
|
||||
console.log('');
|
||||
console.log('或直接发布到后端 API:');
|
||||
console.log(` node scripts/publish-release.mjs ${platform} ${version} --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}/${objectKey}`,
|
||||
].join('\n');
|
||||
const signature = createHmac('sha1', OSS_ACCESS_KEY_SECRET)
|
||||
.update(stringToSign)
|
||||
.digest('base64');
|
||||
const authHeader = `OSS ${OSS_ACCESS_KEY_ID}:${signature}`;
|
||||
const downloadUrl = `https://${OSS_BUCKET}.${OSS_ENDPOINT}/${objectKey}`;
|
||||
|
||||
console.log(`上传中: ${installerName} (${(fileBuffer.length / 1024 / 1024).toFixed(2)} MB)`);
|
||||
console.log(`目标: oss://${OSS_BUCKET}/${objectKey}`);
|
||||
|
||||
try {
|
||||
const response = await fetch(downloadUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': String(fileBuffer.length),
|
||||
'Date': date,
|
||||
'Authorization': authHeader,
|
||||
},
|
||||
body: fileBuffer,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
console.error(`❌ 安装包上传失败 HTTP ${response.status}: ${body}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`✅ 安装包上传成功`);
|
||||
console.log(`下载 URL: ${downloadUrl}`);
|
||||
} catch (err) {
|
||||
console.error(`❌ 安装包上传失败: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 上传 blockmap 文件(如果存在)
|
||||
let blockmapUrl = null;
|
||||
if (blockmapPath) {
|
||||
const blockmapBuffer = readFileSync(blockmapPath);
|
||||
const blockmapKey = `${basePath}/${version}/${blockmapName}`;
|
||||
blockmapUrl = `https://${OSS_BUCKET}.${OSS_ENDPOINT}/${blockmapKey}`;
|
||||
|
||||
const bmDate = new Date().toUTCString();
|
||||
const bmStringToSign = [
|
||||
'PUT', '', 'application/octet-stream', bmDate, `/${OSS_BUCKET}/${blockmapKey}`,
|
||||
].join('\n');
|
||||
const bmSignature = createHmac('sha1', OSS_ACCESS_KEY_SECRET)
|
||||
.update(bmStringToSign)
|
||||
.digest('base64');
|
||||
|
||||
console.log(`\n上传 blockmap: ${blockmapName} (${(blockmapBuffer.length / 1024).toFixed(2)} KB)`);
|
||||
|
||||
try {
|
||||
const bmResponse = await fetch(blockmapUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Length': String(blockmapBuffer.length),
|
||||
'Date': bmDate,
|
||||
'Authorization': `OSS ${OSS_ACCESS_KEY_ID}:${bmSignature}`,
|
||||
},
|
||||
body: blockmapBuffer,
|
||||
});
|
||||
|
||||
if (bmResponse.ok) {
|
||||
console.log(`✅ Blockmap 上传成功`);
|
||||
console.log(`Blockmap URL: ${blockmapUrl}`);
|
||||
} else {
|
||||
console.warn(`⚠️ Blockmap 上传失败(不影响主流程),HTTP ${bmResponse.status}`);
|
||||
blockmapUrl = null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ Blockmap 上传失败(不影响主流程): ${err.message}`);
|
||||
blockmapUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 后续步骤提示
|
||||
console.log('\n接下来 — 重新生成 update-info.json:');
|
||||
if (blockmapUrl) {
|
||||
console.log(` node scripts/generate-update-info.mjs ${platform} ${version} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`);
|
||||
console.log('\n或直接发布到后端 API:');
|
||||
console.log(` node scripts/publish-release.mjs ${platform} ${version} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`);
|
||||
} else {
|
||||
console.log(` node scripts/generate-update-info.mjs ${platform} ${version} --url "${downloadUrl}"`);
|
||||
console.log('\n或直接发布到后端 API:');
|
||||
console.log(` node scripts/publish-release.mjs ${platform} ${version} --url "${downloadUrl}"`);
|
||||
}
|
||||
75
shared/constants/keyboard.ts
Normal file
75
shared/constants/keyboard.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// ============================================================
|
||||
// Keyboard Key Constants — 键盘按键枚举值
|
||||
//
|
||||
// 用法:
|
||||
// import { Key } from '@shared/constants/keyboard';
|
||||
// if (e.key === Key.Delete) { ... }
|
||||
// if (e.key === Key.ArrowLeft) { ... }
|
||||
//
|
||||
// 避免手动编写字符串,防止拼写错误。
|
||||
// ============================================================
|
||||
|
||||
export const Key = {
|
||||
// ── 导航键 ──
|
||||
Enter: 'Enter',
|
||||
Escape: 'Escape',
|
||||
Tab: 'Tab',
|
||||
Space: ' ',
|
||||
Backspace: 'Backspace',
|
||||
Delete: 'Delete',
|
||||
|
||||
// ── 方向键 ──
|
||||
ArrowUp: 'ArrowUp',
|
||||
ArrowDown: 'ArrowDown',
|
||||
ArrowLeft: 'ArrowLeft',
|
||||
ArrowRight: 'ArrowRight',
|
||||
|
||||
// ── 定位键 ──
|
||||
Home: 'Home',
|
||||
End: 'End',
|
||||
PageUp: 'PageUp',
|
||||
PageDown: 'PageDown',
|
||||
|
||||
// ── 修饰键 ──
|
||||
Control: 'Control',
|
||||
Shift: 'Shift',
|
||||
Alt: 'Alt',
|
||||
Meta: 'Meta',
|
||||
|
||||
// ── 字母 ──
|
||||
A: 'a', B: 'b', C: 'c', D: 'd',
|
||||
E: 'e', F: 'f', G: 'g', H: 'h',
|
||||
I: 'i', J: 'j', K: 'k', L: 'l',
|
||||
M: 'm', N: 'n', O: 'o', P: 'p',
|
||||
Q: 'q', R: 'r', S: 's', T: 't',
|
||||
U: 'u', V: 'v', W: 'w', X: 'x',
|
||||
Y: 'y', Z: 'z',
|
||||
|
||||
// ── 数字 ──
|
||||
Digit0: '0',
|
||||
Digit1: '1',
|
||||
Digit2: '2',
|
||||
Digit3: '3',
|
||||
Digit4: '4',
|
||||
Digit5: '5',
|
||||
Digit6: '6',
|
||||
Digit7: '7',
|
||||
Digit8: '8',
|
||||
Digit9: '9',
|
||||
|
||||
// ── 功能键 ──
|
||||
F1: 'F1',
|
||||
F2: 'F2',
|
||||
F3: 'F3',
|
||||
F4: 'F4',
|
||||
F5: 'F5',
|
||||
F6: 'F6',
|
||||
F7: 'F7',
|
||||
F8: 'F8',
|
||||
F9: 'F9',
|
||||
F10: 'F10',
|
||||
F11: 'F11',
|
||||
F12: 'F12',
|
||||
} as const;
|
||||
|
||||
export type Key = (typeof Key)[keyof typeof Key];
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { App as AntdApp } from 'antd';
|
||||
|
||||
import { useAppContext } from '@/components/providers/AppProvider';
|
||||
import { LoginPage } from './pages/auth';
|
||||
import { SettingsPage } from './pages/settings';
|
||||
import { on, off, emit, EVENTS } from './utils/bus';
|
||||
import type { BusinessErrorEvent } from './utils/bus';
|
||||
import { Layout } from '@/components/frame/Layout';
|
||||
import { HomePage } from '@/pages/home/HomePage';
|
||||
import { PageDispatcher } from '@/components/frame/PageDispatcher';
|
||||
import { useAppContext } from '@/app/providers/AppProvider';
|
||||
import { LoginPage } from '@/modules/auth/views/AuthPage';
|
||||
import { SettingsPage } from '@/modules/settings';
|
||||
import { on, off, emit, EVENTS } from '@/shared/utils/bus';
|
||||
import type { BusinessErrorEvent } from '@/shared/utils/bus';
|
||||
import { Layout } from '@/app/layout/Layout';
|
||||
import { HomePage } from '@/modules/home/views/HomePage';
|
||||
import { PageDispatcher } from '@/app/layout/PageDispatcher';
|
||||
|
||||
/**
|
||||
* 根组件 — 主窗口
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { theme as antTheme } from 'antd';
|
||||
import { NavBar } from './navbar';
|
||||
import { BannerCarousel } from '@/pages/home/features/BannerCarousel';
|
||||
import { NavBar } from '@/modules/navbar';
|
||||
import { BannerCarousel } from '@/modules/home/views/right/BannerCarousel';
|
||||
|
||||
export function Layout({ children }: { children: ReactNode }) {
|
||||
const { token } = antTheme.useToken();
|
||||
@@ -18,15 +18,15 @@
|
||||
|
||||
import {type ComponentType, useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {Drawer, Modal} from 'antd';
|
||||
import {EVENTS, off, on} from '@/utils/bus';
|
||||
import {FloatingPanel} from '@/components/ui/FloatingPanel';
|
||||
import {EVENTS, off, on} from '@/shared/utils/bus';
|
||||
import {FloatingPanel} from '@/shared/components/FloatingPanel';
|
||||
|
||||
import {WechatWorkPage} from '@/pages/panels/WechatWorkPage';
|
||||
import {AccountInfo} from '@/pages/panels/AccountInfo';
|
||||
import {BillingInfo} from '@/pages/panels/BillingInfo';
|
||||
import {RechargePage} from '@/pages/panels/RechargePage';
|
||||
import {OrdersPage} from '@/pages/panels/OrdersPage';
|
||||
import {VipPage} from '@/pages/panels/VipPage';
|
||||
import {WechatWorkPage} from '@/modules/account/views/wechat-work/WechatWorkPage';
|
||||
import {AccountInfo} from '@/modules/account/views/info/AccountPage';
|
||||
import {BillingInfo} from '@/modules/account/views/billing/BillingPage';
|
||||
import {RechargePage} from '@/modules/account/views/recharge/RechargePage';
|
||||
import {OrdersPage} from '@/modules/account/views/orders/OrdersPage';
|
||||
import {VipPage} from '@/modules/account/views/vip/VipPage';
|
||||
|
||||
// ---------- 类型 ----------
|
||||
|
||||
@@ -3,12 +3,12 @@ import ReactDOM from 'react-dom/client';
|
||||
import {App as AntdApp} from 'antd';
|
||||
|
||||
import App from './App';
|
||||
import {ThemeProvider} from './components/providers/ThemeProvider';
|
||||
import {AppProvider} from './components/providers/AppProvider';
|
||||
import {SettingsProvider} from './components/providers/SettingsProvider';
|
||||
import CanvasApp from './pages/infinite-canvas/CanvasApp';
|
||||
import {ThemeProvider} from './providers/ThemeProvider';
|
||||
import {AppProvider} from './providers/AppProvider';
|
||||
import {SettingsProvider} from './providers/SettingsProvider';
|
||||
import CanvasApp from '@/modules/canvas/views/CanvasApp';
|
||||
// 全局样式(包含 Tailwind 指令 + 主题扩展 + 重置)
|
||||
import './theme/globals.css';
|
||||
import '@/shared/theme/globals.css';
|
||||
|
||||
/** 判断当前是否为画布窗口(通过 URL hash 区分) */
|
||||
const isCanvasMode = window.location.hash.startsWith('#/canvas');
|
||||
@@ -38,13 +38,13 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
);
|
||||
|
||||
// 仅在 Electron 环境中监听主进程消息(浏览器预览时跳过)
|
||||
import { safeIpcOn } from './utils/bus';
|
||||
import { safeIpcOn } from '@/shared/utils/bus';
|
||||
safeIpcOn('main-process-message', (_event: unknown, message: unknown) => {
|
||||
console.log('[Main Process Message]:', message);
|
||||
});
|
||||
|
||||
// 渲染进程全局未捕获错误(兜底记录到日志文件)
|
||||
import {logger} from './utils/bus';
|
||||
import {logger} from '@/shared/utils/bus';
|
||||
|
||||
window.addEventListener('error', (event) => {
|
||||
logger.error('ui', 'Uncaught error (renderer)', event.error, {
|
||||
@@ -10,13 +10,13 @@
|
||||
import { createContext, useContext, useState, useMemo, useCallback, useEffect, type ReactNode } from 'react';
|
||||
import type { Platform, Edition } from '@shared/types';
|
||||
|
||||
import { getPlatform, isDev } from '@/utils/env';
|
||||
import { safeIpcOn, safeIpcOff } from '@/utils/bus';
|
||||
import { AuthAPI, type LoginResponseBody } from '@/services/modules/auth';
|
||||
import { setToken, clearToken, initTokenStore } from '@/services/request';
|
||||
import { emit, EVENTS } from '@/utils/bus';
|
||||
import { logger } from '@/utils/bus';
|
||||
import { getAutoLoginFlag, clearAutoLoginFlag } from '@/services/auth-persistence';
|
||||
import { getPlatform, isDev } from '@/shared/utils/env';
|
||||
import { safeIpcOn, safeIpcOff } from '@/shared/utils/bus';
|
||||
import { AuthAPI, type LoginResponseBody } from '@/modules/auth/controllers/auth-api';
|
||||
import { setToken, clearToken, initTokenStore } from '@/shared/services/http';
|
||||
import { emit, EVENTS } from '@/shared/utils/bus';
|
||||
import { logger } from '@/shared/utils/bus';
|
||||
import { getAutoLoginFlag, clearAutoLoginFlag } from '@/modules/auth/controllers/auth-persistence';
|
||||
import {
|
||||
initAuthRefresh,
|
||||
getStoredRefreshToken,
|
||||
@@ -25,9 +25,9 @@ import {
|
||||
initRefreshTokenStore,
|
||||
scheduleProactiveRefresh,
|
||||
clearProactiveRefresh,
|
||||
} from '@/services/auth-token';
|
||||
import {initModelCache} from '@/services/cache/model-cache';
|
||||
import {initStorage} from '@/infrastructure/storage';
|
||||
} from '@/modules/auth/controllers/token-manager';
|
||||
import {initModelCache} from '@/shared/services/cache/model-cache';
|
||||
import {initStorage} from '@/shared/infrastructure/storage';
|
||||
|
||||
// ---------- Context 类型 ----------
|
||||
|
||||
@@ -174,6 +174,9 @@ export function AppProvider({ children }: AppProviderProps) {
|
||||
};
|
||||
setUser(u);
|
||||
emit(EVENTS.LOGIN_SUCCESS, u);
|
||||
}).catch((err) => {
|
||||
if (aborted) return;
|
||||
logger.warn('auth', '自动登录-获取用户信息失败', err instanceof Error ? err : undefined);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -183,6 +186,9 @@ export function AppProvider({ children }: AppProviderProps) {
|
||||
// 仅清除自动登录标记,保留用户名供下次表单回填
|
||||
clearAutoLoginFlag();
|
||||
});
|
||||
}).catch((err) => {
|
||||
if (aborted) return;
|
||||
logger.warn('auth', '应用初始化失败(存储/Token/模型缓存)', err instanceof Error ? err : undefined);
|
||||
});
|
||||
|
||||
return () => { aborted = true; };
|
||||
@@ -8,7 +8,7 @@
|
||||
// ============================================================
|
||||
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||
import { setOutputPath as saveOutputPath, setTeamRepoPath as saveTeamRepoPath } from '@/infrastructure/storage';
|
||||
import { setOutputPath as saveOutputPath, setTeamRepoPath as saveTeamRepoPath } from '@/shared/infrastructure/storage';
|
||||
|
||||
// ---------- localStorage 键 ----------
|
||||
|
||||
@@ -12,7 +12,7 @@ import { flushSync } from 'react-dom';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
|
||||
import { getThemeConfig } from '../../theme/antd-theme';
|
||||
import { getThemeConfig } from '@/shared/theme/antd-theme';
|
||||
|
||||
// ---------- Context 类型 ----------
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { NavBar } from './NavBar';
|
||||
@@ -1,5 +1,5 @@
|
||||
import {type Nullable} from "@/utils/display";
|
||||
import {get} from "@/services/request.ts";
|
||||
import {type Nullable} from "@/shared/utils/display";
|
||||
import {get} from "@/shared/services/http.ts";
|
||||
|
||||
const BillingUrlObj = {
|
||||
getBalance: '/api/v1/billing/balance',
|
||||
@@ -1,4 +1,4 @@
|
||||
import { get, post } from '@/services/request';
|
||||
import { get, post } from '@/shared/services/http';
|
||||
|
||||
export const PAYMENT_METHODS = {
|
||||
Alipay: 'alipay',
|
||||
@@ -1,6 +1,6 @@
|
||||
import {post, get} from '../request';
|
||||
import {type UserInfoBody} from "@/services/modules/auth";
|
||||
import {type Nullable} from "@/utils/display";
|
||||
import {post, get} from '@/shared/services/http';
|
||||
import {type UserInfoBody} from "@/modules/auth/controllers/auth-api";
|
||||
import {type Nullable} from "@/shared/utils/display";
|
||||
|
||||
|
||||
// ============================================================
|
||||
7
src/modules/account/index.ts
Normal file
7
src/modules/account/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// account 模块 — 账户与支付
|
||||
export { AccountInfo } from './views/info/AccountPage';
|
||||
export { VipPage } from './views/vip/VipPage';
|
||||
export { RechargePage } from './views/recharge/RechargePage';
|
||||
export { BillingInfo } from './views/billing/BillingPage';
|
||||
export { OrdersPage } from './views/orders/OrdersPage';
|
||||
export { WechatWorkPage } from './views/wechat-work/WechatWorkPage';
|
||||
@@ -15,10 +15,10 @@ import {
|
||||
RiseOutlined,
|
||||
ApiOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {useBillingBalance, useBillingLedger} from '@/hooks/use-api';
|
||||
import type {EntryTypeValue, LedgerItemResponseBody, LedgerReqeustParam} from '@/services/modules/billing';
|
||||
import {EnumResolver} from '@/utils/enum-resolver';
|
||||
import {centToYuan, formatDate} from '@/utils/display';
|
||||
import {useBillingBalance, useBillingLedger} from '@/shared/hooks/use-api';
|
||||
import type {EntryTypeValue, LedgerItemResponseBody, LedgerReqeustParam} from '@/modules/account/controllers/billing-api';
|
||||
import {EnumResolver} from '@/shared/utils/enum-resolver';
|
||||
import {centToYuan, formatDate} from '@/shared/utils/display';
|
||||
|
||||
const {Text, Title} = Typography;
|
||||
|
||||
@@ -17,10 +17,10 @@ import {
|
||||
BarChartOutlined,
|
||||
RightOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {EnumResolver} from '@/utils/enum-resolver';
|
||||
import type { VipLevelsItem } from '@/services/modules/vip-api';
|
||||
import { useAccountInfo } from '@/hooks/use-api';
|
||||
import { centToYuan, formatDate } from '@/utils/display';
|
||||
import {EnumResolver} from '@/shared/utils/enum-resolver';
|
||||
import type { VipLevelsItem } from '@/modules/account/controllers/vip-api';
|
||||
import { useAccountInfo } from '@/shared/hooks/use-api';
|
||||
import { centToYuan, formatDate } from '@/shared/utils/display';
|
||||
|
||||
const {Text, Title} = Typography;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { logger } from '@/shared/utils/bus';
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
@@ -20,16 +21,16 @@ import {
|
||||
type PaymentStatus,
|
||||
type OrderListResponseBody,
|
||||
type OrderInfoResponseBody,
|
||||
} from '@/services/modules/payments';
|
||||
} from '@/modules/account/controllers/payment-api';
|
||||
import {
|
||||
VipAPI,
|
||||
VipOrderStatus,
|
||||
type VipOrderStatusValue,
|
||||
type VipOrderRead,
|
||||
} from '@/services/modules/vip-api';
|
||||
import { centToYuan, formatDate } from '@/utils/display';
|
||||
import { EnumResolver } from '@/utils/enum-resolver';
|
||||
import { RequestError, RequestErrorType } from '@/services/request';
|
||||
} from '@/modules/account/controllers/vip-api';
|
||||
import { centToYuan, formatDate } from '@/shared/utils/display';
|
||||
import { EnumResolver } from '@/shared/utils/enum-resolver';
|
||||
import { RequestError, RequestErrorType } from '@/shared/services/http';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -120,6 +121,7 @@ export function OrdersPage() {
|
||||
} catch (err) {
|
||||
// axios 拦截器将取消错误转为 RequestError(CANCELLED),非原始 AbortError
|
||||
if (err instanceof RequestError && err.type === RequestErrorType.CANCELLED) return;
|
||||
logger.warn('ui', '加载订单列表失败', err instanceof Error ? err : new Error(String(err)));
|
||||
if (mountedRef.current) {
|
||||
setError((err as Error)?.message || '加载订单列表失败');
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
// ============================================================
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { logger } from '@/shared/utils/bus';
|
||||
import {
|
||||
Radio,
|
||||
InputNumber,
|
||||
@@ -30,16 +31,16 @@ import {
|
||||
DollarOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||
import { LegalTextModal } from '@/components/ui/LegalTextModal';
|
||||
import { useTheme } from '@/app/providers/ThemeProvider';
|
||||
import { LegalTextModal } from '@/shared/components/LegalTextModal';
|
||||
import {
|
||||
PaymentAPI,
|
||||
PAYMENT_METHODS,
|
||||
PAYMENT_STATUS,
|
||||
getPaymentStatusText,
|
||||
type PaymentStatus,
|
||||
} from '@/services/modules/payments';
|
||||
import { emit, EVENTS } from '@/utils/bus';
|
||||
} from '@/modules/account/controllers/payment-api';
|
||||
import { emit, EVENTS } from '@/shared/utils/bus';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -162,6 +163,7 @@ export function RechargePage() {
|
||||
window.open(res.pay_url, '_blank');
|
||||
void message.success('订单已创建,请在新页面中完成支付');
|
||||
} catch (err: unknown) {
|
||||
logger.warn('ui', '充值创建订单失败', err instanceof Error ? err : new Error(String(err)));
|
||||
message.error((err as Error)?.message || '创建订单失败,请重试');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -10,6 +10,7 @@
|
||||
// ============================================================
|
||||
|
||||
import {useState, useCallback, useEffect, useRef} from 'react';
|
||||
import { logger } from '@/shared/utils/bus';
|
||||
import {
|
||||
Radio,
|
||||
Button,
|
||||
@@ -26,18 +27,18 @@ import {
|
||||
} from 'antd';
|
||||
import {CrownOutlined, GiftOutlined, DollarOutlined} from '@ant-design/icons';
|
||||
|
||||
import {useTheme} from '@/components/providers/ThemeProvider';
|
||||
import {LegalTextModal} from '@/components/ui/LegalTextModal';
|
||||
import {useTheme} from '@/app/providers/ThemeProvider';
|
||||
import {LegalTextModal} from '@/shared/components/LegalTextModal';
|
||||
import {
|
||||
VipAPI,
|
||||
VipOrderStatus,
|
||||
type VipLevelsItem,
|
||||
type VipOrderStatusValue,
|
||||
} from '@/services/modules/vip-api';
|
||||
import {centToYuan} from '@/utils/display';
|
||||
import {EnumResolver} from '@/utils/enum-resolver';
|
||||
import {emit, EVENTS} from '@/utils/bus';
|
||||
import {RequestError, RequestErrorType} from '@/services/request';
|
||||
} from '@/modules/account/controllers/vip-api';
|
||||
import {centToYuan} from '@/shared/utils/display';
|
||||
import {EnumResolver} from '@/shared/utils/enum-resolver';
|
||||
import {emit, EVENTS} from '@/shared/utils/bus';
|
||||
import {RequestError, RequestErrorType} from '@/shared/services/http';
|
||||
|
||||
const {Text} = Typography;
|
||||
|
||||
@@ -119,6 +120,7 @@ export function VipPage() {
|
||||
} catch (err) {
|
||||
// axios 拦截器将取消错误转为 RequestError(CANCELLED),非原始 AbortError
|
||||
if (err instanceof RequestError && err.type === RequestErrorType.CANCELLED) return;
|
||||
logger.warn('ui', '加载VIP套餐列表失败', err instanceof Error ? err : new Error(String(err)));
|
||||
if (mountedRef.current) {
|
||||
setFetchError((err as Error)?.message || '加载套餐列表失败');
|
||||
}
|
||||
@@ -179,6 +181,7 @@ export function VipPage() {
|
||||
window.open(res.pay_url ?? '', '_blank');
|
||||
void message.success('订单已创建,请在新页面中完成支付');
|
||||
} catch (err: unknown) {
|
||||
logger.warn('ui', 'VIP创建订单失败', err instanceof Error ? err : new Error(String(err)));
|
||||
message.error((err as Error)?.message || '创建订单失败,请重试');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -200,6 +203,7 @@ export function VipPage() {
|
||||
void message.success('激活成功!VIP 权益已生效');
|
||||
emit(EVENTS.CLOSE_PAGE);
|
||||
} catch (err: unknown) {
|
||||
logger.warn('ui', 'VIP激活失败', err instanceof Error ? err : new Error(String(err)));
|
||||
message.error((err as Error)?.message || '激活失败,请检查激活码是否正确');
|
||||
} finally {
|
||||
setActivating(false);
|
||||
@@ -9,9 +9,10 @@
|
||||
// ============================================================
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { logger } from '@/shared/utils/bus';
|
||||
import { Spin, Empty } from 'antd';
|
||||
import { WechatOutlined } from '@ant-design/icons';
|
||||
import { fetchBanners, type Banner } from '@/services/modules/banner';
|
||||
import { fetchBanners, type Banner } from '@/modules/home/controllers/banner-api';
|
||||
|
||||
export function WechatWorkPage() {
|
||||
const [banner, setBanner] = useState<Banner | null>(null);
|
||||
@@ -29,6 +30,7 @@ export function WechatWorkPage() {
|
||||
);
|
||||
setBanner(promotion ?? null);
|
||||
} catch (err) {
|
||||
logger.warn('ui', '企业微信二维码加载失败', err instanceof Error ? err : new Error(String(err)));
|
||||
setError((err as Error).message || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -3,7 +3,7 @@
|
||||
// GET /api/v1/announcements
|
||||
// ============================================================
|
||||
|
||||
import { get } from '../request';
|
||||
import { get } from '@/shared/services/http';
|
||||
|
||||
// ============================================================
|
||||
// AnnouncementUrlObj — 公告模块路由常量
|
||||
5
src/modules/announcement/index.ts
Normal file
5
src/modules/announcement/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// announcement 模块 — 公告
|
||||
export { AnnouncementDrawer } from './views/AnnouncementDrawer';
|
||||
export { fetchAnnouncements } from './controllers/announcement-api';
|
||||
export type { Announcement, AnnouncementType, AnnouncementTypeValue, AnnouncementListResponse } from './controllers/announcement-api';
|
||||
export { AnnouncementTypeEnum, AnnouncementTypeLabelMap, AnnouncementTypeColorMap } from './controllers/announcement-api';
|
||||
@@ -1,14 +1,17 @@
|
||||
// ============================================================
|
||||
// AnnouncementDrawer — 公告侧拉抽屉(SPA 内部弹出)
|
||||
// 数据来源:GET /api/v1/announcements
|
||||
//
|
||||
// 数据由父组件(NavBar)通过 useAnnouncements Hook 提供:
|
||||
// - 挂载时自动拉取 + 每 15 分钟轮询
|
||||
// - 打开抽屉时已标记已读(Badge 消除)
|
||||
// ============================================================
|
||||
|
||||
import {useEffect, useState, useCallback} from 'react';
|
||||
import {useState} from 'react';
|
||||
import {Drawer, Card, Typography, Empty, Tag, Spin, Modal, Tooltip} from 'antd';
|
||||
import {NotificationOutlined} from '@ant-design/icons';
|
||||
|
||||
import { fetchAnnouncements, type Announcement, type AnnouncementListResponse, type AnnouncementTypeValue } from '@/services/modules/announcement';
|
||||
import { EnumResolver } from '@/utils/enum-resolver';
|
||||
import type { Announcement, AnnouncementTypeValue } from '@/modules/announcement/controllers/announcement-api';
|
||||
import { EnumResolver } from '@/shared/utils/enum-resolver';
|
||||
|
||||
const {Text, Paragraph} = Typography;
|
||||
|
||||
@@ -34,37 +37,16 @@ function formatDate(iso: string): string {
|
||||
interface AnnouncementDrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** 公告列表(由 useAnnouncements Hook 提供) */
|
||||
announcements: Announcement[];
|
||||
/** 是否正在加载(由 useAnnouncements Hook 提供) */
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function AnnouncementDrawer({open, onClose}: AnnouncementDrawerProps) {
|
||||
const [list, setList] = useState<Announcement[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
export function AnnouncementDrawer({open, onClose, announcements, loading}: AnnouncementDrawerProps) {
|
||||
// 详情 Modal
|
||||
const [detail, setDetail] = useState<Announcement | null>(null);
|
||||
|
||||
// 打开抽屉时请求公告列表
|
||||
const loadAnnouncements = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data: AnnouncementListResponse = await fetchAnnouncements();
|
||||
setList(data.filter((item: Announcement) => item.is_active));
|
||||
} catch (err) {
|
||||
setError((err as Error).message || '加载公告失败');
|
||||
setList([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadAnnouncements();
|
||||
}
|
||||
}, [open, loadAnnouncements]);
|
||||
|
||||
// ---- 渲染 ----
|
||||
|
||||
return (
|
||||
@@ -86,17 +68,13 @@ export function AnnouncementDrawer({open, onClose}: AnnouncementDrawerProps) {
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spin description="加载中..." />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Empty description={error} />
|
||||
</div>
|
||||
) : list.length === 0 ? (
|
||||
) : announcements.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Empty description="暂无公告" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
{list.map((item:Announcement) => (
|
||||
{announcements.map((item:Announcement) => (
|
||||
<Tooltip
|
||||
key={item.id}
|
||||
title={
|
||||
@@ -1,4 +1,4 @@
|
||||
import {post, get} from '../request';
|
||||
import {post, get} from '@/shared/services/http';
|
||||
|
||||
// ============================================================
|
||||
// AuthUrlObj — 认证模块路由常量
|
||||
@@ -16,15 +16,16 @@
|
||||
// - 内存缓存供同步读取(setTokenRefreshHandler 回调内使用)
|
||||
// ============================================================
|
||||
|
||||
import { setToken, setTokenRefreshHandler, RequestError, RequestErrorType } from './request';
|
||||
import { AuthAPI, type RefreshTokenResponseBody } from './modules/auth';
|
||||
import { isRefreshError } from './types';
|
||||
import { setToken, setTokenRefreshHandler, RequestError, RequestErrorType } from '@/shared/services/http';
|
||||
import { AuthAPI, type RefreshTokenResponseBody } from './auth-api';
|
||||
import { isRefreshError } from '@/shared/services/types';
|
||||
import {
|
||||
getSafeStore,
|
||||
setSafeStore,
|
||||
clearSafeStore,
|
||||
initSafeStore,
|
||||
} from '../utils/safe-storage';
|
||||
} from '@/shared/utils/safe-storage';
|
||||
import { logger } from '@/shared/utils/bus';
|
||||
|
||||
const REFRESH_KEY = 'ele-heixiu-refresh-token';
|
||||
|
||||
@@ -175,6 +176,7 @@ export function initAuthRefresh(): void {
|
||||
} catch (err) {
|
||||
// ---- 永久失败:refresh_token 已过期/无效 ----
|
||||
if (isRefreshError(err)) {
|
||||
logger.warn('auth', 'Token 刷新永久失败(refresh_token 已过期)', err instanceof Error ? err : undefined);
|
||||
clearStoredRefreshToken();
|
||||
clearProactiveRefresh();
|
||||
throw new RefreshTokenExpiredError();
|
||||
@@ -183,11 +185,13 @@ export function initAuthRefresh(): void {
|
||||
// ---- 临时失败:网络超时/无连接 → 保留 token,下次再试 ----
|
||||
if (err instanceof RequestError) {
|
||||
if (err.type === RequestErrorType.NETWORK || err.type === RequestErrorType.TIMEOUT) {
|
||||
logger.warn('auth', 'Token 刷新临时失败(网络问题),保留现有 token', err);
|
||||
return null; // 临时失败,不登出
|
||||
}
|
||||
}
|
||||
|
||||
// ---- HTTP/Business 错误(如 5xx)→ 保守清除 ----
|
||||
logger.warn('auth', 'Token 刷新失败(HTTP/Business 错误),强制登出', err instanceof Error ? err : undefined);
|
||||
clearStoredRefreshToken();
|
||||
clearProactiveRefresh();
|
||||
throw new RefreshTokenExpiredError();
|
||||
@@ -19,9 +19,9 @@ import {
|
||||
getSavedUsername,
|
||||
getSavedRemember,
|
||||
getSavedAutoLogin,
|
||||
} from '@/services/auth-persistence';
|
||||
} from '@/modules/auth/controllers/auth-persistence';
|
||||
|
||||
export { getAutoLoginFlag, clearAutoLoginFlag, clearSavedAuth } from '@/services/auth-persistence';
|
||||
export { getAutoLoginFlag, clearAutoLoginFlag, clearSavedAuth } from '@/modules/auth/controllers/auth-persistence';
|
||||
|
||||
// ---------- Hook ----------
|
||||
|
||||
6
src/modules/auth/index.ts
Normal file
6
src/modules/auth/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// auth 模块 — 认证(登录/注册/密码/Token)
|
||||
export { LoginPage } from './views/AuthPage';
|
||||
export { AuthAPI } from './controllers/auth-api';
|
||||
export type { LoginResponseBody } from './controllers/auth-api';
|
||||
export { initAuthRefresh } from './controllers/token-manager';
|
||||
export { getAutoLoginFlag, clearAutoLoginFlag } from './controllers/auth-persistence';
|
||||
@@ -26,4 +26,4 @@ export interface RegisterFormValues {
|
||||
}
|
||||
|
||||
// 从 ui/ 重新导出通用字段类型(保持向后兼容)
|
||||
export type { FieldRule, InputType, FieldConfig } from '@/components/ui/types';
|
||||
export type { FieldRule, InputType, FieldConfig } from '@/shared/components/types';
|
||||
@@ -3,7 +3,7 @@
|
||||
// 修改校验规则或字段属性只需改此文件,无需动 UI 代码
|
||||
// ============================================================
|
||||
|
||||
import type { FieldConfig } from '../types';
|
||||
import type { FieldConfig } from '../model/types';
|
||||
|
||||
export const loginFields: FieldConfig[] = [
|
||||
{
|
||||
@@ -3,7 +3,7 @@
|
||||
// 修改校验规则或字段属性只需改此文件,无需动 UI 代码
|
||||
// ============================================================
|
||||
|
||||
import type { FieldConfig } from '../types';
|
||||
import type { FieldConfig } from '../model/types';
|
||||
|
||||
export const registerFields: FieldConfig[] = [
|
||||
{
|
||||
@@ -76,7 +76,7 @@ export function validateReferralCode(value: string): ValidateResult {
|
||||
* 对一条字段配置执行所有规则校验
|
||||
* @returns 错误信息数组(空数组表示通过)
|
||||
*/
|
||||
export function validateField(value: string, rules: import('../types').FieldRule[]): string[] {
|
||||
export function validateField(value: string, rules: import('../model/types').FieldRule[]): string[] {
|
||||
const errors: string[] = [];
|
||||
for (const rule of rules) {
|
||||
// required
|
||||
@@ -134,7 +134,7 @@ export function runFieldChecks(
|
||||
*/
|
||||
export function validateForm<T extends Record<string, string>>(
|
||||
values: T,
|
||||
fieldConfigs: import('../types').FieldConfig[],
|
||||
fieldConfigs: import('../model/types').FieldConfig[],
|
||||
crossValidators?: Record<string, (value: string, allValues: T) => string | null>,
|
||||
): Record<string, string[]> {
|
||||
const errors: Record<string, string[]> = {};
|
||||
@@ -8,17 +8,18 @@
|
||||
// ============================================================
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { logger } from '@/shared/utils/bus';
|
||||
import { Modal, Tabs, Typography, App } from 'antd';
|
||||
|
||||
import { useAppContext } from '@/components/providers/AppProvider';
|
||||
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||
import { useAppContext } from '@/app/providers/AppProvider';
|
||||
import { useTheme } from '@/app/providers/ThemeProvider';
|
||||
import { LoginForm } from './components/LoginForm';
|
||||
import { RegisterForm } from './components/RegisterForm';
|
||||
import { ForgotPasswordModal } from './components/ForgotPasswordModal';
|
||||
import { useAuthState } from './hooks/use-auth-state';
|
||||
import { AuthAPI, type LoginResponseBody } from '@/services/modules/auth';
|
||||
import { getDeviceId } from '@/utils/env';
|
||||
import type { LoginFormValues, RegisterFormValues } from './types';
|
||||
import { useAuthState } from '../hooks/use-auth-state';
|
||||
import { AuthAPI, type LoginResponseBody } from '../controllers/auth-api';
|
||||
import { getDeviceId } from '@/shared/utils/env';
|
||||
import type { LoginFormValues, RegisterFormValues } from '../model/types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -60,6 +61,7 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
|
||||
message.success(`登录成功,欢迎 ${auth.display_name || auth.username}`);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
logger.warn('auth', '登录失败', err instanceof Error ? err : undefined);
|
||||
message.error((err as Error).message || '登录失败,请检查用户名和密码');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -87,6 +89,7 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
|
||||
message.success('注册成功,请登录');
|
||||
setActiveTab('login');
|
||||
} catch (err) {
|
||||
logger.warn('auth', '注册失败', err instanceof Error ? err : undefined);
|
||||
message.error((err as Error).message || '注册失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -101,6 +104,7 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
|
||||
await AuthAPI.sendSms({ phone });
|
||||
message.success('验证码已发送');
|
||||
} catch (err) {
|
||||
logger.warn('auth', '验证码发送失败', err instanceof Error ? err : undefined);
|
||||
message.error((err as Error).message || '验证码发送失败');
|
||||
}
|
||||
},
|
||||
@@ -9,12 +9,13 @@
|
||||
// ============================================================
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { logger } from '@/shared/utils/bus';
|
||||
import { Modal, Input, Button, Typography, App } from 'antd';
|
||||
import { LockOutlined, KeyOutlined, SafetyOutlined } from '@ant-design/icons';
|
||||
|
||||
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||
import { AuthAPI } from '@/services/modules/auth';
|
||||
import { validatePassword, validateConfirmPassword, runFieldChecks } from '../config/validators';
|
||||
import { useTheme } from '@/app/providers/ThemeProvider';
|
||||
import { AuthAPI } from '@/modules/auth/controllers/auth-api';
|
||||
import { validatePassword, validateConfirmPassword, runFieldChecks } from '../../settings/validators';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
@@ -69,6 +70,7 @@ export function ChangePasswordModal({ open, onClose }: ChangePasswordModalProps)
|
||||
setConfirmPassword('');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
logger.warn('auth', '密码修改失败', err instanceof Error ? err : undefined);
|
||||
message.error((err as Error).message || '密码修改失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -10,6 +10,7 @@
|
||||
// ============================================================
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { logger } from '@/shared/utils/bus';
|
||||
import { Modal, Input, Button, Typography, App } from 'antd';
|
||||
import {
|
||||
PhoneOutlined,
|
||||
@@ -18,16 +19,16 @@ import {
|
||||
KeyOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||
import { AuthAPI } from '@/services/modules/auth';
|
||||
import { useTheme } from '@/app/providers/ThemeProvider';
|
||||
import { AuthAPI } from '@/modules/auth/controllers/auth-api';
|
||||
import {
|
||||
validatePhone,
|
||||
validateSmsCode,
|
||||
validatePassword,
|
||||
validateConfirmPassword,
|
||||
runFieldChecks,
|
||||
} from '../config/validators';
|
||||
import { useSmsCooldown } from '../hooks/use-sms-cooldown';
|
||||
} from '../../settings/validators';
|
||||
import { useSmsCooldown } from '../../hooks/use-sms-cooldown';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
@@ -76,7 +77,7 @@ export function ForgotPasswordModal({ open, onClose }: ForgotPasswordModalProps)
|
||||
startCooldown();
|
||||
AuthAPI.sendSms({ phone })
|
||||
.then(() => message.success('验证码已发送'))
|
||||
.catch((e) => message.error((e as Error).message || '验证码发送失败'));
|
||||
.catch((e) => { logger.warn('auth', '忘记密码-验证码发送失败', e instanceof Error ? e : undefined); message.error((e as Error).message || '验证码发送失败'); });
|
||||
}, [phone, startCooldown, clearError, message]);
|
||||
|
||||
// ---- 提交重置 ----
|
||||
@@ -102,6 +103,7 @@ export function ForgotPasswordModal({ open, onClose }: ForgotPasswordModalProps)
|
||||
setConfirmPassword('');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
logger.warn('auth', '密码重置失败', err instanceof Error ? err : undefined);
|
||||
message.error((err as Error).message || '密码重置失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -6,10 +6,10 @@ import { useState, useCallback } from 'react';
|
||||
import { Input, Button, Checkbox, Typography } from 'antd';
|
||||
import { UserOutlined, LockOutlined, EyeTwoTone, EyeInvisibleOutlined } from '@ant-design/icons';
|
||||
|
||||
import { loginFields } from '../config/login-fields';
|
||||
import { validateForm } from '../config/validators';
|
||||
import { LegalTextModal } from '@/components/ui/LegalTextModal';
|
||||
import type { LoginFormValues } from '../types';
|
||||
import { loginFields } from '../../settings/login-fields';
|
||||
import { validateForm } from '../../settings/validators';
|
||||
import { LegalTextModal } from '@/shared/components/LegalTextModal';
|
||||
import type { LoginFormValues } from '../../model/types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { Input, Button, Checkbox, Typography } from 'antd';
|
||||
|
||||
import { LegalTextModal } from '@/components/ui/LegalTextModal';
|
||||
import { LegalTextModal } from '@/shared/components/LegalTextModal';
|
||||
import {
|
||||
UserOutlined,
|
||||
LockOutlined,
|
||||
@@ -17,9 +17,9 @@ import {
|
||||
GiftOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { registerFields } from '../config/register-fields';
|
||||
import { validateForm } from '../config/validators';
|
||||
import type { RegisterFormValues } from '../types';
|
||||
import { registerFields } from '../../settings/register-fields';
|
||||
import { validateForm } from '../../settings/validators';
|
||||
import type { RegisterFormValues } from '../../model/types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
38
src/modules/canvas/controllers/menus/episode-menu.ts
Normal file
38
src/modules/canvas/controllers/menus/episode-menu.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// ============================================================
|
||||
// episode-menu.ts — 集数选择器右键菜单定义
|
||||
//
|
||||
// 菜单结构:新建集数 | 统计集数 | —— | 在文件夹中打开
|
||||
// ============================================================
|
||||
|
||||
import type { ContextMenuItems } from '@/shared/components/ContextMenu';
|
||||
|
||||
export interface EpisodeMenuOptions {
|
||||
canCreate: boolean;
|
||||
canOpenFolder: boolean;
|
||||
onCreate: () => void;
|
||||
onStats: () => void;
|
||||
onOpenFolder: () => void;
|
||||
}
|
||||
|
||||
export function getEpisodeMenuItems(opts: EpisodeMenuOptions): ContextMenuItems {
|
||||
return [
|
||||
{
|
||||
key: 'new-episode',
|
||||
label: '新建集数',
|
||||
onClick: opts.onCreate,
|
||||
disabled: !opts.canCreate,
|
||||
},
|
||||
{
|
||||
key: 'stats-episode',
|
||||
label: '统计集数',
|
||||
onClick: opts.onStats,
|
||||
},
|
||||
'divider',
|
||||
{
|
||||
key: 'open-folder',
|
||||
label: '在文件夹中打开',
|
||||
onClick: opts.onOpenFolder,
|
||||
disabled: !opts.canOpenFolder,
|
||||
},
|
||||
];
|
||||
}
|
||||
15
src/modules/canvas/controllers/menus/index.ts
Normal file
15
src/modules/canvas/controllers/menus/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// ============================================================
|
||||
// menus — 无限画布右键菜单定义统一导出
|
||||
// ============================================================
|
||||
|
||||
export { getProjectMenuItems } from './project-menu';
|
||||
export type { ProjectMenuOptions } from './project-menu';
|
||||
|
||||
export { getEpisodeMenuItems } from './episode-menu';
|
||||
export type { EpisodeMenuOptions } from './episode-menu';
|
||||
|
||||
export { getShotMenuItems } from './shot-menu';
|
||||
export type { ShotMenuOptions } from './shot-menu';
|
||||
|
||||
export { getMediaCardMenuItems } from './media-card-menu';
|
||||
export type { MediaCardMenuOptions } from './media-card-menu';
|
||||
54
src/modules/canvas/controllers/menus/media-card-menu.ts
Normal file
54
src/modules/canvas/controllers/menus/media-card-menu.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// ============================================================
|
||||
// media-card-menu.ts — 画布媒体卡片右键菜单定义
|
||||
//
|
||||
// Assets 阶段:
|
||||
// 复制文件路径 | 在文件夹中打开
|
||||
//
|
||||
// 非 Assets 阶段(Storyboard / Keyframe / Video 等):
|
||||
// 标记为待审核 | 标记为通过 | 标记为返修 | 标记为已弃用 | —— | 复制文件路径 | 在文件夹中打开
|
||||
//
|
||||
// 通用操作(复制路径、在文件夹中打开)统一放到底部分隔线以下。
|
||||
// ============================================================
|
||||
|
||||
import type { ContextMenuItems } from '@/shared/components/ContextMenu';
|
||||
import type { AuditStatus } from '../../stores/useCanvasStore';
|
||||
|
||||
/** 审核状态 → 菜单显示文本 */
|
||||
const AUDIT_LABELS: Record<AuditStatus, string> = {
|
||||
pending: '标记为待审核',
|
||||
approved: '标记为通过',
|
||||
rework: '标记为返修',
|
||||
deprecated: '标记为已弃用',
|
||||
};
|
||||
|
||||
export interface MediaCardMenuOptions {
|
||||
/** 是否可审核(非 Assets 阶段为 true) */
|
||||
isAuditable: boolean;
|
||||
onSetAudit: (status: AuditStatus) => void;
|
||||
onCopyPath: () => void;
|
||||
onOpenFolder: () => void;
|
||||
}
|
||||
|
||||
export function getMediaCardMenuItems(opts: MediaCardMenuOptions): ContextMenuItems {
|
||||
const items: ContextMenuItems = [];
|
||||
|
||||
if (opts.isAuditable) {
|
||||
const statuses: AuditStatus[] = ['pending', 'approved', 'rework', 'deprecated'];
|
||||
for (const status of statuses) {
|
||||
items.push({
|
||||
key: `audit-${status}`,
|
||||
label: AUDIT_LABELS[status],
|
||||
onClick: () => opts.onSetAudit(status),
|
||||
});
|
||||
}
|
||||
items.push('divider');
|
||||
}
|
||||
|
||||
// 底部通用操作
|
||||
items.push(
|
||||
{ key: 'copy-path', label: '复制文件路径', onClick: opts.onCopyPath },
|
||||
{ key: 'open-folder', label: '在文件夹中打开', onClick: opts.onOpenFolder },
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
46
src/modules/canvas/controllers/menus/project-menu.ts
Normal file
46
src/modules/canvas/controllers/menus/project-menu.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// ============================================================
|
||||
// project-menu.ts — 项目选择器右键菜单定义
|
||||
//
|
||||
// 菜单结构:新建项目 | 统计项目 | 批量创建镜头 | —— | 在文件夹中打开
|
||||
// ============================================================
|
||||
|
||||
import type { ContextMenuItems } from '@/shared/components/ContextMenu';
|
||||
|
||||
export interface ProjectMenuOptions {
|
||||
/** 是否可以新建(如未选工作区则禁用) */
|
||||
canCreate: boolean;
|
||||
/** 当前选中项目(用于"在文件夹中打开") */
|
||||
canOpenFolder: boolean;
|
||||
onCreate: () => void;
|
||||
onStats: () => void;
|
||||
onBatchShots: () => void;
|
||||
onOpenFolder: () => void;
|
||||
}
|
||||
|
||||
export function getProjectMenuItems(opts: ProjectMenuOptions): ContextMenuItems {
|
||||
return [
|
||||
{
|
||||
key: 'new-project',
|
||||
label: '新建项目',
|
||||
onClick: opts.onCreate,
|
||||
disabled: !opts.canCreate,
|
||||
},
|
||||
{
|
||||
key: 'stats-project',
|
||||
label: '统计项目',
|
||||
onClick: opts.onStats,
|
||||
},
|
||||
{
|
||||
key: 'batch-shots',
|
||||
label: '批量创建镜头',
|
||||
onClick: opts.onBatchShots,
|
||||
},
|
||||
'divider',
|
||||
{
|
||||
key: 'open-folder',
|
||||
label: '在文件夹中打开',
|
||||
onClick: opts.onOpenFolder,
|
||||
disabled: !opts.canOpenFolder,
|
||||
},
|
||||
];
|
||||
}
|
||||
50
src/modules/canvas/controllers/menus/shot-menu.ts
Normal file
50
src/modules/canvas/controllers/menus/shot-menu.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// ============================================================
|
||||
// shot-menu.ts — 镜头列表项右键菜单定义
|
||||
//
|
||||
// 菜单结构:新建镜头 | —— | 重命名 | 删除 | —— | 在文件夹中打开
|
||||
// ============================================================
|
||||
|
||||
import type { ContextMenuItems } from '@/shared/components/ContextMenu';
|
||||
|
||||
export interface ShotMenuOptions {
|
||||
/** 是否可操作(未选项目/集数时禁用) */
|
||||
canOperate: boolean;
|
||||
/** 选中的镜头名称 */
|
||||
shotName: string;
|
||||
onNewShot: () => void;
|
||||
onRename: (shotName: string) => void;
|
||||
onDelete: (shotName: string) => void;
|
||||
onOpenFolder: (shotName: string) => void;
|
||||
}
|
||||
|
||||
export function getShotMenuItems(opts: ShotMenuOptions): ContextMenuItems {
|
||||
return [
|
||||
{
|
||||
key: 'new-shot',
|
||||
label: '新建镜头',
|
||||
onClick: opts.onNewShot,
|
||||
disabled: !opts.canOperate,
|
||||
},
|
||||
'divider',
|
||||
{
|
||||
key: 'rename-shot',
|
||||
label: '重命名',
|
||||
onClick: () => opts.onRename(opts.shotName),
|
||||
disabled: !opts.canOperate,
|
||||
},
|
||||
{
|
||||
key: 'delete-shot',
|
||||
label: '删除',
|
||||
onClick: () => opts.onDelete(opts.shotName),
|
||||
disabled: !opts.canOperate,
|
||||
danger: true,
|
||||
},
|
||||
'divider',
|
||||
{
|
||||
key: 'open-folder',
|
||||
label: '在文件夹中打开',
|
||||
onClick: () => opts.onOpenFolder(opts.shotName),
|
||||
disabled: !opts.canOperate,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
// ============================================================
|
||||
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useCanvasStore, type InteractionMode } from '../store/useCanvasStore';
|
||||
import { useCanvasStore, type InteractionMode } from '../stores/useCanvasStore';
|
||||
|
||||
export function useCanvasInteraction() {
|
||||
const interactionMode = useCanvasStore((s) => s.interactionMode);
|
||||
@@ -10,7 +10,8 @@
|
||||
// ============================================================
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useCanvasStore } from '../store/useCanvasStore';
|
||||
import { logger } from '@/shared/utils/bus';
|
||||
import { useCanvasStore } from '../stores/useCanvasStore';
|
||||
|
||||
export function useDirectoryScan() {
|
||||
// 选择性订阅
|
||||
@@ -112,7 +113,7 @@ export function useDirectoryScan() {
|
||||
setScanProgress(null);
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted || epoch !== epochRef.current) return;
|
||||
console.error('[useDirectoryScan] ❌ 扫描失败:', err);
|
||||
logger.warn('canvas', '目录扫描失败', err instanceof Error ? err : new Error(String(err)));
|
||||
} finally {
|
||||
if (epoch === epochRef.current) {
|
||||
setIsScanning(false);
|
||||
@@ -6,7 +6,7 @@
|
||||
// ============================================================
|
||||
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { useCanvasStore, getCanvasStore } from '../store/useCanvasStore';
|
||||
import { useCanvasStore, getCanvasStore } from '../stores/useCanvasStore';
|
||||
import type { SidecarMetadata } from '../utils/ICanvasDataSource';
|
||||
|
||||
export function useSidecarSync() {
|
||||
@@ -5,7 +5,7 @@
|
||||
// 非 Electron 环境(浏览器开发模式)降级返回空数据/抛出友好错误。
|
||||
// ============================================================
|
||||
|
||||
import { hasIpc, safeIpcInvoke } from '@/utils/bus';
|
||||
import { hasIpc, safeIpcInvoke } from '@/shared/utils/bus';
|
||||
import { BIDIRECTIONAL } from '@shared/constants/ipc-channels';
|
||||
import type {
|
||||
ICanvasDataSource,
|
||||
@@ -2,7 +2,7 @@
|
||||
// constants.ts — 无限画布常量配置
|
||||
// ============================================================
|
||||
|
||||
import type { StageType } from '../store/useCanvasStore';
|
||||
import type { StageType } from '../stores/useCanvasStore';
|
||||
|
||||
// ---------- 布局 ----------
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// 实现 Grid 模式和 Single 模式的坐标计算。
|
||||
// ============================================================
|
||||
|
||||
import type { CatalogItem } from '../store/useCanvasStore';
|
||||
import type { CatalogItem } from '../stores/useCanvasStore';
|
||||
import {
|
||||
ITEM_W,
|
||||
ITEM_H,
|
||||
@@ -9,7 +9,7 @@
|
||||
// - shotNameMatchesQuery(shotName, query) — 镜头名称匹配
|
||||
// ============================================================
|
||||
|
||||
import type { StageType } from '../store/useCanvasStore';
|
||||
import type { StageType } from '../stores/useCanvasStore';
|
||||
|
||||
// ---------- 阶段检测 ----------
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
// ============================================================
|
||||
|
||||
import { useMemo, useEffect } from 'react';
|
||||
import { LocalFileDataSource } from './utils/LocalFileDataSource';
|
||||
import { createCanvasStoreProvider, useCanvasStore } from './store/useCanvasStore';
|
||||
import { logger } from '@/shared/utils/bus';
|
||||
import { LocalFileDataSource } from '../utils/LocalFileDataSource';
|
||||
import { createCanvasStoreProvider, useCanvasStore } from '../stores/useCanvasStore';
|
||||
import { CanvasPage } from './CanvasPage';
|
||||
|
||||
/** localStorage 键名 —— 与 SettingsProvider 保持一致 */
|
||||
@@ -85,7 +86,7 @@ function WorkspaceInitializer() {
|
||||
setProjects(projects);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[CanvasApp] ❌ 加载项目列表失败:', err);
|
||||
logger.warn('canvas', '加载项目列表失败', err instanceof Error ? err : new Error(String(err)));
|
||||
});
|
||||
}, [workspaceRoot, setProjects, getDataSource]);
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Layout } from 'antd';
|
||||
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||
import { useCanvasStore } from './store/useCanvasStore';
|
||||
import { useDirectoryScan } from './hooks/useDirectoryScan';
|
||||
import { useTheme } from '@/app/providers/ThemeProvider';
|
||||
import { useCanvasStore } from '../stores/useCanvasStore';
|
||||
import { useDirectoryScan } from '../hooks/useDirectoryScan';
|
||||
import { CanvasToolbar } from './toolbar/CanvasToolbar';
|
||||
import { CanvasViewport } from './core/CanvasViewport';
|
||||
import { MediaPreview } from './core/MediaPreview';
|
||||
@@ -10,8 +10,9 @@
|
||||
// ============================================================
|
||||
|
||||
import { useRef, useCallback, useEffect, useState, useMemo } from 'react';
|
||||
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||
import { useCanvasStore } from '../store/useCanvasStore';
|
||||
import { logger } from '@/shared/utils/bus';
|
||||
import { useTheme } from '@/app/providers/ThemeProvider';
|
||||
import { useCanvasStore } from '../../stores/useCanvasStore';
|
||||
import { MediaCard } from './MediaCard';
|
||||
import { MediaCardContextMenu } from './MediaCardContextMenu';
|
||||
import { PagerOverlay, ZoomOverlay, ModeSwitch } from './ViewportControls';
|
||||
@@ -21,8 +22,8 @@ import {
|
||||
gridCanvasSize,
|
||||
singleLayout,
|
||||
singlePageInfo,
|
||||
} from '../utils/layout';
|
||||
import { ITEM_H, ITEM_SPACING, ROW_LABEL_WIDTH, MAX_ITEMS_PER_PAGE } from '../utils/constants';
|
||||
} from '../../utils/layout';
|
||||
import { ITEM_H, ITEM_SPACING, ROW_LABEL_WIDTH, MAX_ITEMS_PER_PAGE } from '../../utils/constants';
|
||||
|
||||
/** 缩放倍率范围 */
|
||||
const ZOOM_MIN = 0.02;
|
||||
@@ -199,7 +200,7 @@ export function CanvasViewport({ onPreviewOpen, previewActive }: CanvasViewportP
|
||||
try {
|
||||
e.preventDefault();
|
||||
} catch (err) {
|
||||
console.error('[CanvasViewport] ⚠️ preventDefault 失败(passive 冲突):', err);
|
||||
logger.warn('canvas', 'preventDefault 失败(passive 冲突)', err instanceof Error ? err : undefined);
|
||||
return;
|
||||
}
|
||||
// 从 ref 读取当前 zoom(避免 Zustand v5 getState() 在原生事件中返回旧值)
|
||||
@@ -10,9 +10,10 @@
|
||||
// ============================================================
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useCanvasStore } from '../store/useCanvasStore';
|
||||
import { LOD_LEVELS } from '../utils/constants';
|
||||
import type { LODLevel } from '../utils/constants';
|
||||
import { logger } from '@/shared/utils/bus';
|
||||
import { useCanvasStore } from '../../stores/useCanvasStore';
|
||||
import { LOD_LEVELS } from '../../utils/constants';
|
||||
import type { LODLevel } from '../../utils/constants';
|
||||
|
||||
interface LODImageProps {
|
||||
itemId: number;
|
||||
@@ -79,7 +80,8 @@ export function LODImage({ itemId, filePath, targetLevel }: LODImageProps) {
|
||||
loadThumbnail(itemId, url, targetLevel);
|
||||
loadingRef.current = false;
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((err) => {
|
||||
logger.warn('canvas', 'LOD 图片加载失败', err instanceof Error ? err : undefined);
|
||||
setLoading(false);
|
||||
setError(true);
|
||||
loadingRef.current = false;
|
||||
@@ -12,8 +12,8 @@
|
||||
|
||||
import { useCallback, memo } from 'react';
|
||||
import { LODImage, zoomToLODIndex } from './LODImage';
|
||||
import { useCanvasStore, type InteractionMode } from '../store/useCanvasStore';
|
||||
import { AUDIT_STATUS_COLORS, ITEM_W, ITEM_H } from '../utils/constants';
|
||||
import { useCanvasStore, type InteractionMode } from '../../stores/useCanvasStore';
|
||||
import { AUDIT_STATUS_COLORS, ITEM_W, ITEM_H } from '../../utils/constants';
|
||||
|
||||
export interface MediaCardProps {
|
||||
itemId: number;
|
||||
87
src/modules/canvas/views/core/MediaCardContextMenu.tsx
Normal file
87
src/modules/canvas/views/core/MediaCardContextMenu.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
// ============================================================
|
||||
// MediaCardContextMenu — 画布媒体卡片右键菜单
|
||||
//
|
||||
// 菜单项定义见 menus/media-card-menu.ts
|
||||
// 组件职责:读取 Store 状态 + 绑定 IPC 操作 → 生成菜单项
|
||||
// ============================================================
|
||||
|
||||
import { message } from 'antd';
|
||||
import { useCallback } from 'react';
|
||||
import { useCanvasStore } from '../../stores/useCanvasStore';
|
||||
import { useSidecarSync } from '../../hooks/useSidecarSync';
|
||||
import { getMediaCardMenuItems } from '../../controllers/menus';
|
||||
import { ContextMenu } from '@/shared/components/ContextMenu';
|
||||
import { safeIpcInvoke } from '@/shared/utils/bus';
|
||||
import { BIDIRECTIONAL } from '@shared/constants/ipc-channels';
|
||||
import { ITEM_W, ITEM_H } from '../../utils/constants';
|
||||
import type { AuditStatus } from '../../stores/useCanvasStore';
|
||||
|
||||
interface MediaCardContextMenuProps {
|
||||
itemId: number;
|
||||
children: React.ReactNode;
|
||||
/** 画布中的绝对定位 */
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export function MediaCardContextMenu({ itemId, children, x, y }: MediaCardContextMenuProps) {
|
||||
const item = useCanvasStore((s) => s.items.find((i) => i.id === itemId));
|
||||
const { commitMetadata } = useSidecarSync();
|
||||
|
||||
// ── 回调:复制文件路径 ──
|
||||
const handleCopyPath = useCallback(async () => {
|
||||
if (!item) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(item.path);
|
||||
void message.success('已复制文件路径');
|
||||
} catch {
|
||||
void message.error('复制失败');
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
// ── 回调:在文件夹中打开 ──
|
||||
const handleOpenFolder = useCallback(async () => {
|
||||
if (!item) return;
|
||||
await safeIpcInvoke(BIDIRECTIONAL.SHOW_ITEM_IN_FOLDER, {
|
||||
filePath: item.path,
|
||||
});
|
||||
}, [item]);
|
||||
|
||||
// ── 回调:设置审核状态 ──
|
||||
const handleSetAudit = useCallback(
|
||||
(status: AuditStatus) => {
|
||||
if (!item) return;
|
||||
commitMetadata(itemId, item.path, { audit_status: status });
|
||||
void message.success(`审核状态已更新为:${status}`);
|
||||
},
|
||||
[item, itemId, commitMetadata],
|
||||
);
|
||||
|
||||
if (!item) return <>{children}</>;
|
||||
|
||||
const isAuditable = ['Storyboard', 'Keyframe', 'Video', 'Audio', 'LipSync'].includes(item.stage);
|
||||
|
||||
// ── 通过菜单定义函数生成 ContextMenuItems ──
|
||||
const menuItems = getMediaCardMenuItems({
|
||||
isAuditable,
|
||||
onSetAudit: handleSetAudit,
|
||||
onCopyPath: handleCopyPath,
|
||||
onOpenFolder: handleOpenFolder,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: x,
|
||||
top: y,
|
||||
width: ITEM_W,
|
||||
height: ITEM_H,
|
||||
}}
|
||||
>
|
||||
<ContextMenu items={menuItems}>
|
||||
{children}
|
||||
</ContextMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import { Button } from 'antd';
|
||||
import { CloseOutlined, LeftOutlined, RightOutlined, ZoomInOutlined, ZoomOutOutlined } from '@ant-design/icons';
|
||||
import { useCanvasStore } from '../store/useCanvasStore';
|
||||
import { useCanvasStore } from '../../stores/useCanvasStore';
|
||||
|
||||
interface MediaPreviewProps {
|
||||
itemId: number | null;
|
||||
@@ -16,11 +16,11 @@ import {
|
||||
CheckSquareOutlined,
|
||||
MenuOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||
import { useCanvasStore } from '../store/useCanvasStore';
|
||||
import { useCanvasInteraction } from '../hooks/useCanvasInteraction';
|
||||
import { gridPageInfo, singlePageInfo } from '../utils/layout';
|
||||
import { MAX_ITEMS_PER_PAGE } from '../utils/constants';
|
||||
import { useTheme } from '@/app/providers/ThemeProvider';
|
||||
import { useCanvasStore } from '../../stores/useCanvasStore';
|
||||
import { useCanvasInteraction } from '../../hooks/useCanvasInteraction';
|
||||
import { gridPageInfo, singlePageInfo } from '../../utils/layout';
|
||||
import { MAX_ITEMS_PER_PAGE } from '../../utils/constants';
|
||||
|
||||
// ── 分页控件 ──
|
||||
|
||||
@@ -5,17 +5,21 @@
|
||||
// 1. 项目下拉选择器(ProjectSelector)
|
||||
// 2. 集数下拉选择器(EpisodeSelector,Assets 阶段隐藏)
|
||||
// 3. 镜头号搜索输入框
|
||||
// 4. 镜头单选列表(ShotList,上下滚动)
|
||||
// 4. 镜头单选列表(每项支持右键菜单,定义见 menus/shot-menu.ts)
|
||||
// 5. 刷新镜头列表按钮
|
||||
// ============================================================
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Input, Button, App } from 'antd';
|
||||
import { logger } from '@/shared/utils/bus';
|
||||
import { Input, Button, App, Modal } from 'antd';
|
||||
import { SearchOutlined, ReloadOutlined, FolderOpenOutlined } from '@ant-design/icons';
|
||||
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||
import { useCanvasStore } from '../store/useCanvasStore';
|
||||
import { useTheme } from '@/app/providers/ThemeProvider';
|
||||
import { useCanvasStore } from '../../stores/useCanvasStore';
|
||||
import { ProjectSelector } from './ProjectSelector';
|
||||
import { EpisodeSelector } from './EpisodeSelector';
|
||||
import { ContextMenu } from '@/shared/components/ContextMenu';
|
||||
import { getShotMenuItems } from '../../controllers/menus';
|
||||
import { DirectoryCreateModal } from './DirectoryCreateModal';
|
||||
|
||||
export function CanvasSidebar() {
|
||||
const { isDark } = useTheme();
|
||||
@@ -37,6 +41,7 @@ export function CanvasSidebar() {
|
||||
// ── 本地状态 ──
|
||||
const [shotSearchQuery, setShotSearchQuery] = useState('');
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [createShotOpen, setCreateShotOpen] = useState(false);
|
||||
|
||||
const isAssets = currentStage === 'Assets';
|
||||
|
||||
@@ -53,6 +58,11 @@ export function CanvasSidebar() {
|
||||
[selectShot],
|
||||
);
|
||||
|
||||
// ── 镜头目录基础路径 ──
|
||||
const shotsBasePath = workspaceRoot && selectedProject
|
||||
? `${workspaceRoot}\\${selectedProject}\\shots`
|
||||
: '';
|
||||
|
||||
// ── 刷新镜头列表 ──
|
||||
const handleRefreshShots = useCallback(async () => {
|
||||
if (!workspaceRoot || !selectedProject || !selectedEpisode) return;
|
||||
@@ -64,6 +74,7 @@ export function CanvasSidebar() {
|
||||
setShots(updated);
|
||||
message.success(`镜头列表已刷新(${updated.length} 个镜头)`);
|
||||
} catch (err) {
|
||||
logger.warn('canvas', '刷新镜头列表失败', err instanceof Error ? err : undefined);
|
||||
message.error(`刷新失败: ${(err as Error).message}`);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
@@ -81,14 +92,86 @@ export function CanvasSidebar() {
|
||||
const projects = await dataSource.listSubdirs(dir);
|
||||
setProjects(projects);
|
||||
} catch (err) {
|
||||
console.error('[CanvasSidebar] 加载项目列表失败:', err);
|
||||
logger.warn('canvas', '加载项目列表失败', err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('canvas', '打开目录失败', err instanceof Error ? err : undefined);
|
||||
message.error((err as Error).message || '打开目录失败');
|
||||
}
|
||||
};
|
||||
|
||||
// ── 右键菜单操作 ──
|
||||
|
||||
// 新建镜头
|
||||
const handleCreateShot = useCallback(async (dirName: string) => {
|
||||
if (!shotsBasePath) return;
|
||||
try {
|
||||
const dataSource = getDataSource();
|
||||
const episodePath = `${shotsBasePath}\\${selectedEpisode}`;
|
||||
await dataSource.createDirectory(episodePath, dirName);
|
||||
// 刷新镜头列表
|
||||
const updated = await dataSource.listSubdirs(episodePath);
|
||||
setShots(updated);
|
||||
// 自动选中
|
||||
selectShot(dirName);
|
||||
message.success(`镜头 "${dirName}" 已创建`);
|
||||
} catch (err) {
|
||||
logger.warn('canvas', '创建镜头失败', err instanceof Error ? err : undefined);
|
||||
message.error(`创建失败: ${(err as Error).message}`);
|
||||
}
|
||||
}, [shotsBasePath, selectedEpisode, getDataSource, setShots, selectShot, message]);
|
||||
|
||||
// 重命名镜头
|
||||
const handleRenameShot = useCallback((_shotName: string) => {
|
||||
message.info('重命名功能待实现');
|
||||
}, [message]);
|
||||
|
||||
// 删除镜头
|
||||
const handleDeleteShot = useCallback((shotName: string) => {
|
||||
if (!shotsBasePath || !selectedEpisode) return;
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除镜头「${shotName}」及其所有内容吗?此操作不可撤销。`,
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const dataSource = getDataSource();
|
||||
const shotPath = `${shotsBasePath}\\${selectedEpisode}\\${shotName}`;
|
||||
await dataSource.deleteFile(shotPath);
|
||||
// 刷新列表
|
||||
const episodePath = `${shotsBasePath}\\${selectedEpisode}`;
|
||||
const updated = await dataSource.listSubdirs(episodePath);
|
||||
setShots(updated);
|
||||
message.success(`已删除镜头「${shotName}」`);
|
||||
} catch (err) {
|
||||
logger.warn('canvas', '删除镜头失败', err instanceof Error ? err : undefined);
|
||||
message.error(`删除失败: ${(err as Error).message}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [shotsBasePath, selectedEpisode, getDataSource, setShots, message]);
|
||||
|
||||
// 在文件夹中打开
|
||||
const handleOpenShotFolder = useCallback((shotName: string) => {
|
||||
if (!shotsBasePath || !selectedEpisode) return;
|
||||
const shotPath = `${shotsBasePath}\\${selectedEpisode}\\${shotName}`;
|
||||
window.fileStorage?.showItemInFolder(shotPath);
|
||||
}, [shotsBasePath, selectedEpisode]);
|
||||
|
||||
// 生成单个镜头项的右键菜单
|
||||
const getShotContextItems = useCallback((shotName: string) =>
|
||||
getShotMenuItems({
|
||||
canOperate: !!workspaceRoot && !!selectedProject && !!selectedEpisode,
|
||||
shotName,
|
||||
onNewShot: () => setCreateShotOpen(true),
|
||||
onRename: handleRenameShot,
|
||||
onDelete: handleDeleteShot,
|
||||
onOpenFolder: handleOpenShotFolder,
|
||||
}), [workspaceRoot, selectedProject, selectedEpisode, handleRenameShot, handleDeleteShot, handleOpenShotFolder]);
|
||||
|
||||
// ── 样式变量 ──
|
||||
const bg = isDark ? '#0F0D21' : '#F5F3FF';
|
||||
const borderColor = isDark ? '#2D2A4A' : '#E5E0F0';
|
||||
@@ -203,8 +286,7 @@ export function CanvasSidebar() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 镜头列表 ── */}
|
||||
{/* minHeight: 0 是 flexbox 关键修复:防止列表内容撑开容器、导致底部按钮被推出视口 */}
|
||||
{/* ── 镜头列表(每项支持右键菜单) ── */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
@@ -212,7 +294,6 @@ export function CanvasSidebar() {
|
||||
overflowX: 'hidden',
|
||||
minHeight: 0,
|
||||
padding: '2px 0',
|
||||
// 自定义滚动条样式
|
||||
scrollbarWidth: 'thin' as const,
|
||||
scrollbarColor: isDark
|
||||
? '#3E3A6A transparent'
|
||||
@@ -271,33 +352,34 @@ export function CanvasSidebar() {
|
||||
{filteredShots.map((shot) => {
|
||||
const active = shot === selectedShot;
|
||||
return (
|
||||
<div
|
||||
key={shot}
|
||||
onClick={() => handleShotClick(shot)}
|
||||
style={{
|
||||
padding: '5px 12px',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
color: active ? activeColor : textColor,
|
||||
background: active ? activeBg : 'transparent',
|
||||
transition: 'background 0.15s',
|
||||
userSelect: 'none',
|
||||
borderLeft: active
|
||||
? `3px solid ${activeColor}`
|
||||
: '3px solid transparent',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!active)
|
||||
(e.currentTarget as HTMLElement).style.background = hoverBg;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!active)
|
||||
(e.currentTarget as HTMLElement).style.background =
|
||||
'transparent';
|
||||
}}
|
||||
>
|
||||
{shot}
|
||||
</div>
|
||||
<ContextMenu key={shot} items={getShotContextItems(shot)}>
|
||||
<div
|
||||
onClick={() => handleShotClick(shot)}
|
||||
style={{
|
||||
padding: '5px 12px',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
color: active ? activeColor : textColor,
|
||||
background: active ? activeBg : 'transparent',
|
||||
transition: 'background 0.15s',
|
||||
userSelect: 'none',
|
||||
borderLeft: active
|
||||
? `3px solid ${activeColor}`
|
||||
: '3px solid transparent',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!active)
|
||||
(e.currentTarget as HTMLElement).style.background = hoverBg;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!active)
|
||||
(e.currentTarget as HTMLElement).style.background =
|
||||
'transparent';
|
||||
}}
|
||||
>
|
||||
{shot}
|
||||
</div>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
@@ -335,6 +417,14 @@ export function CanvasSidebar() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 新建镜头 Modal */}
|
||||
<DirectoryCreateModal
|
||||
open={createShotOpen}
|
||||
title="新建镜头"
|
||||
onOk={handleCreateShot}
|
||||
onCancel={() => setCreateShotOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,14 +2,16 @@
|
||||
// EpisodeSelector — 集数下拉选择器
|
||||
//
|
||||
// 列出 selectedProject/shots/ 下的子文件夹作为可选集数。
|
||||
// 右击可新建集数。Assets 阶段隐藏。
|
||||
// 右击菜单项定义见 menus/episode-menu.ts
|
||||
// ============================================================
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { logger } from '@/shared/utils/bus';
|
||||
import { Select, App } from 'antd';
|
||||
import { useCanvasStore } from '../store/useCanvasStore';
|
||||
import { ContextMenu } from './ContextMenu';
|
||||
import { useCanvasStore } from '../../stores/useCanvasStore';
|
||||
import { ContextMenu } from '@/shared/components/ContextMenu';
|
||||
import { DirectoryCreateModal } from './DirectoryCreateModal';
|
||||
import { getEpisodeMenuItems } from '../../controllers/menus';
|
||||
|
||||
export function EpisodeSelector() {
|
||||
const { message } = App.useApp();
|
||||
@@ -28,7 +30,6 @@ export function EpisodeSelector() {
|
||||
const handleSelect = useCallback(
|
||||
async (name: string) => {
|
||||
selectEpisode(name);
|
||||
// 异步加载该集数下的镜头列表
|
||||
try {
|
||||
if (!workspaceRoot || !selectedProject) return;
|
||||
const dataSource = getDataSource();
|
||||
@@ -36,7 +37,7 @@ export function EpisodeSelector() {
|
||||
const shots = await dataSource.listSubdirs(shotsPath);
|
||||
setShots(shots);
|
||||
} catch (err) {
|
||||
console.error('[EpisodeSelector] 加载镜头列表失败:', err);
|
||||
logger.warn('canvas', '加载镜头列表失败', err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
},
|
||||
[workspaceRoot, selectedProject, selectEpisode, setShots, getDataSource],
|
||||
@@ -49,19 +50,33 @@ export function EpisodeSelector() {
|
||||
const dataSource = getDataSource();
|
||||
const parentPath = `${workspaceRoot}\\${selectedProject}\\shots`;
|
||||
await dataSource.createDirectory(parentPath, dirName);
|
||||
// 刷新集数列表
|
||||
const updated = await dataSource.listSubdirs(parentPath);
|
||||
setEpisodes(updated);
|
||||
// 自动选中新集数
|
||||
await handleSelect(dirName);
|
||||
message.success(`集数 "${dirName}" 已创建`);
|
||||
} catch (err) {
|
||||
message.error(`创建失败: ${(err as Error).message}`);
|
||||
logger.warn('canvas', '创建集数失败', err instanceof Error ? err : undefined);
|
||||
message.error(`创建失败: ${(err as Error).message}`);
|
||||
}
|
||||
},
|
||||
[workspaceRoot, selectedProject, getDataSource, setEpisodes, handleSelect, message],
|
||||
);
|
||||
|
||||
// ── 右键菜单定义 ──
|
||||
|
||||
const menuItems = getEpisodeMenuItems({
|
||||
canCreate: !!selectedProject,
|
||||
canOpenFolder: !!selectedProject && !!selectedEpisode && !!workspaceRoot,
|
||||
onCreate: () => setCreateOpen(true),
|
||||
onStats: () => message.info('统计集数功能待实现'),
|
||||
onOpenFolder: () => {
|
||||
if (workspaceRoot && selectedProject && selectedEpisode) {
|
||||
const path = `${workspaceRoot}\\${selectedProject}\\shots\\${selectedEpisode}`;
|
||||
window.fileStorage?.showItemInFolder(path);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Assets 阶段隐藏集数选择器
|
||||
if (currentStage === 'Assets') return null;
|
||||
|
||||
@@ -69,15 +84,7 @@ export function EpisodeSelector() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu
|
||||
items={[
|
||||
{
|
||||
label: '新建集数',
|
||||
onClick: () => setCreateOpen(true),
|
||||
disabled: !selectedProject,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ContextMenu items={menuItems}>
|
||||
<Select
|
||||
value={selectedEpisode}
|
||||
onChange={handleSelect}
|
||||
@@ -2,14 +2,16 @@
|
||||
// ProjectSelector — 项目下拉选择器
|
||||
//
|
||||
// 列出 workspaceRoot 下的子文件夹作为可选项目。
|
||||
// 右击可新建项目。
|
||||
// 右击菜单项定义见 menus/project-menu.ts
|
||||
// ============================================================
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { logger } from '@/shared/utils/bus';
|
||||
import { Select, App } from 'antd';
|
||||
import { useCanvasStore } from '../store/useCanvasStore';
|
||||
import { ContextMenu } from './ContextMenu';
|
||||
import { useCanvasStore } from '../../stores/useCanvasStore';
|
||||
import { ContextMenu } from '@/shared/components/ContextMenu';
|
||||
import { DirectoryCreateModal } from './DirectoryCreateModal';
|
||||
import { getProjectMenuItems } from '../../controllers/menus';
|
||||
|
||||
export function ProjectSelector() {
|
||||
const { message } = App.useApp();
|
||||
@@ -26,14 +28,13 @@ export function ProjectSelector() {
|
||||
const handleSelect = useCallback(
|
||||
async (name: string) => {
|
||||
selectProject(name);
|
||||
// 异步加载该项目的集数列表
|
||||
try {
|
||||
const dataSource = getDataSource();
|
||||
const epPath = `${workspaceRoot}\\${name}\\shots`;
|
||||
const episodes = await dataSource.listSubdirs(epPath);
|
||||
setEpisodes(episodes);
|
||||
} catch (err) {
|
||||
console.error('[ProjectSelector] 加载集数列表失败:', err);
|
||||
logger.warn('canvas', '加载集数列表失败', err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
},
|
||||
[workspaceRoot, selectProject, setEpisodes, getDataSource],
|
||||
@@ -45,32 +46,39 @@ export function ProjectSelector() {
|
||||
try {
|
||||
const dataSource = getDataSource();
|
||||
await dataSource.createDirectory(workspaceRoot, dirName);
|
||||
// 刷新项目列表
|
||||
const updated = await dataSource.listSubdirs(workspaceRoot);
|
||||
setProjects(updated);
|
||||
// 自动选中新项目
|
||||
await handleSelect(dirName);
|
||||
message.success(`项目 "${dirName}" 已创建`);
|
||||
} catch (err) {
|
||||
message.error(`创建失败: ${(err as Error).message}`);
|
||||
logger.warn('canvas', '创建项目失败', err instanceof Error ? err : undefined);
|
||||
message.error(`创建失败: ${(err as Error).message}`);
|
||||
}
|
||||
},
|
||||
[workspaceRoot, getDataSource, setProjects, handleSelect, message],
|
||||
);
|
||||
|
||||
// ── 右键菜单定义 ──
|
||||
|
||||
const menuItems = getProjectMenuItems({
|
||||
canCreate: !!workspaceRoot,
|
||||
canOpenFolder: !!selectedProject && !!workspaceRoot,
|
||||
onCreate: () => setCreateOpen(true),
|
||||
onStats: () => message.info('统计项目功能待实现'),
|
||||
onBatchShots: () => message.info('批量创建镜头功能待实现'),
|
||||
onOpenFolder: () => {
|
||||
if (workspaceRoot && selectedProject) {
|
||||
const path = `${workspaceRoot}\\${selectedProject}`;
|
||||
window.fileStorage?.showItemInFolder(path);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const options = projects.map((p) => ({ value: p, label: p }));
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu
|
||||
items={[
|
||||
{
|
||||
label: '新建项目',
|
||||
onClick: () => setCreateOpen(true),
|
||||
disabled: !workspaceRoot,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ContextMenu items={menuItems}>
|
||||
<Select
|
||||
value={selectedProject}
|
||||
onChange={handleSelect}
|
||||
@@ -6,8 +6,8 @@
|
||||
// ============================================================
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useCanvasStore } from '../store/useCanvasStore';
|
||||
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||
import { useCanvasStore } from '../../stores/useCanvasStore';
|
||||
import { useTheme } from '@/app/providers/ThemeProvider';
|
||||
|
||||
const SHOT_LIST_WIDTH = 200;
|
||||
|
||||
@@ -6,6 +6,4 @@ export { ProjectSelector } from './ProjectSelector';
|
||||
export { EpisodeSelector } from './EpisodeSelector';
|
||||
export { ShotList } from './ShotList';
|
||||
export { CanvasSidebar } from './CanvasSidebar';
|
||||
export { ContextMenu } from './ContextMenu';
|
||||
export type { ContextMenuItem } from './ContextMenu';
|
||||
export { DirectoryCreateModal } from './DirectoryCreateModal';
|
||||
// ContextMenu 已提升为全应用共享组件:@/shared/components/ContextMenuexport { DirectoryCreateModal } from './DirectoryCreateModal';
|
||||
@@ -9,8 +9,8 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Input } from 'antd';
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||
import { useCanvasStore } from '../store/useCanvasStore';
|
||||
import { useTheme } from '@/app/providers/ThemeProvider';
|
||||
import { useCanvasStore } from '../../stores/useCanvasStore';
|
||||
|
||||
export function CanvasFilterBar() {
|
||||
const { isDark } = useTheme();
|
||||
@@ -8,8 +8,8 @@
|
||||
// 注:项目/集数选择器已移至 CanvasSidebar。
|
||||
// ============================================================
|
||||
|
||||
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||
import { useCanvasStore } from '../store/useCanvasStore';
|
||||
import { useTheme } from '@/app/providers/ThemeProvider';
|
||||
import { useCanvasStore } from '../../stores/useCanvasStore';
|
||||
import { StageTabs } from './StageTabs';
|
||||
import { CategoryTabs } from './CategoryTabs';
|
||||
import { CanvasFilterBar } from './CanvasFilterBar';
|
||||
@@ -14,8 +14,8 @@
|
||||
// ============================================================
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||
import { useCanvasStore, type AssetTypeFilter, type AuditStatus } from '../store/useCanvasStore';
|
||||
import { useTheme } from '@/app/providers/ThemeProvider';
|
||||
import { useCanvasStore, type AssetTypeFilter, type AuditStatus } from '../../stores/useCanvasStore';
|
||||
|
||||
interface CategoryTab {
|
||||
key: string;
|
||||
@@ -9,11 +9,12 @@
|
||||
// ============================================================
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { logger } from '@/shared/utils/bus';
|
||||
import { Button } from 'antd';
|
||||
import { ExpandOutlined } from '@ant-design/icons';
|
||||
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||
import { useCanvasStore, type StageType } from '../store/useCanvasStore';
|
||||
import { STAGE_CONFIGS } from '../utils/constants';
|
||||
import { useTheme } from '@/app/providers/ThemeProvider';
|
||||
import { useCanvasStore, type StageType } from '../../stores/useCanvasStore';
|
||||
import { STAGE_CONFIGS } from '../../utils/constants';
|
||||
|
||||
export function StageTabs() {
|
||||
const { isDark } = useTheme();
|
||||
@@ -27,7 +28,7 @@ export function StageTabs() {
|
||||
const projectPath = getProjectPath();
|
||||
window.canvas?.openWindow(projectPath ?? undefined);
|
||||
} catch (err) {
|
||||
console.error('[StageTabs] 打开无限画布窗口失败:', err);
|
||||
logger.warn('canvas', '打开无限画布窗口失败', err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}, [getProjectPath]);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user