Compare commits
16 Commits
bc33b81121
...
dev-main
| Author | SHA1 | Date | |
|---|---|---|---|
| 136a17c0bf | |||
| 904edecd90 | |||
| 337d6c1205 | |||
| e0d91335c7 | |||
| b04b5a0390 | |||
| 48144ed784 | |||
| 4ea414c9c7 | |||
| dd8f84ae27 | |||
| 8b697e775d | |||
| e4d66a2ac0 | |||
| e4c9ae8fc6 | |||
| e1c8eff946 | |||
| 72a0290693 | |||
| 06e6486aa3 | |||
| 4472d406d9 | |||
| 0e6d996718 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"pid": 77548,
|
||||
"pid": 1493920,
|
||||
"version": "0.9.9",
|
||||
"socketPath": "\\\\.\\pipe\\codegraph-aef859f0205bacea",
|
||||
"startedAt": 1781164466085
|
||||
"startedAt": 1782701380684
|
||||
}
|
||||
|
||||
@@ -9,9 +9,5 @@ VITE_API_BASE_URL=https://www.heixiu.net
|
||||
# 主进程 — 更新检查 API(指向本地测试服务器)
|
||||
UPDATE_API_URL=http://127.0.0.1:8000
|
||||
|
||||
# 版本类型
|
||||
VITE_EDITION=personal
|
||||
EDITION=personal
|
||||
|
||||
# 应用标题后缀
|
||||
VITE_APP_TITLE=船长·HeiXiu
|
||||
|
||||
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)
|
||||
|
||||
@@ -10,9 +10,5 @@ VITE_API_BASE_URL=https://www.heixiu.net
|
||||
# 主进程 — 更新检查 API(生产服务器)
|
||||
UPDATE_API_URL=https://www.heixiu.net
|
||||
|
||||
# 版本类型
|
||||
VITE_EDITION=personal
|
||||
EDITION=personal
|
||||
|
||||
# 应用标题后缀
|
||||
VITE_APP_TITLE=船长·HeiXiu
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -6,7 +6,7 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
.codegraph/*
|
||||
node_modules
|
||||
# vite打包输出
|
||||
dist
|
||||
@@ -39,3 +39,10 @@ WORKLOG.md
|
||||
*open*.json
|
||||
response_*.json
|
||||
test-server/__pycache__/
|
||||
|
||||
/src/vite-env.d.ts.tmp.444812.865037551ae0
|
||||
|
||||
# 自动生成的文件夹 README 文档(根目录 README.md 保留)
|
||||
/**/README.md
|
||||
!/README.md
|
||||
infinite_canvas_140/**/*.*
|
||||
|
||||
279
CHANGELOG.md
279
CHANGELOG.md
@@ -3,6 +3,285 @@
|
||||
> 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。
|
||||
---
|
||||
|
||||
## 0.0.29(2026-06-29)
|
||||
|
||||
**IPC/Preload/Services 模块化拆分 — 单一职责 + 向后兼容**
|
||||
|
||||
### 模块化拆分
|
||||
|
||||
- **shared/constants/ipc**:`ipc-channels.ts` (105行) → `ipc/` 模块 (base/storage/canvas/updater)
|
||||
- **electron/preload**:`preload.ts` (229行) → `preload/` 模块 (base/safe-storage/file-storage/canvas/app-runtime)
|
||||
- **electron/main/ipc**:
|
||||
- `canvas-ipc.ts` (705行) → `canvas/` 模块 (types/walk/sidecar/mime/thumbnail/scan-roots)
|
||||
- `storage-ipc.ts` (357行) → 提取 `utils/` (path/download/thumbnail)
|
||||
- **electron/main/updater**:`updater.ts` (479行) → `updater/` 模块 (provider/platform-updaters)
|
||||
- **src/services/request**:`request.ts` (479行) → `request/` 模块 (token/interceptors/methods)
|
||||
- **src/shared/utils/pricing**:`pricing.ts` (488行) → `pricing/` 模块 (types/helpers/format/calculate/fields)
|
||||
- **ModelInputForm.tsx**:提取 `utils.ts` + `DependentFormItem.tsx` 子组件
|
||||
|
||||
### 架构优化
|
||||
|
||||
- 所有拆分均保持向后兼容(旧导入路径仍可用)
|
||||
- 每个模块单一职责,平均 ~130 行
|
||||
- TypeScript 编译零错误
|
||||
|
||||
---
|
||||
|
||||
## 0.0.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 重构 + 预览键盘控制 + 拖拽体验优化**
|
||||
|
||||
### 无限画布
|
||||
|
||||
- **双栏布局重构**:AntD Layout(Sider + Content),左侧导航 + 右侧画布,页面级别渲染预览组件解耦 DOM
|
||||
- **CanvasSidebar**:项目/集数选择器 + 镜头搜索 + 镜头列表(滚动条 + 底部固定刷新按钮)+ 工作区切换
|
||||
- **CategoryTabs 双模式**:Assets 阶段 → 全部/角色/道具/场景/收藏;其他阶段 → 所有[阶段名]/收藏/待审核/通过/返修/已弃用
|
||||
- **分类数量徽标**:每个标签显示数据计数,"收藏"标签额外显示媒体类型细分(视频/图片/音频)
|
||||
- **Ctrl/Cmd + 拖拽平移**:替代 Space 键修饰,释放 Space 给媒体控制;`pointerEvents: none` 防止拖拽时误抓缩略图
|
||||
- **画布锁**:预览打开时 `previewActiveRef` 双守卫阻止穿透缩放/拖拽
|
||||
- **无限画布入口按钮**:StageTabs 右侧按钮,调用 `OPEN_CANVAS_WINDOW` IPC
|
||||
|
||||
### 预览键盘控制
|
||||
|
||||
- **Space 暂停/播放**:capture 阶段拦截 + `stopPropagation`,避免与原生 `<video controls>` 双重触发
|
||||
- **← → 快退/快进**:capture 阶段直接设置 `currentTime`,步长 ±0.5 秒
|
||||
- **↑ ↓ 音量**:透传给原生 controls 处理
|
||||
|
||||
### 主进程
|
||||
|
||||
- **local-media:// Range 头转发**:协议 handler 只提取并转发 `Range` 请求头到 `net.fetch`,确保音视频 seek 返回正确的 206 Partial Content
|
||||
|
||||
### UI 调整
|
||||
|
||||
- 全局字号上调(侧栏、工具栏、标签、状态栏等)
|
||||
- CanvasSidebar 滚动条定制 + 主题色适配
|
||||
- ProjectSelector / EpisodeSelector 宽度自适应
|
||||
|
||||
---
|
||||
## 0.0.26(2026-06-23)
|
||||
|
||||
**无限画布滚轮缩放 + 项目管理入口 + 文件拖拽**
|
||||
|
||||
### 无限画布(企业版项目管理)
|
||||
|
||||
- **滚轮缩放视口**:纯滚轮 10%/tick 快速缩放,Ctrl/Cmd+滚轮 3%/tick 精细控制,范围 2%~1600%
|
||||
- **鼠标拖拽平移** + **Ctrl/Cmd+0 重置缩放** + **ZoomOverlay 百分比指示器**
|
||||
- **预览事件穿透防护**:预览打开时锁定底层视口(`previewActiveRef` 双守卫)
|
||||
- **预览内独立缩放**:图片灯箱支持滚轮缩放(2%~800%) + 拖拽平移 + 百分比指示器
|
||||
- **原生文件拖拽**:缩略图/预览 `webContents.startDrag()` → Photoshop/资源管理器等外部应用
|
||||
- **local-media:// 协议**:支持 UNC 网络路径 + 本地路径安全加载
|
||||
|
||||
### 主进程
|
||||
|
||||
- **画布窗口 `before-input-event`**:拦截 Chromium 页面级缩放,滚轮事件完整到达渲染层
|
||||
- **`canvas-ipc.ts`**:文件扫描/缩略图生成/文件操作/sidecar 读写/startDrag 等 15+ IPC handler
|
||||
- **数据源抽象**:`ICanvasDataSource` 接口,离线 `LocalFileDataSource` + 未来在线模式平滑切换
|
||||
|
||||
### Bug 修复
|
||||
|
||||
- **passive event listener**:React `onWheel` → window 级原生 `{ passive: false }` listener
|
||||
- **Zustand v5 `getState()` 批处理旧值**:双 `ref`(`currentZoomRef` + `setZoomRef`)读写,绕过原生事件中的批处理快照
|
||||
|
||||
---
|
||||
## 0.0.25(2026-06-22)
|
||||
|
||||
**业务错误 Modal 引导 + 左侧栏 UI 增强**
|
||||
|
||||
### 业务错误 Modal 引导体系
|
||||
|
||||
- **HTTP 402 + code 1001 → 余额不足 Modal**:`request.ts` 拦截器双条件精确匹配,`App.tsx` 弹 Modal 引导"去充值"
|
||||
- **HTTP 403 + code 4040 → 软件到期 Modal**:拦截 403+4040,弹 Modal 引导"去购买 VIP"
|
||||
- **`BusinessErrorEvent` 事件总线类型**:标准化 `{ code, message }` 负载,新增 `BALANCE_INSUFFICIENT` 和 `SUBSCRIPTION_EXPIRED` 事件
|
||||
- **`RequestError.displayed` 标记**:Modal 已展示的错误标记 `displayed=true`,`ModelInputForm.doSubmit` 读取标记跳过 `message.error` 重复 toast
|
||||
- **防御性兜底**:HTTP 200 + code 1001 也入拦截器(兼容后端行为变体)
|
||||
|
||||
### 左侧栏 UI 增强
|
||||
|
||||
- **模型列表 emoji 渲染**:从后端 `ui_config.badge` / `meta_config.icon` 提取 emoji 显示在模型名称前
|
||||
- **标题栏色条加深**:模型选择器 + 任务记录标题栏加 `borderLeft: 3px` 色条 + `colorFillSecondary` 背景 + 图标染色
|
||||
- **任务列表分页器固定**:`Pagination` 独立到表格外部,`pagination={false}` on Table,分页器始终固定在底部
|
||||
- **水平滚动条固定**:独立 `div` + 双向 `scroll` 事件同步 + MutationObserver,不随垂直滚动移动
|
||||
- **统一滚动条样式**:`::-webkit-scrollbar` 暗色/亮色双套,纵向 6px、横向 6px,风格统一
|
||||
- **表头取消加粗**:`.ant-table-thead > tr > th { font-weight: normal }`
|
||||
- **模型列表与任务列表间距**:`paddingBottom: 4` / `paddingTop: 4`
|
||||
|
||||
---
|
||||
## 0.0.24(2026-06-22)
|
||||
|
||||
**去除构建时版本变量 + 窗口标题动态版控 + 退出登录保留缓存**
|
||||
|
||||
### 版控体系重构
|
||||
|
||||
- **去除构建时 `EDITION` 环境变量**:`--personal`/`--enterprise` 参数从 `build.mjs` 移除,`vite.config.ts` 不再注入 `process.env.EDITION`,`.env.*` 清理相应行
|
||||
- **统一的安装包文件名**:`船长-HeiXiu-{os}-{版本号}-Setup.{ext}`(移除 `${env.EDITION}`)
|
||||
- **`package.json` 简化**:移除 3 条 `:enterprise` 构建脚本
|
||||
- **`scripts/*.mjs` 统一**:`upload-oss`/`generate-update-info`/`publish-release` 不再接受 `edition` 参数
|
||||
|
||||
### 窗口标题动态版控
|
||||
|
||||
- **新格式**:`船长-HeiXiu-{os}-{版本号}`(未登录)/ `船长-HeiXiu-{os}-{版本号}·{版控}`(登录后)
|
||||
- **运行时链路**:`AppProvider` 根据 `user.account_type` 派生 `edition` → `useEffect` → `window.appRuntime.setWindowEdition()` → IPC `set-window-title` → 主进程 `buildWindowTitle` → `mainWindow.setTitle`
|
||||
- **Edtion 类型守卫**:`src/vite-env.d.ts` + `electron/electron-env.d.ts` 双端类型声明
|
||||
- **更新检查 API** 移除 `edition` 查询参数
|
||||
|
||||
### 本地存储生命周期修正
|
||||
|
||||
- **退出登录不再自动清缓存**:`AppProvider.logout()` 移除 `clearUserStorage()` 调用。数据已按 `user_id` 隔离,同一用户重新登录缓存直接命中
|
||||
- **`clearUserStorage` 竞态修复**:添加 `isOpen()` 前置守卫,防止数据库未初始化时执行 SQL;移除尾部 `closeDatabase()`,确保退出→重新登录周期内数据库持续可用
|
||||
- **手动清理保留**:`DataManageSetting` 的「清理缓存」按钮仍可主动触发
|
||||
|
||||
---
|
||||
## 0.0.23(2026-06-22)
|
||||
|
||||
**三栏等比缩放修复 + 代码重组**
|
||||
|
||||
### 三栏布局修复
|
||||
|
||||
- **约束逻辑修正**:双遍最小值约束改为 clamp + 超限兜底,修复窗口缩小至 1036px 以下时中列被挤出视口
|
||||
- **双触发器**:`ResizeObserver` + `window.resize` 事件双重驱动,防止 React 批量更新吞掉重绘
|
||||
- **三栏等比缩放**:旧算法只保 left:right 比例,中列被动吸收剩余;新算法通过 `prevWidthRef` 反推中列上次宽度,三栏按各自占比同步缩放——窗口从半屏拉到全屏保持 ≈1:1:1
|
||||
|
||||
### 代码重组(第二轮)
|
||||
|
||||
- **src/utils/**:11 文件精简为 7 文件 —— `env.ts`(platform+device)、`bus.ts`(event-bus+ipc+logger)、`display.ts`(display+logo+type)、`media-upload.ts`
|
||||
- **controls/**:14 文件精简为 6 文件 —— `simple-widgets.tsx`(6合1)、`upload-widgets.tsx`(ImageInput+VideoInput 去重)、`list-upload-widgets.tsx`(ImageListInput+AudioListInput)
|
||||
- **FormField 提升**:从 `auth/components/` 提升到 `src/components/ui/`,`FieldConfig` 类型同步移到 `ui/types.ts`
|
||||
- **data-sync 归位**:从 `settings/sections/` 移到 `infrastructure/storage/`
|
||||
- **electron/main/ 分组**:3 个 IPC handler(log-ipc/safe-storage-ipc/storage-ipc)收拢到 `ipc/` 子目录
|
||||
- **shell/ → frame/**:`src/components/shell/` 重命名为 `frame/`,更直观表达"应用框架"
|
||||
- **storage/ 分层**:15 文件平铺 → `core/`(4)+ `stores/`(5)+ `services/`(4)+ 入口
|
||||
|
||||
### 文档
|
||||
|
||||
- 37 个文件夹全部生成 README.md(不进 Git)
|
||||
- PROJECT.md 目录树同步更新
|
||||
|
||||
---
|
||||
## 0.0.22(2026-06-18)
|
||||
|
||||
**三栏布局比例缩放 + SeedanceContent 视频参考上传 + 本地存储基建**
|
||||
|
||||
### 三栏布局:比例自适应缩放
|
||||
|
||||
- ResizeObserver 改为**等比例分配**策略(替代固定默认值恢复):窗口 Snap/最大化/手动缩放时左-中-右三栏按当前比例同步缩放
|
||||
- 修复拖拽期间窗口缩放竞态:`handleMouseUp` 即时清除 `draggingRef` + `needsRecalcRef` 延迟补算,解决拖拽中最大化后放开鼠标布局卡死
|
||||
- 使用 `containerRef.current` 动态读取 DOM(替代闭包捕获 mount 时 `el`),避免 Provider 重渲染后读到旧 DOM
|
||||
|
||||
### Seedance2Content 内容编排
|
||||
|
||||
- `SeedanceContent` 从占位文本重构为完整内容编排控件:文本描述 + 参考图片/视频/音频三条上传通道
|
||||
- 根据 `param_schema[].spec.image_files / video_files / audio_files` 动态渲染对应上传区域,**不依赖 widget 名称的隐式约定**
|
||||
- 内部值类型 `{ text, imageUrls[], videoUrls[], audioUrls[] }` → 表单 JSON 字符串 → 提交时序列化为 API `ContentItem[]`(`text` / `image_url` / `video_url` / `audio_url` 判别联合)
|
||||
- `useModelUI` 新增 `widgetName` 字段 + `spec` 媒体文件计数传参(`imageFiles`/`videoFiles`/`audioFiles`/`uploadProvider`)
|
||||
- `ModelInputForm.doSubmit` 新增 `seedance2Content` 分支序列化逻辑
|
||||
|
||||
### 图片上传兼容视频文件
|
||||
|
||||
- `ImageInput` / `ImageListInput`:`buildAccept` 根据 `file_filter` 自动区分 `image/` vs `video/` 前缀(原硬编码 `image/` 导致视频文件不可选)
|
||||
- `createMediaBeforeUpload` 根据 `filters` 动态放行 `video/*` MIME
|
||||
- 公共模块 `media-upload-utils.ts`:`VIDEO_EXTENSIONS` + `buildAccept()` + `createMediaBeforeUpload()` 三组件共享
|
||||
|
||||
### 本地存储基建(sql.js + safeStorage)
|
||||
|
||||
- **数据库**:sql.js(WASM SQLite)渲染进程内存数据库,DDL 7 张表(task_records / order_records / param_history / media_cache / sync_cursor / app_settings / _schema_version)
|
||||
- **加密**:safeStorage(OS DPAPI/Keychain)加密持久化,base64 编解码管线
|
||||
- **IPC 文件操作**:8 个双向 IPC 通道(FILE_WRITE/READ/DELETE/MAKE_DIR/SHOW_ITEM_IN_FOLDER/GET_DATA_DIR/FILE_SIZE/FILE_EXISTS),`resolveSafe()` 路径穿越防护
|
||||
- **数据目录**:`<userData>/heixiu-data/`(沙箱内),含 `heixiu.db`(加密镜像)+ `media-cache/<userId>/<date>/`
|
||||
- **用户隔离**:JWT `user_id` 区分数据,`getTodayKey()` 日期分桶
|
||||
- **同步策略**:本地优先 + 手动触发("加载历史数据"按钮),游标分页(`sync_cursor` 表),每批 100 条
|
||||
- **设置集成**:`DataManageSetting` 卡片(缓存统计 + 加载历史数据 + 保存到磁盘 + 清理缓存)
|
||||
|
||||
### 媒体输出预览优化
|
||||
|
||||
- `<img>/<video>` 直接加载远程 URL(无 CORS 限制),移除阻塞式 axios 下载
|
||||
- 类型检测:URL 扩展名正则 + `output_type` 补充(CDN 无扩展名链接)
|
||||
- `<Image fallback>` 加载失败占位图
|
||||
|
||||
### 其他
|
||||
|
||||
- 修复 `errorMessageRef` 在渲染期间写入(react-hooks/refs 警告)
|
||||
- CSP 添加 `wasm-unsafe-eval` 支持 WebAssembly + `https:` CDN 媒体源
|
||||
|
||||
---
|
||||
## 0.0.21(2026-06-15)
|
||||
|
||||
**文件结构重组:components/ pages/ 按职责分层**
|
||||
|
||||
### components/ → 三层拆分
|
||||
|
||||
- `providers/`(状态管理):AppProvider / ThemeProvider / SettingsProvider
|
||||
- `shell/`(应用外壳):Layout / PageDispatcher / navbar/
|
||||
- `ui/`(通用原子):FloatingPanel / LegalTextModal
|
||||
|
||||
### pages/ → 重命名消除歧义
|
||||
|
||||
- `headers/` → `panels/`(NavBar 面板页,非 HTTP headers)
|
||||
- `login/` → `auth/`(含登录/注册/忘记密码/修改密码)
|
||||
- `settings/blocks/` → `sections/`
|
||||
- `home/components/useUI/` → `home/controls/`
|
||||
|
||||
### home/ → 四层子结构
|
||||
|
||||
- `panels/` — 布局壳(LeftPanel / CenterPanel / RightPanel)
|
||||
- `features/` — 功能面板(Banner / ModelInput / ModelSelector / Output / TaskHistory)
|
||||
- `controls/` — 表单控件库(12 个控件 + Widget 注册表)
|
||||
- `hooks/` — 纯 Hook(useModelUI)
|
||||
|
||||
### 其他
|
||||
|
||||
- HomeContext 从 HomeContent.tsx 拆分独立文件(解决 react-refresh ESLint 警告)
|
||||
- uploadValidation 内联到 ImageInput(controls/ 不再混工具函数)
|
||||
- 删除空占位 Test3.tsx
|
||||
|
||||
### 支付系统 & 订单明细
|
||||
|
||||
- **RechargePage antd 6.x 适配**:`InputNumber.addonAfter` → `Space.Compact`、`Space direction` → `orientation`,消除弃用警告
|
||||
- **OrdersPage 订单明细页面**(`/orders`,方案 B — 独立订单管理页):
|
||||
- 状态筛选 Segmented(全部/待支付/已支付/已过期/支付失败)+ 分页 Table
|
||||
- "继续支付"操作:打开支付 URL + `startPolling` 单订单轮询
|
||||
- `pollingRef: Map<orderId, timer>` 管理多订单并发轮询,幂等启动(重复点击防重)
|
||||
- 局部 state 更新 + 终态全量刷新(`fetchOrders`)
|
||||
- `AbortController` 防竞态:切换筛选/分页时取消旧请求
|
||||
- `columns` 使用 `useMemo` 稳定引用
|
||||
- 错误态 Result + 重试按钮
|
||||
- **PageDispatcher**:注册 `/orders` 路由(width: 820, height: 640),新增 `CLOSE_PAGE` / `PAGE_LOCK` 事件监听
|
||||
- **payments.ts**:支付 API 模块类型体系(`CreateOrderResponseBody` → `OrderListResponseBody` / `OrderInfoResponseBody` 名义类型),`PaymentAPI.getOrdersListInfo` 支持 `AbortSignal`
|
||||
|
||||
---
|
||||
|
||||
## 0.0.20(2026-06-12)
|
||||
|
||||
**任务状态轮询 + Token 刷新容错增强 + 提交防抖 + 文件上传 Hook**
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
|
||||
- 进行任务时都需要考虑安全、性能、主题切换、业务自定义错误、事件总线驱动方式以及日志记录;
|
||||
- 进行系统级别的操作时,需要考虑系统差异,并处理兼容性。如WINDOWS、MAC OS;
|
||||
- 处理业务时,需要同时考虑兜底机制和业务自定义错误捕获;
|
||||
- 所有设计思路、代码实现均需要考虑以上三个条件。
|
||||
- 处理业务时,需要同时考虑兜底机制和业务自定义错误捕获,比如后端在修改了某些数据的枚举值但没有通知时,需要能够正常转化对用户友好的提示信息;
|
||||
- 新增文件时,需要注意各个文件夹当前平铺的文件数量,如果过多需要考虑合并文件或者合并为子文件夹;
|
||||
- 在编写代码时,尽量保持JSX文件只有HTML元素结构,减少style属性的使用。其他关于js操作应提取为单独的TS模块文件,如复杂页面交互逻辑、菜单组件的注册等;
|
||||
- 所有设计思路、代码实现均需要考虑以上五个条件。
|
||||
|
||||
# 相关文档说明
|
||||
|
||||
@@ -22,4 +24,5 @@
|
||||
- CLAUDE.md
|
||||
- 永远不要修改CLAUDE.md文档
|
||||
- 在各个文档中的更新需要保持精简。且职责划分保持严谨,除了架构、项目文档,已完成的事需要从TODO.md文档中移除。
|
||||
- 在没有得到修改指令时,不要修改文档文件。
|
||||
|
||||
|
||||
256
PROJECT.md
256
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)通过事件总线解耦:请求拦截器抛出自定义错误 →
|
||||
@@ -46,82 +46,181 @@
|
||||
|
||||
4. 窗口间通信统一通过主进程转发(或使用 MessagePort 等 Electron 支持的机制),保持窗口独立性。
|
||||
|
||||
- 路由与导航:主窗口为单页应用,使用 React Router v7+ 管理页面切换。**遵循 QT 多窗口模型映射原则**:
|
||||
- 路由与导航:主窗口为单页应用,不使用 React Router。所有页面切换通过事件总线 + PageDispatcher 实现:
|
||||
- 叠加层(设置、公告、素材库等)→ Modal/Drawer + 事件总线驱动,主页面不卸载
|
||||
- 页面跳转(真正的视图切换)→ React Router,需跨页保持的状态放 Provider 或 localStorage
|
||||
- NavBar 导航项点击 → `emit(OPEN_PAGE, { target, modalType })` → PageDispatcher 按 `PAGE_MAP` 注册表渲染对应容器
|
||||
- 每个页面路径在 `PageDispatcher.PAGE_MAP` 中注册一行(target → { component, modalType, width, height })
|
||||
- 需跨页保持的状态放 Provider 或 localStorage
|
||||
|
||||
- 辅助窗口(设置、预览、导入导出等)以 Modal/Drawer 叠加在主窗口之上,不与主窗口共享 DOM,但保持在同一个 React 组件树中以确保状态一致性。
|
||||
|
||||
# 目录结构
|
||||
|
||||
> 2026-06-29 重构:业务模块从 `pages/` + `services/modules/` 分散模式统一为 `modules/` 下的
|
||||
> VCHSM 五层架构(Views → Controllers → Hooks → Settings → Model)。
|
||||
|
||||
```md
|
||||
├── electron/ # Electron 主进程
|
||||
│ ├── main.ts # 入口:窗口创建 / IPC 注册 / 生命周期
|
||||
│ ├── preload.ts # 预加载脚本(contextBridge)
|
||||
│ └── main/
|
||||
│ ├── updater.ts # 自定义更新(API 版本检查 + OSS 下载 + NSIS 安装)
|
||||
│ ├── logger.ts # 主进程日志核心(JSON Lines 写入 / 轮转 / 清理)
|
||||
│ ├── log-ipc.ts # 渲染进程日志 IPC 通道接收
|
||||
│ ├── net-request.ts # Electron net.request 的 axios 风格封装
|
||||
│ ├── menu/index.ts # 系统菜单
|
||||
│ └── utils/ # 平台判断 / 图标路径
|
||||
├── src/ # 渲染进程(React 19 SPA)
|
||||
│ ├── main.tsx # React 挂载点 + 全局错误捕获
|
||||
│ ├── App.tsx # 根组件(路由 + 登录弹窗)
|
||||
│ ├── components/ # 通用 UI 组件 + Provider
|
||||
│ │ ├── AppProvider.tsx # 全局状态(platform / edition / login)
|
||||
│ │ ├── ThemeProvider.tsx # 主题状态(light / dark)
|
||||
│ │ ├── SettingsProvider.tsx # 设置状态(存储路径等)
|
||||
│ │ ├── Layout.tsx # 页面壳(NavBar + Outlet)
|
||||
│ │ └── navbar/ # 导航栏 + 公告抽屉
|
||||
│ ├── contexts/ # Context 定义(app / settings / theme)
|
||||
│ ├── hooks/ # 自定义 Hooks(useUpdater / useTheme)
|
||||
│ ├── pages/ # 页面组件
|
||||
│ │ ├── home/ # 首页仪表盘
|
||||
│ │ ├── login/ # 登录 / 注册页
|
||||
│ │ └── settings/ # 设置面板(主题 / 存储 / 账号安全 / 系统信息 / 更新)
|
||||
│ ├── router/index.tsx # HashRouter 路由配置
|
||||
│ ├── services/ # HTTP 请求封装
|
||||
│ │ ├── request.ts # Axios 封装 + 拦截器 + Token 刷新
|
||||
│ │ ├── types.ts # 自定义错误类型(RequestError / RefreshError)
|
||||
│ │ ├── auth-token.ts # refresh_token 管理
|
||||
│ │ └── modules/ # API 模块(auth / announcement)
|
||||
│ ├── theme/ # 主题系统
|
||||
│ │ ├── tokens.ts # 设计令牌
|
||||
│ │ └── globals.css # Tailwind + 全局样式
|
||||
│ └── utils/ # 纯函数工具
|
||||
│ ├── event-bus.ts # 事件总线(on / off / emit + 日志钩子)
|
||||
│ ├── logger.ts # 渲染进程日志器(IPC 转发 / DEV 控制台)
|
||||
│ ├── ipc.ts # 安全 IPC 守卫(safeIpcOn / send / invoke)
|
||||
│ ├── platform.ts # 平台判定
|
||||
│ └── device.ts # 设备 ID 生成
|
||||
├── shared/ # 主进程 & 渲染进程共享
|
||||
│ ├── types/ # 类型定义(API / IPC / update / logging)
|
||||
│ ├── constants/ # 常量(IPC 通道 / version / app)
|
||||
│ └── utils/ # 共享工具
|
||||
│ ├── index.ts # debounce / throttle / compareVersions
|
||||
│ └── sanitize.ts # 日志脱敏工具(ID / 邮箱 / 手机 / 名称)
|
||||
├── scripts/ # 构建脚本
|
||||
│ ├── build.mjs # 构建总管
|
||||
│ ├── clean.mjs # 清理产物
|
||||
│ ├── upload-oss.mjs # OSS 上传
|
||||
│ ├── publish-release.mjs # 发布到 API
|
||||
│ └── generate-update-info.mjs # 生成更新元信息
|
||||
├── 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/ # 自动更新模块
|
||||
│ │ │ ├── provider.ts # 自定义 Provider + 设备指纹 + 构建配置
|
||||
│ │ │ ├── platform-updaters.ts # 平台特定 Updater(Windows/macOS/Linux)
|
||||
│ │ │ └── index.ts # 初始化 + IPC 注册
|
||||
│ │ ├── 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/ # 存储通用工具
|
||||
│ │ │ │ ├── path.ts # 路径安全解析(resolveSafe / ensureDir)
|
||||
│ │ │ │ ├── download.ts # Electron net 文件下载(绕过 CORS)
|
||||
│ │ │ │ └── thumbnail.ts # sharp 缩略图生成(内存优化)
|
||||
│ │ │ └── canvas/ # 画布文件操作模块
|
||||
│ │ │ ├── types.ts # 类型定义(StageType / ScanEntry / ThumbnailResult)
|
||||
│ │ │ ├── walk.ts # 递归目录遍历
|
||||
│ │ │ ├── sidecar.ts # Sidecar JSON 元数据读写
|
||||
│ │ │ ├── mime.ts # MIME 类型推断
|
||||
│ │ │ ├── thumbnail.ts # 画布缩略图生成(缓存优先)
|
||||
│ │ │ ├── scan-roots.ts # 扫描根目录计算 + 阶段过滤
|
||||
│ │ │ └── index.ts # IPC handler 注册
|
||||
│ │ └── utils/ # 平台判断 / 图标路径
|
||||
│ └── preload/ # 预加载脚本(contextBridge 桥接层)
|
||||
│ ├── base.ts # ipcRenderer 基础桥接(on/off/send/invoke)
|
||||
│ ├── safe-storage.ts # safeStorage 加密/解密 API
|
||||
│ ├── file-storage.ts # 文件存储 API(getDataDir / readFile / writeFile)
|
||||
│ ├── canvas.ts # 画布 API(openWindow / scanProject / readSidecar)
|
||||
│ ├── app-runtime.ts # 运行时 API(setWindowEdition)
|
||||
│ ├── index.ts # 汇总导出(exposeBaseIpc / exposeSafeStorage / ...)
|
||||
│ ├── 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 客户端 + API 模块
|
||||
│ │ ├── request/ # Axios 请求封装
|
||||
│ │ │ ├── token.ts # Token 管理(加密存储 + 内存缓存)
|
||||
│ │ │ ├── interceptors.ts # 请求/响应拦截器(401 刷新 + 错误处理)
|
||||
│ │ │ ├── methods.ts # 请求方法(get/post/put/del/upload/download)
|
||||
│ │ │ └── index.ts # 主入口 + 导出
|
||||
│ │ └── modules/ # API 模块(auth / home / account / announcement)
|
||||
│ ├── utils/ # 工具函数
|
||||
│ │ ├── bus.ts # 事件总线(emit / on / off / EVENTS)
|
||||
│ │ ├── pricing/ # 定价计算模块
|
||||
│ │ │ ├── types.ts # 类型定义(ModelPricingConfig / ModelDefinition)
|
||||
│ │ │ ├── helpers.ts # 内部工具(asFloat / matchDimensions)
|
||||
│ │ │ ├── format.ts # 金额格式化(formatYuan / formatCent)
|
||||
│ │ │ ├── calculate.ts # 计价逻辑(6 种定价模式)
|
||||
│ │ │ ├── fields.ts # 依赖字段收集
|
||||
│ │ │ └── index.ts # 主入口 + 导出
|
||||
│ │ ├── enum-resolver.ts # 枚举值解析
|
||||
│ │ ├── display.ts # 显示工具
|
||||
│ │ └── safe-storage.ts # 安全存储工具
|
||||
│ ├── infrastructure/storage/ # 本地加密数据库(sql.js + safeStorage)
|
||||
│ │ ├── core/ # db-core.ts / db-encrypt.ts / schema.ts
|
||||
│ │ ├── stores/ # task-store / order-store / media-store / settings-store
|
||||
│ │ └── services/ # media-loader / sync-manager / data-sync / user-scope
|
||||
│ └── theme/ # 主题系统(tokens / antd-theme / CSS 过渡)
|
||||
│
|
||||
├── shared/ # 主进程 & 渲染进程共享(根级 @shared 别名)
|
||||
│ ├── constants/ # 应用常量
|
||||
│ │ ├── app.ts # 应用名称 / 窗口标题
|
||||
│ │ ├── version.ts # 版本号
|
||||
│ │ ├── keyboard.ts # 快捷键映射
|
||||
│ │ └── ipc/ # IPC 通道常量(模块化)
|
||||
│ │ ├── base.ts # 基础通道(窗口/日志/主题/配置)
|
||||
│ │ ├── storage.ts # 存储通道(文件/加密/下载/缩略图)
|
||||
│ │ ├── canvas.ts # 画布通道(扫描/sidecar/拖拽)
|
||||
│ │ ├── updater.ts # 更新通道(检查/下载/安装)
|
||||
│ │ └── index.ts # 汇总导出 + 兼容性 BIDIRECTIONAL
|
||||
│ ├── types/ # index.ts(Platform/Theme/Edition)/ logging.ts
|
||||
│ └── utils/ # sanitize.ts(日志脱敏)+ index.ts(通用工具)
|
||||
│
|
||||
├── scripts/ # 构建与发布脚本
|
||||
├── vite.config.ts # Vite 构建配置
|
||||
├── package.json # 含 electron-builder 打包配置
|
||||
├── vite.config.ts
|
||||
├── tsconfig.json
|
||||
└── tailwind.config.ts
|
||||
└── 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/。
|
||||
|
||||
# 多窗口与平台判定
|
||||
|
||||
- windowManager.ts 负责统一创建 BrowserWindow,根据环境变量/参数加载不同入口(如 src/main/index.html 或
|
||||
src/sub/settings/index.html)。
|
||||
- 窗口管理由 `electron/main.ts` 统一负责:`createMainWindow()` 创建主窗口,`BrowserWindow` 通过 `contextIsolation: true` + `nodeIntegration: false` 运行沙箱化渲染进程。辅助窗口通过 `RENDERER_TO_MAIN.OPEN_SUB_WINDOW` IPC 通道按需创建。
|
||||
|
||||
- src/utils/platform.ts 提供 isMacOS、isWindows 等函数(基于 navigator.userAgent 或 preload 暴露的 process.platform)。在主进程的
|
||||
electron/main/utils/platform.ts 中直接使用 Node.js API。
|
||||
- 平台判定双端各有一份:`electron/main/utils/platform.ts`(主进程,直接使用 `process.platform`)和 `src/shared/utils/env.ts`(渲染进程,优先 `process.platform`,降级 `navigator.userAgent`)。
|
||||
|
||||
- 打包配置 (package.json build 字段) 须针对平台设置不同的图标、签名策略、安装包格式。
|
||||
- 打包配置 (`package.json` build 字段) 须针对平台设置不同的图标、签名策略、安装包格式。
|
||||
|
||||
# 代码风格
|
||||
|
||||
@@ -151,16 +250,18 @@
|
||||
## 状态管理
|
||||
|
||||
- 使用 React Context + Provider 模式管理全局状态,按领域拆分:AppContext(平台/登录)、ThemeContext(主题)、
|
||||
SettingsContext(设置项)。避免引入 Redux 等重量级方案。
|
||||
SettingsContext(设置项)。各 Context 定义与 Provider 合并在 `app/providers/` 目录下,避免引入 Redux 等重量级方案。
|
||||
|
||||
- Provider 组件在 main.tsx 中按层级嵌套挂载:
|
||||
- Provider 在 main.tsx 中按层级嵌套挂载:
|
||||
AppProvider → ThemeProvider → SettingsProvider → AntdApp → App
|
||||
|
||||
- 各模块通过自定义 Hook 消费(useAppContext / useTheme / useSettings),Hook 内部做 null 检查。
|
||||
- 各模块通过自定义 Hook 消费(useAppContext / useTheme / useSettings),Hook 内部做 null 检查并抛出明确错误信息。
|
||||
|
||||
- 页面级状态(如首页的 selectedModel / selectedTask / 分页)放在 HomeContext 中,由 HomeContent 提供,遵循与全局 Provider 相同的 Context + Provider + Hook 模式。
|
||||
|
||||
## 日志系统
|
||||
|
||||
- 双端日志:主进程直接写文件(electron/main/logger.ts),渲染进程通过 IPC send 上报(src/utils/logger.ts)。
|
||||
- 双端日志:主进程直接写文件(electron/main/logger.ts),渲染进程通过 IPC send 上报(src/shared/utils/bus.ts)。
|
||||
两端暴露一致的 API:`logger.debug / info / warn / error(category, message, error?, context?)`。
|
||||
|
||||
- 文件存储:JSON Lines 格式(每行一个 JSON),路径 `{userData}/logs/app-YYYY-MM-DD.log`,
|
||||
@@ -186,9 +287,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 机制
|
||||
|
||||
@@ -204,16 +305,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 工具类 完成布局与常规样式,自定义设计令牌应映射为 Tailwind 扩展(在 tailwind.config.ts 中配置
|
||||
theme.extend)。
|
||||
- 优先使用 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。
|
||||
|
||||
@@ -226,7 +326,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 并处理超时。
|
||||
@@ -267,8 +367,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/ 目录,语义版本排序 |
|
||||
|
||||
## 增量更新(差分下载)
|
||||
@@ -329,4 +429,4 @@ set UPDATE_API_URL=http://127.0.0.1:8000 && .\release\0.0.3\win-unpacked\船长
|
||||
|
||||
- 新增窗口时,需同步说明窗口入口、预加载脚本、主进程创建逻辑及 IPC 通道注册。
|
||||
|
||||
- 修改主题令牌时,需同步更新 tokens.ts、tailwind.config.ts 及 antd-theme.ts,确保三处一致。
|
||||
- 修改主题令牌时,需同步更新 tokens.ts、tokens.css 及 antd-theme.ts,确保三处一致。
|
||||
|
||||
136
README.md
136
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) |
|
||||
|-------------|------------------------------------------------------------|
|
||||
| `--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 五层说明及各模块文件清单。
|
||||
|
||||
---
|
||||
|
||||
## 环境变量
|
||||
|
||||
50
TODO.md
50
TODO.md
@@ -1,12 +1,24 @@
|
||||
# TODO — 船长·HeiXiu 待办与优化清单
|
||||
|
||||
> 最后更新:2026-06-12
|
||||
> 最后更新:2026-06-29
|
||||
|
||||
---
|
||||
|
||||
[//]: # (警告:(48, 14) ESLint: Fast refresh only works when a file only exports components. Move your React context(s) to a separate file. (react-refresh/only-export-components))
|
||||
## ✅ 近期完成
|
||||
|
||||
[//]: # (警告:(52, 17) ESLint: Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components. (react-refresh/only-export-components))
|
||||
- **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` 隔离 + 手动清理按钮
|
||||
- **无限画布(企业版项目管理)**:独立 BrowserWindow 画布窗口,项目/集数/镜头导航、阶段切换、网格/单行布局、滚轮缩放、拖拽平移、全屏灯箱预览、原生文件拖拽
|
||||
- **无限画布 UI 重构**:AntD Layout 双栏布局(Sider+Content)、CanvasSidebar 导航、CategoryTabs 双模式分类、Ctrl/Cmd+拖拽平移
|
||||
- **预览键盘控制**:Space 暂停/播放、← → 快退快进 ±0.5s、↑ ↓ 音量透传原生 controls
|
||||
- **local-media:// Range 请求支持**:协议头转发修复音视频 seek 失败问题
|
||||
|
||||
---
|
||||
|
||||
## 🟡 功能待完善(优先级中)
|
||||
|
||||
@@ -19,9 +31,21 @@
|
||||
### 路由与页面
|
||||
|
||||
- [x] 账户信息页面(`/account`) — `AccountInfo.tsx`,AuthAPI.getUserInfo() 驱动
|
||||
- [x] 充值页面(`/recharge`) — `RechargePage.tsx`,创建订单 → 支付状态跟踪
|
||||
- [x] 订单明细页面(`/orders`) — `OrdersPage.tsx`,状态筛选 + 分页 + 继续支付 + 轮询
|
||||
- [ ] 合规素材库页面(`/compliance-assets`)
|
||||
- [ ] 项目管理页面(`/projects`,企业版)
|
||||
- [ ] 会员/充值/消费记录/订单明细页面
|
||||
- [x] 项目管理页面(企业版,独立画布窗口 `#/canvas`)
|
||||
- [ ] 会员页面(`/vip`)
|
||||
- [ ] 消费记录页面
|
||||
- [x] 本地数据管理(加载历史/保存到磁盘/清理缓存) — `DataManageSetting` + sql.js 存储
|
||||
|
||||
### 媒体与文件
|
||||
|
||||
- [ ] 媒体文件本地下载+缓存(主进程 HTTP 无 CORS 限制,替代渲染进程 axios)
|
||||
- [ ] CDN 签名过期检测(主进程发 HEAD 请求读 `SignatureExpired` 响应体)
|
||||
- [ ] 列表页"下一页"懒加载集成(TaskHistory / OrdersPage 调用 `fetchAndSync*`)
|
||||
- [ ] 参数回填 UI(选择模型时显示历史参数下拉)
|
||||
- [ ] 右键"在文件夹中查看"功能(媒体预览右键菜单)
|
||||
|
||||
### 导航栏
|
||||
|
||||
@@ -31,9 +55,9 @@
|
||||
### 公告
|
||||
|
||||
- [x] 公告数据获取与展示(AnnouncementDrawer + fetchAnnouncements API)
|
||||
- [ ] 公告已读/未读状态
|
||||
- [ ] 公告红点/角标提醒(有新公告时导航栏图标变化)
|
||||
- [ ] 公告缓存(减少重复请求)
|
||||
- [x] 公告已读/未读状态(localStorage 缓存已读 ID Set)
|
||||
- [x] 公告 Badge 角标提醒(antd Badge + NavItem badgeCount prop)
|
||||
- [x] 公告缓存 + 轮询(useAnnouncements Hook,15 分钟 setInterval 对比)
|
||||
|
||||
### 任务
|
||||
|
||||
@@ -63,6 +87,7 @@
|
||||
|
||||
### 文档
|
||||
|
||||
- [x] 文件夹 README 文档(37 个文件夹全部覆盖,不进 Git)
|
||||
- [ ] API 接口文档(Swagger / 手动维护)
|
||||
- [ ] 组件文档(Storybook)
|
||||
- [ ] 用户操作手册
|
||||
@@ -84,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,纯前端限制对脚本攻击无效)
|
||||
|
||||
143
UpdateA.md
143
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: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 指南
|
||||
|
||||
# 独立脚本(需传参数)
|
||||
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) |
|
||||
|-------------|------------------------------------------------------------|
|
||||
| `--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 入口)
|
||||
├ 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 ← 仅保留简短的 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/
|
||||
├ package.json ← 不含 "build" 字段(已提取到 build/electron-builder.js)
|
||||
├ release/ ← 构建产物目录
|
||||
│ ├ .build-meta.json ← 最近一次构建元数据
|
||||
│ └ 0.1.0/ ← Stable 版本
|
||||
│ ├ xxx-Setup.exe
|
||||
│ ├ xxx-Setup.exe.blockmap ← 差分更新块映射文件
|
||||
│ └ xxx-update-info.json
|
||||
├ dist/ ← Vite 前端产物(构建输入)
|
||||
└ dist-electron/ ← Electron 主进程产物(构建输入)
|
||||
├ dist/ ← Vite 前端产物
|
||||
└ dist-electron/ ← Electron 主进程产物
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
324
build.mjs
324
build.mjs
@@ -1,324 +0,0 @@
|
||||
// ============================================================
|
||||
// build.mjs — 构建总管
|
||||
//
|
||||
// 用法:node build.mjs [选项]
|
||||
//
|
||||
// 平台(必选其一):
|
||||
// --win Windows
|
||||
// --mac macOS
|
||||
// --linux Linux
|
||||
//
|
||||
// 版本(可选,默认 personal):
|
||||
// --personal 个人版
|
||||
// --enterprise 企业版
|
||||
//
|
||||
// 操作(可选组合,默认只执行 --build):
|
||||
// --build 仅构建(默认)
|
||||
// --clean 构建前清理 release/ dist/ dist-electron/
|
||||
// --sign 代码签名
|
||||
// --upload 上传安装包到 OSS
|
||||
// --publish 发布版本信息到后端 API
|
||||
// --all 全流程(clean + build + sign + upload + publish)
|
||||
//
|
||||
// 签名(仅 --sign 时可用):
|
||||
// --cert <path> 证书文件路径 (.pfx)
|
||||
// --cert-password <p> 证书密码
|
||||
//
|
||||
// 示例:
|
||||
// node build.mjs --win --personal # 仅打包
|
||||
// node build.mjs --win --personal --upload --publish # 打包 + 上传 + 发布
|
||||
// node build.mjs --win --enterprise --all # 全流程
|
||||
// node build.mjs --win --personal --sign --cert ./cert.pfx --cert-password xxx
|
||||
// ============================================================
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = __dirname;
|
||||
|
||||
// ANSI 颜色工具(在其它代码之前定义,因为 printHelp() 在参数解析时就会调用)
|
||||
const c = (code, text) => `\x1b[${code}m${text}\x1b[0m`;
|
||||
const dim = (t) => c(2, t);
|
||||
const bold = (t) => c(1, t);
|
||||
const cyan = (t) => c(36, t);
|
||||
const green = (t) => c(32, t);
|
||||
const yellow = (t) => c(33, t);
|
||||
|
||||
// ---------- 参数解析 ----------
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
const opts = {
|
||||
win: false,
|
||||
mac: false,
|
||||
linux: false,
|
||||
personal: false,
|
||||
enterprise: false,
|
||||
clean: false,
|
||||
build: false,
|
||||
sign: false,
|
||||
upload: false,
|
||||
publish: false,
|
||||
cert: '',
|
||||
certPassword: '',
|
||||
};
|
||||
|
||||
let i = 0;
|
||||
while (i < args.length) {
|
||||
const a = args[i];
|
||||
switch (a) {
|
||||
case '--win': opts.win = true; break;
|
||||
case '--mac': opts.mac = true; break;
|
||||
case '--linux': opts.linux = true; break;
|
||||
case '--personal': opts.personal = true; break;
|
||||
case '--enterprise': opts.enterprise = true; break;
|
||||
case '--clean': opts.clean = true; break;
|
||||
case '--build': opts.build = true; break;
|
||||
case '--sign': opts.sign = true; break;
|
||||
case '--upload': opts.upload = true; break;
|
||||
case '--publish': opts.publish = true; break;
|
||||
case '--all':
|
||||
opts.clean = opts.build = opts.sign = opts.upload = opts.publish = true;
|
||||
break;
|
||||
case '--cert':
|
||||
opts.cert = args[++i] || '';
|
||||
break;
|
||||
case '--cert-password':
|
||||
opts.certPassword = args[++i] || '';
|
||||
break;
|
||||
case '--help': // fallthrough
|
||||
case '-h':
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
break; // eslint 友好
|
||||
default:
|
||||
// 忽略未知参数
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
// ---------- 校验 ----------
|
||||
|
||||
const platformCount = [opts.win, opts.mac, opts.linux].filter(Boolean).length;
|
||||
if (platformCount !== 1) {
|
||||
console.error('错误: 请指定且仅指定一个平台(--win / --mac / --linux)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const editionCount = [opts.personal, opts.enterprise].filter(Boolean).length;
|
||||
if (editionCount === 0) {
|
||||
opts.personal = true; // 默认个人版
|
||||
} else if (editionCount > 1) {
|
||||
console.error('错误: --personal 和 --enterprise 不能同时使用');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 默认执行 build
|
||||
if (!opts.clean && !opts.build && !opts.sign && !opts.upload && !opts.publish) {
|
||||
opts.build = true;
|
||||
}
|
||||
|
||||
const platform = opts.win ? 'win32' : opts.mac ? 'darwin' : 'linux';
|
||||
const edition = opts.personal ? 'personal' : 'enterprise';
|
||||
|
||||
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
|
||||
const version = pkg.version;
|
||||
|
||||
// ---------- 工具函数 ----------
|
||||
|
||||
function run(cmd, args, opts = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(cmd, args, {
|
||||
cwd: ROOT,
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
...opts,
|
||||
env: { ...process.env, ...(opts.env || {}) },
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`${cmd} ${args.join(' ')} 退出码: ${code}`));
|
||||
});
|
||||
child.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function runScript(name, ...args) {
|
||||
const scriptPath = join(ROOT, 'scripts', `${name}.mjs`);
|
||||
return run('node', [scriptPath, ...args]);
|
||||
}
|
||||
|
||||
function step(title) {
|
||||
console.log(`\n${'='.repeat(60)}`);
|
||||
console.log(` ${title}`);
|
||||
console.log(`${'='.repeat(60)}\n`);
|
||||
}
|
||||
|
||||
// ---------- 主流程 ----------
|
||||
|
||||
async function main() {
|
||||
const electronBuilderPlatform = opts.win ? '--win' : opts.mac ? '--mac' : '--linux';
|
||||
const editionEnv = { EDITION: edition };
|
||||
|
||||
console.log('┌──────────────────────────────────────┐');
|
||||
console.log(`│ HeiXiu 构建 v${version} ${platform}/${edition}`.padEnd(45) + '│');
|
||||
console.log('└──────────────────────────────────────┘');
|
||||
console.log(`操作: ${[
|
||||
opts.clean && '清理',
|
||||
opts.build && '构建',
|
||||
opts.sign && '签名',
|
||||
opts.upload && '上传',
|
||||
opts.publish && '发布',
|
||||
].filter(Boolean).join(' → ')}`);
|
||||
|
||||
// ── Step 1: 清理 ──
|
||||
if (opts.clean) {
|
||||
step('1/ 清理构建产物');
|
||||
await runScript('clean');
|
||||
}
|
||||
|
||||
// ── Step 2: 构建 ──
|
||||
if (opts.build) {
|
||||
step('2/ 构建应用');
|
||||
|
||||
// 签名环境变量(electron-builder 原生支持 CSC_LINK / CSC_KEY_PASSWORD)
|
||||
const signEnv = {};
|
||||
if (opts.sign) {
|
||||
if (opts.cert) signEnv.CSC_LINK = opts.cert;
|
||||
if (opts.certPassword) signEnv.CSC_KEY_PASSWORD = opts.certPassword;
|
||||
console.log(opts.cert ? `签名证书: ${opts.cert}` : '签名: 使用系统证书存储');
|
||||
}
|
||||
|
||||
// 2a. TypeScript 编译
|
||||
console.log('[tsc] 类型检查...');
|
||||
await run('npx', ['tsc'], { env: editionEnv });
|
||||
|
||||
// 2b. Vite 构建
|
||||
console.log('[vite] 打包前端 + Electron...');
|
||||
await run('npx', ['vite', 'build'], { env: editionEnv });
|
||||
|
||||
// 2c. electron-builder 打包
|
||||
console.log(`[electron-builder] ${electronBuilderPlatform} 打包...`);
|
||||
await run('npx', ['electron-builder', electronBuilderPlatform], {
|
||||
env: { ...editionEnv, ...signEnv },
|
||||
});
|
||||
|
||||
// 2d. 生成 update-info.json
|
||||
console.log('[info] 生成 update-info.json...');
|
||||
await runScript('generate-update-info', platform, edition, version);
|
||||
}
|
||||
|
||||
// ── Step 3: 上传 OSS ──
|
||||
if (opts.upload) {
|
||||
step('3/ 上传 OSS');
|
||||
await runScript('upload-oss', platform, edition, version);
|
||||
}
|
||||
|
||||
// ── Step 4: 发布到 API ──
|
||||
if (opts.publish) {
|
||||
step('4/ 发布版本到 API');
|
||||
await runScript('publish-release', platform, edition, version);
|
||||
}
|
||||
|
||||
console.log('\n✅ 全部完成');
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
const W = 62;
|
||||
const box = (title) => {
|
||||
const pad = Math.max(0, W - 4 - title.length);
|
||||
console.log(`\n${cyan('┌')}${cyan('─'.repeat(W - 2))}${cyan('┐')}`);
|
||||
console.log(`${cyan('│')} ${bold(title)}${' '.repeat(pad)}${cyan('│')}`);
|
||||
console.log(`${cyan('└')}${cyan('─'.repeat(W - 2))}${cyan('┘')}`);
|
||||
};
|
||||
const row = (flag, desc) => {
|
||||
console.log(` ${green(flag.padEnd(22))}${dim(desc)}`);
|
||||
};
|
||||
const hdr = (t) => console.log(`\n ${bold(yellow(t))}`);
|
||||
|
||||
console.log('');
|
||||
console.log(cyan(' ╔') + cyan('═══════════════════════════════════════════════════') + cyan('╗'));
|
||||
console.log(cyan(' ║') + bold(' 船长·HeiXiu — 构建工具') + ' ' + cyan('║'));
|
||||
console.log(cyan(' ╚') + cyan('═══════════════════════════════════════════════════') + cyan('╝'));
|
||||
|
||||
// 用法
|
||||
box('用法');
|
||||
console.log(` ${bold('node build.mjs')} ${dim('<平台> [版本] [操作...]')}`);
|
||||
console.log(` ${bold('npm run build:win')} ${dim('— 等效快捷命令')}`);
|
||||
|
||||
// 平台
|
||||
hdr('平台(必选其一)');
|
||||
row('--win', 'Windows');
|
||||
row('--mac', 'macOS');
|
||||
row('--linux', 'Linux');
|
||||
|
||||
// 版本
|
||||
hdr('版本(可选,默认 --personal)');
|
||||
row('--personal', '个人版');
|
||||
row('--enterprise', '企业版');
|
||||
|
||||
// 操作
|
||||
hdr('操作(可选组合,不指定则默认 --build)');
|
||||
row('--build', '构建应用(tsc + vite + electron-builder + info)');
|
||||
row('--clean', '构建前清理 release/ dist/ dist-electron/');
|
||||
row('--sign', '代码签名(需配合 --cert 或环境变量)');
|
||||
row('--upload', '上传安装包到 OSS');
|
||||
row('--publish', '发布版本信息到后端 API');
|
||||
row('--all', '全流程 = clean + build + sign + upload + publish');
|
||||
|
||||
// 签名
|
||||
hdr('签名参数(配合 --sign)');
|
||||
row('--cert <path>', '证书文件路径(.pfx / .p12)');
|
||||
row('--cert-password <p>', '证书密码');
|
||||
console.log(` ${dim('也可通过 CSC_LINK / CSC_KEY_PASSWORD 环境变量设置')}`);
|
||||
|
||||
// 示例
|
||||
box('示例');
|
||||
console.log(` ${dim('# 日常开发')}`);
|
||||
console.log(` ${bold('node build.mjs --win --personal')}`);
|
||||
console.log(` ${bold('npm run build:win')} ${dim('— 等效')}`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# 打包 + 上传 OSS + 发布到 API')}`);
|
||||
console.log(` ${bold('node build.mjs --win --personal --upload --publish')}`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# 全流程(清理 → 构建 → 签名 → 上传 → 发布)')}`);
|
||||
console.log(` ${bold('node build.mjs --win --enterprise --all --cert ./cert.pfx --cert-password xxx')}`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# 仅清理')}`);
|
||||
console.log(` ${bold('npm run build:clean')}`);
|
||||
|
||||
// 环境变量
|
||||
box('环境变量');
|
||||
console.log(` ${yellow('UPDATE_API_URL')} ${dim('版本检查 API 基地址')}`);
|
||||
console.log(` ${yellow('UPDATE_API_KEY')} ${dim('发布 API 鉴权 Key')}`);
|
||||
console.log(` ${yellow('UPDATE_DOWNLOAD_BASE')} ${dim('OSS 下载基地址(预判 URL 用)')}`);
|
||||
console.log(` ${yellow('OSS_ENDPOINT')} ${dim('阿里云 OSS endpoint')}`);
|
||||
console.log(` ${yellow('OSS_BUCKET')} ${dim('阿里云 OSS bucket 名称')}`);
|
||||
console.log(` ${yellow('OSS_ACCESS_KEY_ID')} ${dim('阿里云 AccessKey ID')}`);
|
||||
console.log(` ${yellow('OSS_ACCESS_KEY_SECRET')} ${dim('阿里云 AccessKey Secret')}`);
|
||||
console.log(` ${yellow('CSC_LINK')} ${dim('签名证书路径(与 --cert 等效)')}`);
|
||||
console.log(` ${yellow('CSC_KEY_PASSWORD')} ${dim('签名证书密码(与 --cert-password 等效)')}`);
|
||||
|
||||
// 快捷命令
|
||||
box('npm 快捷命令');
|
||||
console.log(` ${bold('npm run build:win')} ${dim('→ node build.mjs --win --personal --build')}`);
|
||||
console.log(` ${bold('npm run build:win:enterprise')} ${dim('→ node build.mjs --win --enterprise --build')}`);
|
||||
console.log(` ${bold('npm run build:mac')} ${dim('→ node build.mjs --mac --personal --build')}`);
|
||||
console.log(` ${bold('npm run build:linux')} ${dim('→ node build.mjs --linux --personal --build')}`);
|
||||
console.log(` ${bold('npm run build:clean')} ${dim('→ node scripts/clean.mjs')}`);
|
||||
console.log(` ${bold('npm run upload:oss')} ${dim('→ node scripts/upload-oss.mjs')}`);
|
||||
console.log(` ${bold('npm run publish:release')} ${dim('→ node scripts/publish-release.mjs')}`);
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// ---------- 入口 ----------
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`\n❌ 构建失败: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
431
build/build.mjs
Normal file
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> <edition> [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 personal
|
||||
//
|
||||
// 方式 B(指定 URL):上传后用实际 URL 重新生成 → 适用于上传后才知道 URL 时
|
||||
// node scripts/generate-update-info.mjs win32 personal 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,27 +40,26 @@ for (let i = 0; i < args.length; i++) {
|
||||
}
|
||||
|
||||
const platform = positional[0];
|
||||
const edition = positional[1];
|
||||
const version = positional[1];
|
||||
const channel = positional[2] || 'stable';
|
||||
const edition = positional[3] || 'personal';
|
||||
const ossPrefix = positional[4] || `releases/${version}`;
|
||||
const releaseDirName = positional[5] || version;
|
||||
|
||||
if (!platform || !edition) {
|
||||
console.error('用法: node scripts/generate-update-info.mjs <platform> <edition> [version] [--url <url>]');
|
||||
console.error('示例: node scripts/generate-update-info.mjs win32 personal');
|
||||
console.error(' node scripts/generate-update-info.mjs win32 personal 0.1.0 --url "https://..."');
|
||||
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[2] || 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) {
|
||||
@@ -84,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;
|
||||
|
||||
@@ -104,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;
|
||||
}
|
||||
@@ -127,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}`);
|
||||
}
|
||||
|
||||
@@ -141,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更新内容待补充`;
|
||||
}
|
||||
@@ -183,15 +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(),
|
||||
},
|
||||
@@ -199,7 +193,7 @@ const updateInfo = {
|
||||
|
||||
const outputPath = join(
|
||||
releaseDir,
|
||||
`${productName}-${platform}-${edition}-v${version}-update-info.json`,
|
||||
`${productName}-${platform}-v${version}-update-info.json`,
|
||||
);
|
||||
|
||||
writeFileSync(outputPath, JSON.stringify(updateInfo, null, 2), 'utf-8');
|
||||
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> <edition> [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,17 +38,17 @@ for (let i = 0; i < args.length; i++) {
|
||||
}
|
||||
|
||||
const platform = positional[0];
|
||||
const edition = positional[1];
|
||||
const version = positional[1];
|
||||
const channel = positional[2] || 'stable';
|
||||
const edition = positional[3] || 'personal';
|
||||
const ossPrefix = positional[4] || `releases/${version}`;
|
||||
const releaseDirName = positional[5] || version;
|
||||
|
||||
if (!platform || !edition) {
|
||||
console.error('用法: node scripts/publish-release.mjs <platform> <edition> [version] [--url <url>]');
|
||||
console.error('示例: node scripts/publish-release.mjs win32 personal 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[2] || pkg.version;
|
||||
|
||||
// ---------- 读取 update-info.json ----------
|
||||
|
||||
let updateInfo;
|
||||
@@ -58,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.includes(edition) && f.endsWith('-update-info.json'),
|
||||
f.includes(platform) && f.endsWith('-update-info.json'),
|
||||
);
|
||||
if (!infoFile) console.error(`未找到 ${platform}+${edition} 的 update-info.json`);
|
||||
if (!infoFile) {
|
||||
console.error(`未找到 ${platform} 的 update-info.json(在 ${releaseDir} 中)`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`错误: ${releaseDir} — ${err.message}`);
|
||||
process.exit(1);
|
||||
@@ -81,6 +87,7 @@ const apiKey = process.env.UPDATE_API_KEY || '';
|
||||
|
||||
const payload = {
|
||||
platform,
|
||||
channel,
|
||||
edition,
|
||||
latestVersion: updateInfo.latestVersion || version,
|
||||
downloadUrl: updateInfo.downloadUrl,
|
||||
@@ -90,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`;
|
||||
@@ -98,7 +114,10 @@ console.log('━━━━━━━━━━━━━━━━━━━━━━
|
||||
console.log('📡 发布版本到 API');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log(`POST ${endpoint}`);
|
||||
console.log(`版本: ${version} (${platform}/${edition})`);
|
||||
console.log(`版本: ${version}`);
|
||||
console.log(`平台: ${platform}`);
|
||||
console.log(`渠道: ${channel}`);
|
||||
console.log(`版本类型: ${edition}`);
|
||||
console.log(`URL: ${payload.downloadUrl}\n`);
|
||||
|
||||
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}"`);
|
||||
}
|
||||
54
electron/electron-env.d.ts
vendored
54
electron/electron-env.d.ts
vendored
@@ -21,6 +21,18 @@ declare namespace NodeJS {
|
||||
}
|
||||
}
|
||||
|
||||
/** sharp 图片处理库(可选依赖,运行时动态加载) */
|
||||
declare module 'sharp' {
|
||||
interface SharpInstance {
|
||||
metadata(): Promise<{ width?: number; height?: number; format?: string }>;
|
||||
resize(width?: number, height?: number, options?: { fit?: string; withoutEnlargement?: boolean }): SharpInstance;
|
||||
jpeg(options?: { quality?: number }): SharpInstance;
|
||||
toBuffer(): Promise<Buffer>;
|
||||
}
|
||||
function sharp(input: string | Buffer): SharpInstance;
|
||||
export = sharp;
|
||||
}
|
||||
|
||||
// Used in Renderer process, expose in `preload.ts`
|
||||
declare interface Window {
|
||||
ipcRenderer: import('electron').IpcRenderer;
|
||||
@@ -29,4 +41,46 @@ declare interface Window {
|
||||
encrypt(plaintext: string): Promise<string | null>;
|
||||
decrypt(encryptedBase64: string): Promise<string | null>;
|
||||
};
|
||||
/** 应用运行时 API */
|
||||
appRuntime: {
|
||||
/** 登录/登出后更新窗口标题中的版控后缀 */
|
||||
setWindowEdition(edition: string | null): void;
|
||||
};
|
||||
/** 无限画布 API(由 preload 注入) */
|
||||
canvas: {
|
||||
/** 打开画布窗口 */
|
||||
openWindow(projectPath?: string): void;
|
||||
/** 扫描目录 */
|
||||
scanDirectory(params: {
|
||||
roots: string[];
|
||||
extensions: string[];
|
||||
recursive: boolean;
|
||||
}): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }>;
|
||||
/** 读取 sidecar 元数据 */
|
||||
readSidecar(params: { filePath: string }): Promise<{ success: boolean; data: object | null }>;
|
||||
/** 写入 sidecar 元数据 */
|
||||
writeSidecar(params: { filePath: string; metadata: object }): Promise<{ success: boolean; error?: string }>;
|
||||
/** 生成缩略图 */
|
||||
generateThumbnail(params: { filePath: string; size: number }): Promise<{
|
||||
success: boolean;
|
||||
data: { data: string; mimeType: string; width: number; height: number };
|
||||
error?: string;
|
||||
}>;
|
||||
/** 扫描项目 */
|
||||
scanProject(params: { projectPath: string; stage: string; assetTypeFilter: string; shotQuery: string }): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }>;
|
||||
/** 复制文件 */
|
||||
copyFile(params: { src: string; destDir: string; fileName: string }): Promise<{ success: boolean; data: string; error?: string }>;
|
||||
/** 列出目录下的子文件夹(非递归,排除隐藏) */
|
||||
listSubdirs(params: { parentPath: string }): Promise<{ success: boolean; data: string[]; error?: string }>;
|
||||
/** 扫描阶段文件(单个 episode/shot/stage 组合) */
|
||||
scanStageFiles(params: { projectPath: string; episode: string; shot: string; stage: string }): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }>;
|
||||
/** 扫描资产文件(深度递归扫描 assets/ 目录) */
|
||||
scanAssetFiles(params: { projectPath: string; assetTypeFilter: string }): Promise<{ success: boolean; data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>; error?: string }>;
|
||||
/** 创建目录 */
|
||||
createDirectory(params: { parentPath: string; dirName: string }): Promise<{ success: boolean; data: string; error?: string }>;
|
||||
/** 将本地文件路径转换为 local-media:// 协议 URL */
|
||||
getMediaUrl(filePath: string): string;
|
||||
/** 启动原生文件拖拽(从缩略图拖放媒体文件到外部应用) */
|
||||
startDrag(params: { filePath: string; fileName: string }): Promise<{ success: boolean; error?: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
195
electron/main.ts
195
electron/main.ts
@@ -1,18 +1,20 @@
|
||||
import {app, BrowserWindow, ipcMain, dialog, session} from 'electron';
|
||||
import {app, BrowserWindow, ipcMain, dialog, session, protocol, net} from 'electron';
|
||||
import {createRequire} from 'node:module';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
import {getPlatform, isMacOS} from './main/utils/platform';
|
||||
import {getWindowIconPath} from './main/utils/logo';
|
||||
import {buildWindowTitle, parseEdition} from '../shared/constants/app';
|
||||
import {buildWindowTitle} from '../shared/constants/app';
|
||||
import {loadEnvFile} from './main/load-env';
|
||||
import {initUpdater, registerUpdateIpcHandlers} from './main/updater';
|
||||
import {setupAppMenu} from './main/menu';
|
||||
import {initLogger, flushLogger, logger} from './main/logger';
|
||||
import {registerLogIpcHandlers} from './main/log-ipc';
|
||||
import {registerSafeStorageIpcHandlers} from './main/safe-storage-ipc';
|
||||
import {BIDIRECTIONAL} from '../shared/constants/ipc-channels';
|
||||
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, setDataDirResolver} from './main/ipc/storage-ipc';
|
||||
import {registerCanvasIpcHandlers} from './main/ipc/canvas';
|
||||
import {BIDIRECTIONAL, RENDERER_TO_MAIN, MAIN_TO_RENDERER} from '../shared/constants/ipc';
|
||||
|
||||
createRequire(import.meta.url);
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -33,8 +35,10 @@ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
|
||||
|
||||
// ---------- 平台 & 版本信息 ----------
|
||||
const currentPlatform = getPlatform();
|
||||
const currentEdition = parseEdition(process.env.EDITION);
|
||||
const WINDOW_TITLE = buildWindowTitle(currentPlatform, currentEdition);
|
||||
/** 当前版控后缀(登录后通过 IPC 更新) */
|
||||
let currentEdition: string | null = null;
|
||||
/** 基础标题(无版控后缀),登录后通过 IPC 追加 */
|
||||
const WINDOW_TITLE = buildWindowTitle(currentPlatform);
|
||||
|
||||
// ============================================================
|
||||
// 全局未捕获异常(主进程兜底记录)
|
||||
@@ -62,6 +66,7 @@ process.on('unhandledRejection', (reason) => {
|
||||
// ============================================================
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let canvasWindow: BrowserWindow | null = null;
|
||||
|
||||
// ============================================================
|
||||
// 主窗口
|
||||
@@ -124,6 +129,85 @@ function createMainWindow(): BrowserWindow {
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 画布窗口
|
||||
// ============================================================
|
||||
|
||||
function createCanvasWindow(): BrowserWindow {
|
||||
const iconPath = getWindowIconPath(app.getAppPath());
|
||||
|
||||
logger.info('canvas-window', '创建画布窗口', {
|
||||
isDev: !!VITE_DEV_SERVER_URL,
|
||||
url: VITE_DEV_SERVER_URL
|
||||
? VITE_DEV_SERVER_URL + '#/canvas'
|
||||
: path.join(RENDERER_DIST, 'index.html') + '#/canvas',
|
||||
});
|
||||
|
||||
canvasWindow = new BrowserWindow({
|
||||
title: '无限画布 - HeiXiu',
|
||||
icon: iconPath,
|
||||
width: 1400,
|
||||
height: 900,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.mjs'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
canvasWindow.loadURL(VITE_DEV_SERVER_URL + '#/canvas');
|
||||
canvasWindow.webContents.openDevTools();
|
||||
logger.info('canvas-window', '画布窗口 DevTools 已自动打开(开发模式)');
|
||||
} else {
|
||||
canvasWindow.loadFile(path.join(RENDERER_DIST, 'index.html'), {
|
||||
hash: '/canvas',
|
||||
});
|
||||
}
|
||||
|
||||
// 禁用浏览器默认页面缩放(Ctrl/Cmd+滚轮 和 触控板捏合)。
|
||||
// 画布使用 CSS transform 实现独立缩放,不允许 Chromium 页面级缩放干扰。
|
||||
// before-input-event 在 Chromium 处理输入之前触发,比 JS event listener 更早。
|
||||
canvasWindow.webContents.on('before-input-event', (event, input) => {
|
||||
if (input.type === 'mouseWheel' && (input.control || input.meta)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
canvasWindow.once('ready-to-show', () => {
|
||||
logger.info('canvas-window', '画布窗口 ready-to-show,即将显示');
|
||||
canvasWindow?.show();
|
||||
});
|
||||
|
||||
canvasWindow.on('closed', () => {
|
||||
logger.info('canvas-window', '画布窗口已关闭');
|
||||
canvasWindow = null;
|
||||
});
|
||||
|
||||
return canvasWindow;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 自定义协议:local-media — 安全加载本地文件
|
||||
// 必须在 app.whenReady 之前注册为特权 scheme
|
||||
// ============================================================
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: 'local-media',
|
||||
privileges: {
|
||||
standard: true,
|
||||
secure: true,
|
||||
supportFetchAPI: true,
|
||||
bypassCSP: true,
|
||||
stream: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// ============================================================
|
||||
// 应用生命周期
|
||||
// ============================================================
|
||||
@@ -145,9 +229,47 @@ app.on('before-quit', () => {
|
||||
});
|
||||
|
||||
app.whenReady().then(() => {
|
||||
// ── 存储路径配置 ──
|
||||
// 修改以下两行即可将日志/数据存储到任意位置
|
||||
setLogDirResolver(() => path.join(app.getPath('userData'), 'logs'));
|
||||
setDataDirResolver(() => path.join(app.getPath('userData'), 'heixiu-data'));
|
||||
|
||||
initLogger();
|
||||
registerLogIpcHandlers();
|
||||
registerSafeStorageIpcHandlers();
|
||||
registerStorageIpcHandlers();
|
||||
registerCanvasIpcHandlers();
|
||||
|
||||
protocol.handle('local-media', (request) => {
|
||||
const urlObj = new URL(request.url);
|
||||
let filePath: string;
|
||||
|
||||
if (urlObj.hostname) {
|
||||
// UNC 路径:local-media://server/share/path → \\server\share\path
|
||||
filePath = '\\\\' + urlObj.hostname + decodeURIComponent(urlObj.pathname).replace(/\//g, '\\');
|
||||
} else {
|
||||
// 本地路径:local-media:///C:/path/to/file → C:/path/to/file
|
||||
filePath = decodeURIComponent(urlObj.pathname.slice(1));
|
||||
}
|
||||
|
||||
const fwd = filePath.replace(/\\/g, '/');
|
||||
let fileUrl: string;
|
||||
if (fwd.startsWith('//')) {
|
||||
fileUrl = 'file:' + fwd;
|
||||
} else {
|
||||
fileUrl = 'file:///' + fwd;
|
||||
}
|
||||
|
||||
// 只转发 Range 头,确保音视频 seek 时返回 206 Partial Content。
|
||||
// ⚠️ 不能转发所有 headers(Origin/Referer/Sec-Fetch-* 等),
|
||||
// 否则会干扰 Chromium 内部的 file:// 协议处理,导致请求失败。
|
||||
const rangeHeader = request.headers.get('Range');
|
||||
if (rangeHeader) {
|
||||
return net.fetch(fileUrl, {headers: {Range: rangeHeader}});
|
||||
}
|
||||
return net.fetch(fileUrl);
|
||||
});
|
||||
|
||||
setupAppMenu();
|
||||
app.setName(WINDOW_TITLE);
|
||||
registerUpdateIpcHandlers();
|
||||
@@ -164,12 +286,65 @@ app.whenReady().then(() => {
|
||||
|
||||
// 文件对话框 IPC 处理(供渲染进程选择文件夹)
|
||||
ipcMain.handle(BIDIRECTIONAL.FILE_DIALOG, async (_event, options: Electron.OpenDialogOptions) => {
|
||||
if (!mainWindow) return {canceled: true, filePaths: []};
|
||||
return dialog.showOpenDialog(mainWindow, {
|
||||
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) => {
|
||||
try {
|
||||
currentEdition = edition;
|
||||
if (!mainWindow) return;
|
||||
const title = edition
|
||||
? buildWindowTitle(currentPlatform, undefined, edition as 'personal' | 'enterprise')
|
||||
: buildWindowTitle(currentPlatform);
|
||||
mainWindow.setTitle(title);
|
||||
} catch (err) {
|
||||
logger.warn('ipc', '设置窗口标题失败', err instanceof Error ? err : undefined);
|
||||
}
|
||||
});
|
||||
|
||||
// 打开无限画布窗口(渲染进程请求 → 主进程创建新 BrowserWindow)
|
||||
ipcMain.on(RENDERER_TO_MAIN.OPEN_CANVAS_WINDOW, (_event, payload?: { projectPath?: string }) => {
|
||||
try {
|
||||
const existingAlive = canvasWindow && !canvasWindow.isDestroyed();
|
||||
logger.info('canvas-window', '收到 OPEN_CANVAS_WINDOW IPC', {
|
||||
projectPath: payload?.projectPath || '(未指定)',
|
||||
existingAlive,
|
||||
});
|
||||
|
||||
if (existingAlive) {
|
||||
logger.info('canvas-window', '画布窗口已存在,聚焦现有窗口');
|
||||
canvasWindow?.focus();
|
||||
} else {
|
||||
logger.info('canvas-window', '画布窗口不存在或已销毁,创建新窗口');
|
||||
canvasWindow = createCanvasWindow();
|
||||
}
|
||||
// 画布页面加载完成后,发送初始化参数
|
||||
if (payload?.projectPath) {
|
||||
const readyHandler = () => {
|
||||
logger.info('canvas-window', '画布页面 did-finish-load,发送 CANVAS_READY', {
|
||||
projectPath: payload.projectPath,
|
||||
});
|
||||
canvasWindow?.webContents.send(MAIN_TO_RENDERER.CANVAS_READY, {
|
||||
projectPath: payload.projectPath,
|
||||
});
|
||||
};
|
||||
canvasWindow?.webContents.on('did-finish-load', readyHandler);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('canvas-window', '打开画布窗口失败', err instanceof Error ? err : undefined);
|
||||
}
|
||||
});
|
||||
|
||||
// 始终打开主窗口
|
||||
|
||||
352
electron/main/ipc/canvas/index.ts
Normal file
352
electron/main/ipc/canvas/index.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
// ============================================================
|
||||
// canvas/index.ts — 画布 IPC 处理器注册
|
||||
//
|
||||
// 职责:
|
||||
// - 注册所有画布相关的 IPC 处理器
|
||||
// - 组合使用各工具模块实现业务逻辑
|
||||
// ============================================================
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
import { BIDIRECTIONAL_CANVAS } from '../../../../shared/constants/ipc/canvas';
|
||||
import { logger } from '../../logger';
|
||||
import { walkDirectory } from './walk';
|
||||
import { readSidecarFile, writeSidecarFile } from './sidecar';
|
||||
import { generateCanvasThumbnail } from './thumbnail';
|
||||
import { buildScanRoots, getStageExtensions } from './scan-roots';
|
||||
import type { StageType, AssetTypeFilter } from './types';
|
||||
|
||||
/** 注册画布 IPC 处理器 */
|
||||
export function registerCanvasIpcHandlers(): void {
|
||||
// ---- 扫描项目(主进程内部 buildScanRoots + walkDirectory) ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL_CANVAS.CANVAS_SCAN_PROJECT,
|
||||
async (
|
||||
_event,
|
||||
params: {
|
||||
projectPath: string;
|
||||
stage: StageType;
|
||||
assetTypeFilter: AssetTypeFilter;
|
||||
shotQuery: string;
|
||||
},
|
||||
) => {
|
||||
try {
|
||||
logger.info('canvas', '收到 CANVAS_SCAN_PROJECT', params);
|
||||
|
||||
const roots = buildScanRoots(params);
|
||||
const extensions = getStageExtensions(params.stage);
|
||||
|
||||
logger.info('canvas', `buildScanRoots 结果:${roots.length} 个扫描根`, {
|
||||
roots: roots.length <= 10 ? roots : roots.slice(0, 10).concat(`... 共 ${roots.length} 个`),
|
||||
extensions,
|
||||
stage: params.stage,
|
||||
});
|
||||
|
||||
if (roots.length === 0) {
|
||||
logger.warn('canvas', '扫描根目录为空', undefined, {
|
||||
projectPath: params.projectPath,
|
||||
stage: params.stage,
|
||||
shotQuery: params.shotQuery || '(无)',
|
||||
});
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
|
||||
const allResults: Array<{
|
||||
path: string;
|
||||
fileName: string;
|
||||
ext: string;
|
||||
fileSize: number;
|
||||
modifiedAt: string;
|
||||
}> = [];
|
||||
|
||||
for (const root of roots) {
|
||||
const results = walkDirectory(root, extensions, true);
|
||||
if (results.length > 0) {
|
||||
logger.info('canvas', ` 📁 ${root} → ${results.length} 个文件`);
|
||||
}
|
||||
allResults.push(...results);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
'canvas',
|
||||
`扫描项目完成:${allResults.length} 个文件(${roots.length} 个扫描根目录,阶段:${params.stage})`,
|
||||
);
|
||||
return { success: true, data: allResults };
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
logger.warn('canvas', '项目扫描失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 目录扫描(保留低级 API:外部提供扫描根) ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL_CANVAS.CANVAS_SCAN_DIRECTORY,
|
||||
async (
|
||||
_event,
|
||||
params: { roots: string[]; extensions: string[]; recursive: boolean },
|
||||
) => {
|
||||
try {
|
||||
const allResults: Array<{
|
||||
path: string;
|
||||
fileName: string;
|
||||
ext: string;
|
||||
fileSize: number;
|
||||
modifiedAt: string;
|
||||
}> = [];
|
||||
for (const root of params.roots) {
|
||||
if (!fs.existsSync(root)) {
|
||||
logger.warn('canvas', `扫描根目录不存在,跳过:${root}`);
|
||||
continue;
|
||||
}
|
||||
const results = walkDirectory(root, params.extensions, params.recursive);
|
||||
allResults.push(...results);
|
||||
}
|
||||
logger.info(
|
||||
'canvas',
|
||||
`扫描完成:${allResults.length} 个文件(${params.roots.length} 个根目录)`,
|
||||
);
|
||||
return { success: true, data: allResults };
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
logger.warn('canvas', '目录扫描失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 读取 sidecar ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL_CANVAS.CANVAS_READ_SIDECAR,
|
||||
async (_event, params: { filePath: string }) => {
|
||||
try {
|
||||
const data = readSidecarFile(params.filePath);
|
||||
return { success: true, data };
|
||||
} catch (err) {
|
||||
logger.warn('canvas', '读取 sidecar 失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 写入 sidecar ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL_CANVAS.CANVAS_WRITE_SIDECAR,
|
||||
async (_event, params: { filePath: string; metadata: object }) => {
|
||||
try {
|
||||
writeSidecarFile(params.filePath, params.metadata);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
logger.warn('canvas', '写入 sidecar 失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 生成缩略图 ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL_CANVAS.CANVAS_GENERATE_THUMBNAIL,
|
||||
async (_event, params: { filePath: string; size: number }) => {
|
||||
try {
|
||||
const result = await generateCanvasThumbnail(params.filePath, params.size);
|
||||
return { success: true, data: result };
|
||||
} catch (err) {
|
||||
logger.warn('canvas', '生成缩略图失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 复制文件 ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL_CANVAS.CANVAS_COPY_FILE,
|
||||
async (_event, params: { src: string; destDir: string; fileName: string }) => {
|
||||
try {
|
||||
if (!fs.existsSync(params.destDir)) {
|
||||
fs.mkdirSync(params.destDir, { recursive: true });
|
||||
}
|
||||
const dest = path.join(params.destDir, params.fileName);
|
||||
fs.copyFileSync(params.src, dest);
|
||||
return { success: true, data: dest };
|
||||
} catch (err) {
|
||||
logger.warn('canvas', '复制文件失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 获取图片尺寸(使用 sharp) ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL_CANVAS.CANVAS_GET_IMAGE_SIZE,
|
||||
async (_event, params: { filePath: string }) => {
|
||||
try {
|
||||
if (sharp) {
|
||||
const metadata = await sharp(params.filePath).metadata();
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
width: metadata.width || 0,
|
||||
height: metadata.height || 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { success: false, error: 'sharp 未安装,无法获取图片尺寸' };
|
||||
} catch (err) {
|
||||
logger.warn('canvas', '获取图片尺寸失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 列出子文件夹(非递归,排除隐藏) ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL_CANVAS.CANVAS_LIST_SUBDIRS,
|
||||
async (_event, params: { parentPath: string }) => {
|
||||
try {
|
||||
if (!fs.existsSync(params.parentPath)) {
|
||||
logger.info('canvas', `listSubdirs: 路径不存在,返回空数组 — ${params.parentPath}`);
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
const entries = fs.readdirSync(params.parentPath, { withFileTypes: true });
|
||||
const dirs = entries
|
||||
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
||||
.map((e) => e.name);
|
||||
logger.info('canvas', `listSubdirs: ${dirs.length} 个子文件夹 — ${params.parentPath}`);
|
||||
return { success: true, data: dirs };
|
||||
} catch (err) {
|
||||
logger.warn('canvas', 'listSubdirs 失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 扫描阶段文件 ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL_CANVAS.CANVAS_SCAN_STAGE_FILES,
|
||||
async (
|
||||
_event,
|
||||
params: {
|
||||
projectPath: string;
|
||||
episode: string;
|
||||
shot: string;
|
||||
stage: string;
|
||||
},
|
||||
) => {
|
||||
try {
|
||||
const stageDir = path.join(
|
||||
params.projectPath,
|
||||
'shots',
|
||||
params.episode,
|
||||
params.shot,
|
||||
params.stage,
|
||||
);
|
||||
logger.info('canvas', `scanStageFiles: ${stageDir}`);
|
||||
if (!fs.existsSync(stageDir)) {
|
||||
logger.info('canvas', `scanStageFiles: 阶段目录不存在 — ${stageDir}`);
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
const extensions = getStageExtensions(params.stage as StageType);
|
||||
const results = walkDirectory(stageDir, extensions, true);
|
||||
logger.info('canvas', `scanStageFiles: ${results.length} 个文件 — ${stageDir}`);
|
||||
return { success: true, data: results };
|
||||
} catch (err) {
|
||||
logger.warn('canvas', 'scanStageFiles 失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 扫描资产文件(深度递归) ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL_CANVAS.CANVAS_SCAN_ASSET_FILES,
|
||||
async (
|
||||
_event,
|
||||
params: { projectPath: string; assetTypeFilter: string },
|
||||
) => {
|
||||
try {
|
||||
let assetsDir: string;
|
||||
switch (params.assetTypeFilter) {
|
||||
case '角色':
|
||||
assetsDir = path.join(params.projectPath, 'assets', 'chr');
|
||||
break;
|
||||
case '道具':
|
||||
assetsDir = path.join(params.projectPath, 'assets', 'prp');
|
||||
break;
|
||||
case '场景':
|
||||
assetsDir = path.join(params.projectPath, 'assets', 'set');
|
||||
break;
|
||||
default:
|
||||
assetsDir = path.join(params.projectPath, 'assets');
|
||||
}
|
||||
logger.info('canvas', `scanAssetFiles: ${assetsDir} (filter=${params.assetTypeFilter})`);
|
||||
if (!fs.existsSync(assetsDir)) {
|
||||
logger.info('canvas', `scanAssetFiles: 资产目录不存在 — ${assetsDir}`);
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
// 资产深度递归:图片 + 视频
|
||||
const extensions = ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'mp4', 'webm'];
|
||||
const results = walkDirectory(assetsDir, extensions, true);
|
||||
logger.info('canvas', `scanAssetFiles: ${results.length} 个文件 — ${assetsDir}`);
|
||||
return { success: true, data: results };
|
||||
} catch (err) {
|
||||
logger.warn('canvas', 'scanAssetFiles 失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 创建目录 ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL_CANVAS.CANVAS_CREATE_DIRECTORY,
|
||||
async (_event, params: { parentPath: string; dirName: string }) => {
|
||||
try {
|
||||
// 安全校验:拒绝包含危险字符的名称
|
||||
const dangerous = /[/\\:*?"<>|]/;
|
||||
if (!params.dirName || dangerous.test(params.dirName)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `无效的目录名: "${params.dirName}"`,
|
||||
};
|
||||
}
|
||||
const fullPath = path.join(params.parentPath, params.dirName);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
return { success: false, error: `目录已存在: ${fullPath}` };
|
||||
}
|
||||
fs.mkdirSync(fullPath);
|
||||
logger.info('canvas', `createDirectory: ${fullPath}`);
|
||||
return { success: true, data: fullPath };
|
||||
} catch (err) {
|
||||
logger.warn('canvas', 'createDirectory 失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 启动原生文件拖拽(拖放到 Photoshop/Explorer 等外部应用) ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL_CANVAS.CANVAS_START_DRAG,
|
||||
async (_event, params: { filePath: string; fileName: string }) => {
|
||||
try {
|
||||
if (!fs.existsSync(params.filePath)) {
|
||||
logger.warn('canvas', `startDrag: 文件不存在 — ${params.filePath}`);
|
||||
return { success: false, error: '文件不存在' };
|
||||
}
|
||||
_event.sender.startDrag({
|
||||
file: params.filePath,
|
||||
icon: params.filePath,
|
||||
});
|
||||
logger.info('canvas', `startDrag: 已启动 — ${params.fileName}`);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
logger.warn('canvas', 'startDrag 失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
logger.info('canvas', '画布 IPC 处理器已注册(完整实现)');
|
||||
}
|
||||
36
electron/main/ipc/canvas/mime.ts
Normal file
36
electron/main/ipc/canvas/mime.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// ============================================================
|
||||
// canvas/mime.ts — MIME 类型推断
|
||||
// ============================================================
|
||||
|
||||
import path from 'node:path';
|
||||
|
||||
/** MIME 类型映射表 */
|
||||
const MIME_MAP: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.webp': 'image/webp',
|
||||
'.gif': 'image/gif',
|
||||
'.bmp': 'image/bmp',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.mkv': 'video/x-matroska',
|
||||
'.mov': 'video/quicktime',
|
||||
'.avi': 'video/x-msvideo',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.wav': 'audio/wav',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.flac': 'audio/flac',
|
||||
'.aac': 'audio/aac',
|
||||
'.m4a': 'audio/mp4',
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据文件扩展名推断 MIME 类型。
|
||||
* @param filePath - 文件路径
|
||||
* @returns MIME 类型字符串,未知类型返回 'application/octet-stream'
|
||||
*/
|
||||
export function getMimeType(filePath: string): string {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return MIME_MAP[ext] || 'application/octet-stream';
|
||||
}
|
||||
189
electron/main/ipc/canvas/scan-roots.ts
Normal file
189
electron/main/ipc/canvas/scan-roots.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
// ============================================================
|
||||
// canvas/scan-roots.ts — 扫描根目录计算
|
||||
//
|
||||
// 职责:
|
||||
// - 根据项目路径、阶段、资产类型计算扫描根目录列表
|
||||
// - 支持 shots 和 assets 两种目录结构
|
||||
// ============================================================
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { logger } from '../../logger';
|
||||
import type { StageType, AssetTypeFilter } from './types';
|
||||
|
||||
/** 阶段 → 允许的扩展名 */
|
||||
export function getStageExtensions(stage: StageType): string[] {
|
||||
switch (stage) {
|
||||
case 'Storyboard':
|
||||
case 'Keyframe':
|
||||
return ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif'];
|
||||
case 'Video':
|
||||
return ['mp4', 'webm', 'mov', 'avi', 'mkv'];
|
||||
case 'Audio':
|
||||
case 'LipSync':
|
||||
return ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'];
|
||||
case 'Assets':
|
||||
return ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'mp4', 'webm'];
|
||||
}
|
||||
}
|
||||
|
||||
/** 镜头名匹配查询 */
|
||||
export function shotNameMatchesQuery(shotName: string, query: string): boolean {
|
||||
if (!query) return true;
|
||||
const upperShot = shotName.toUpperCase();
|
||||
const upperQuery = query.toUpperCase();
|
||||
if (upperShot.includes(upperQuery)) return true;
|
||||
if (/^\d+$/.test(query)) {
|
||||
const digits = upperShot.replace(/\D/g, '');
|
||||
if (digits.includes(query)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算扫描根目录列表。
|
||||
*
|
||||
* @param params - 扫描参数
|
||||
* @returns 扫描根目录路径列表
|
||||
*/
|
||||
export function buildScanRoots(params: {
|
||||
projectPath: string;
|
||||
stage: StageType;
|
||||
assetTypeFilter: AssetTypeFilter;
|
||||
shotQuery: string;
|
||||
}): string[] {
|
||||
const { projectPath, stage, assetTypeFilter, shotQuery } = params;
|
||||
|
||||
// ── 诊断:检查 projectPath 本身是否存在 ──
|
||||
const projectExists = fs.existsSync(projectPath);
|
||||
logger.info('canvas', 'buildScanRoots 诊断 — projectPath', {
|
||||
projectPath,
|
||||
exists: projectExists,
|
||||
});
|
||||
|
||||
if (!projectExists) {
|
||||
logger.warn('canvas', '⚠️ 项目路径不可访问', undefined, { projectPath });
|
||||
// 尝试列出父目录内容以便排查
|
||||
const parent = path.dirname(projectPath);
|
||||
try {
|
||||
const siblings = fs.readdirSync(parent).filter((n) => !n.startsWith('.'));
|
||||
logger.info('canvas', `父目录 "${parent}" 内容(前 20 项)`, {
|
||||
count: siblings.length,
|
||||
items: siblings.slice(0, 20),
|
||||
});
|
||||
} catch {
|
||||
logger.warn('canvas', `无法读取父目录 "${parent}"`);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// ── 诊断:列出项目根目录内容 ──
|
||||
let projectEntries: string[] = [];
|
||||
try {
|
||||
projectEntries = fs.readdirSync(projectPath).filter((n) => !n.startsWith('.'));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
logger.info('canvas', 'buildScanRoots 诊断 — 项目根目录内容(前 30 项)', {
|
||||
count: projectEntries.length,
|
||||
items: projectEntries.slice(0, 30),
|
||||
});
|
||||
|
||||
if (stage === 'Assets') {
|
||||
const assetsDir = path.join(projectPath, 'assets');
|
||||
logger.info('canvas', 'buildScanRoots — Assets 模式', {
|
||||
assetsDir,
|
||||
exists: fs.existsSync(assetsDir),
|
||||
assetTypeFilter,
|
||||
});
|
||||
switch (assetTypeFilter) {
|
||||
case '角色': return [path.join(assetsDir, 'chr')];
|
||||
case '道具': return [path.join(assetsDir, 'prp')];
|
||||
case '场景': return [path.join(assetsDir, 'set')];
|
||||
default: return [assetsDir];
|
||||
}
|
||||
}
|
||||
|
||||
const shotsDir = path.join(projectPath, 'shots');
|
||||
const shotsExists = fs.existsSync(shotsDir);
|
||||
logger.info('canvas', 'buildScanRoots 诊断 — shotsDir', {
|
||||
shotsDir,
|
||||
exists: shotsExists,
|
||||
});
|
||||
|
||||
if (!shotsExists) {
|
||||
// 列出可能存在的子目录帮助用户对比
|
||||
const dirsInRoot = projectEntries.filter((name) => {
|
||||
try {
|
||||
return fs.statSync(path.join(projectPath, name)).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
logger.warn('canvas', '⚠️ shots 目录不存在,项目根下的子目录:', undefined, {
|
||||
subdirs: dirsInRoot,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
const roots: string[] = [];
|
||||
let episodes: string[];
|
||||
try {
|
||||
episodes = fs.readdirSync(shotsDir).filter((name) => {
|
||||
const full = path.join(shotsDir, name);
|
||||
return !name.startsWith('.') && fs.statSync(full).isDirectory();
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn('canvas', '无法读取 shots 目录', err instanceof Error ? err : undefined, {
|
||||
error: (err as Error).message,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
logger.info('canvas', `buildScanRoots — 发现 ${episodes.length} 个剧集目录`, {
|
||||
episodes,
|
||||
});
|
||||
|
||||
for (const ep of episodes) {
|
||||
const epDir = path.join(shotsDir, ep);
|
||||
let shotNames: string[];
|
||||
try {
|
||||
shotNames = fs.readdirSync(epDir).filter((name) => {
|
||||
const full = path.join(epDir, name);
|
||||
return !name.startsWith('.') && fs.statSync(full).isDirectory();
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
'canvas',
|
||||
`无法读取剧集目录 "${ep}"`,
|
||||
err instanceof Error ? err : undefined,
|
||||
{ error: (err as Error).message },
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info('canvas', ` 📂 ${ep} → ${shotNames.length} 个镜头`, {
|
||||
shots: shotNames.slice(0, 20),
|
||||
});
|
||||
|
||||
for (const shot of shotNames) {
|
||||
if (shotQuery && !shotNameMatchesQuery(shot, shotQuery)) continue;
|
||||
const stageDir = path.join(epDir, shot, stage);
|
||||
const stageExists = fs.existsSync(stageDir);
|
||||
if (stageExists) {
|
||||
roots.push(stageDir);
|
||||
}
|
||||
// 仅在前几个镜头时记录阶段目录存在性
|
||||
if (roots.length <= 3 && !stageExists) {
|
||||
logger.info('canvas', ` 阶段目录不存在: ${stageDir}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('canvas', `buildScanRoots 最终结果:${roots.length} 个扫描根`, {
|
||||
roots: roots.slice(0, 10),
|
||||
stage,
|
||||
});
|
||||
|
||||
return roots;
|
||||
}
|
||||
47
electron/main/ipc/canvas/sidecar.ts
Normal file
47
electron/main/ipc/canvas/sidecar.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// ============================================================
|
||||
// canvas/sidecar.ts — Sidecar 元数据操作
|
||||
//
|
||||
// 职责:
|
||||
// - 读取/写入旁加载 JSON 元数据文件
|
||||
// - sidecar 文件与媒体文件同目录、同名、.json 扩展名
|
||||
// ============================================================
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/** 生成 sidecar JSON 文件路径:同目录、同名、.json 扩展名 */
|
||||
export function sidecarPathForMedia(filePath: string): string {
|
||||
const dir = path.dirname(filePath);
|
||||
const baseName = path.basename(filePath, path.extname(filePath));
|
||||
return path.join(dir, `${baseName}.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取旁加载元数据。
|
||||
* @param filePath - 媒体文件路径
|
||||
* @returns 元数据对象,文件不存在返回 null
|
||||
*/
|
||||
export function readSidecarFile(filePath: string): object | null {
|
||||
const sidecarPath = sidecarPathForMedia(filePath);
|
||||
if (!fs.existsSync(sidecarPath)) return null;
|
||||
try {
|
||||
const content = fs.readFileSync(sidecarPath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入旁加载元数据(自动创建目录)。
|
||||
* @param filePath - 媒体文件路径
|
||||
* @param metadata - 元数据对象
|
||||
*/
|
||||
export function writeSidecarFile(filePath: string, metadata: object): void {
|
||||
const sidecarPath = sidecarPathForMedia(filePath);
|
||||
const dir = path.dirname(sidecarPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(sidecarPath, JSON.stringify(metadata, null, 2), 'utf-8');
|
||||
}
|
||||
84
electron/main/ipc/canvas/thumbnail.ts
Normal file
84
electron/main/ipc/canvas/thumbnail.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
// ============================================================
|
||||
// canvas/thumbnail.ts — 画布缩略图生成
|
||||
//
|
||||
// 职责:
|
||||
// - 生成画布专用缩略图(返回 base64 数据)
|
||||
// - 支持缓存优先策略(.cache 目录)
|
||||
//
|
||||
// 优先级:
|
||||
// 1. .cache/{filename}.webp — 预生成 WEBP 缩略图(最快)
|
||||
// 2. .cache/{filename} — 预生成 PNG 缩略图
|
||||
// 3. sharp — 实时缩放(可选依赖)
|
||||
// 4. 原始文件字节 — 浏览器端缩放回退
|
||||
// ============================================================
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
import { getMimeType } from './mime';
|
||||
import type { ThumbnailResult } from './types';
|
||||
|
||||
/**
|
||||
* 生成画布缩略图。
|
||||
*
|
||||
* @param filePath - 源文件路径
|
||||
* @param size - 缩略图尺寸
|
||||
* @returns 缩略图数据(base64)
|
||||
*/
|
||||
export async function generateCanvasThumbnail(
|
||||
filePath: string,
|
||||
size: number,
|
||||
): Promise<ThumbnailResult> {
|
||||
const dir = path.dirname(filePath);
|
||||
const baseName = path.basename(filePath);
|
||||
const cacheDir = path.join(dir, '.cache');
|
||||
|
||||
// ── 优先级 1:.cache/{filename}.webp ──
|
||||
const cachedWebp = path.join(cacheDir, `${baseName}.webp`);
|
||||
if (fs.existsSync(cachedWebp)) {
|
||||
const buffer = fs.readFileSync(cachedWebp);
|
||||
return {
|
||||
data: buffer.toString('base64'),
|
||||
mimeType: 'image/webp',
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 优先级 2:.cache/{filename}(PNG 副本) ──
|
||||
const cachedPng = path.join(cacheDir, baseName);
|
||||
if (fs.existsSync(cachedPng)) {
|
||||
const buffer = fs.readFileSync(cachedPng);
|
||||
return {
|
||||
data: buffer.toString('base64'),
|
||||
mimeType: 'image/png',
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (sharp) {
|
||||
const image = sharp(filePath);
|
||||
const metadata = await image.metadata();
|
||||
const resized = await image
|
||||
.resize(size, size, { fit: 'inside', withoutEnlargement: true })
|
||||
.jpeg({ quality: 85 })
|
||||
.toBuffer();
|
||||
return {
|
||||
data: resized.toString('base64'),
|
||||
mimeType: 'image/jpeg',
|
||||
width: metadata.width || 0,
|
||||
height: metadata.height || 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 优先级 4:回退原始文件 ──
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
const mimeType = getMimeType(filePath);
|
||||
return {
|
||||
data: buffer.toString('base64'),
|
||||
mimeType,
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
}
|
||||
26
electron/main/ipc/canvas/types.ts
Normal file
26
electron/main/ipc/canvas/types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// ============================================================
|
||||
// canvas/types.ts — 画布相关类型定义
|
||||
// ============================================================
|
||||
|
||||
/** 阶段类型 */
|
||||
export type StageType = 'Storyboard' | 'Keyframe' | 'Video' | 'Audio' | 'LipSync' | 'Assets';
|
||||
|
||||
/** 资产类型过滤器 */
|
||||
export type AssetTypeFilter = '全部' | '角色' | '道具' | '场景';
|
||||
|
||||
/** 扫描结果条目 */
|
||||
export interface ScanEntry {
|
||||
path: string;
|
||||
fileName: string;
|
||||
ext: string;
|
||||
fileSize: number;
|
||||
modifiedAt: string;
|
||||
}
|
||||
|
||||
/** 缩略图结果 */
|
||||
export interface ThumbnailResult {
|
||||
data: string;
|
||||
mimeType: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
66
electron/main/ipc/canvas/walk.ts
Normal file
66
electron/main/ipc/canvas/walk.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
// ============================================================
|
||||
// canvas/walk.ts — 目录遍历算法
|
||||
//
|
||||
// 职责:
|
||||
// - 递归遍历目录,收集所有匹配扩展名的文件
|
||||
// - 跳过隐藏文件和文件夹
|
||||
// ============================================================
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { ScanEntry } from './types';
|
||||
|
||||
/**
|
||||
* 递归遍历目录,收集所有匹配扩展名的文件。
|
||||
*
|
||||
* @param root - 根目录路径
|
||||
* @param extensions - 允许的文件扩展名列表(不含点号)
|
||||
* @param recursive - 是否递归遍历子目录
|
||||
* @returns 文件列表
|
||||
*/
|
||||
export function walkDirectory(
|
||||
root: string,
|
||||
extensions: string[],
|
||||
recursive: boolean,
|
||||
): ScanEntry[] {
|
||||
const results: ScanEntry[] = [];
|
||||
const extSet = new Set(extensions.map((e) => e.toLowerCase()));
|
||||
|
||||
function walk(dir: string) {
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return; // 权限不足等,跳过该目录
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
// 跳过隐藏文件和文件夹
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (recursive) walk(fullPath);
|
||||
} else if (entry.isFile()) {
|
||||
const ext = path.extname(entry.name).slice(1).toLowerCase();
|
||||
if (extSet.has(ext)) {
|
||||
try {
|
||||
const stat = fs.statSync(fullPath);
|
||||
results.push({
|
||||
path: fullPath,
|
||||
fileName: entry.name,
|
||||
ext,
|
||||
fileSize: stat.size,
|
||||
modifiedAt: stat.mtime.toISOString(),
|
||||
});
|
||||
} catch {
|
||||
// 文件不可访问,跳过
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(root);
|
||||
return results;
|
||||
}
|
||||
@@ -3,9 +3,9 @@
|
||||
// ============================================================
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import { RENDERER_TO_MAIN } from '../../shared/constants/ipc-channels';
|
||||
import { writeRendererLog } from './logger';
|
||||
import type { LogEntry } from '../../shared/types/logging';
|
||||
import { RENDERER_TO_MAIN } from '../../../shared/constants/ipc/base';
|
||||
import { writeRendererLog } from '../logger';
|
||||
import type { LogEntry } from '../../../shared/types/logging';
|
||||
|
||||
/**
|
||||
* 注册日志 IPC 监听器
|
||||
@@ -16,8 +16,8 @@
|
||||
// ============================================================
|
||||
|
||||
import { ipcMain, safeStorage } from 'electron';
|
||||
import { BIDIRECTIONAL } from '../../shared/constants/ipc-channels';
|
||||
import { logger } from './logger';
|
||||
import { BIDIRECTIONAL_STORAGE } from '../../../shared/constants/ipc/storage';
|
||||
import { logger } from '../logger';
|
||||
|
||||
/** 检查 safeStorage 是否可用,不可用时记录警告 */
|
||||
function checkAvailability(): boolean {
|
||||
@@ -34,7 +34,7 @@ function checkAvailability(): boolean {
|
||||
*/
|
||||
export function registerSafeStorageIpcHandlers(): void {
|
||||
// ---------- 加密 ----------
|
||||
ipcMain.handle(BIDIRECTIONAL.SAFESTORAGE_ENCRYPT, (_event, plaintext: string) => {
|
||||
ipcMain.handle(BIDIRECTIONAL_STORAGE.SAFESTORAGE_ENCRYPT, (_event, plaintext: string) => {
|
||||
if (typeof plaintext !== 'string' || plaintext.length === 0) {
|
||||
logger.warn('safe-storage', '加密参数无效');
|
||||
return { success: false, error: '参数无效:需要非空字符串' };
|
||||
@@ -56,7 +56,7 @@ export function registerSafeStorageIpcHandlers(): void {
|
||||
});
|
||||
|
||||
// ---------- 解密 ----------
|
||||
ipcMain.handle(BIDIRECTIONAL.SAFESTORAGE_DECRYPT, (_event, encryptedBase64: string) => {
|
||||
ipcMain.handle(BIDIRECTIONAL_STORAGE.SAFESTORAGE_DECRYPT, (_event, encryptedBase64: string) => {
|
||||
if (typeof encryptedBase64 !== 'string' || encryptedBase64.length === 0) {
|
||||
logger.warn('safe-storage', '解密参数无效');
|
||||
return { success: false, error: '参数无效:需要非空字符串' };
|
||||
225
electron/main/ipc/storage-ipc.ts
Normal file
225
electron/main/ipc/storage-ipc.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
// ============================================================
|
||||
// storage-ipc.ts — 本地存储 IPC 处理器(主进程)
|
||||
//
|
||||
// 职责:
|
||||
// - 文件写入/读取/删除(Buffer 基础操作)
|
||||
// - 递归创建目录
|
||||
// - shell.showItemInFolder(右键"在文件夹中查看")
|
||||
// - 获取应用数据目录路径
|
||||
// - 所有操作在 heixiu-data/ 沙箱内进行
|
||||
//
|
||||
// 安全:
|
||||
// - 路径校验:拒绝相对路径中的 ../ 穿越
|
||||
// - 所有写入操作 base 于 getDataDir()
|
||||
// ============================================================
|
||||
|
||||
import { ipcMain, shell, app } from 'electron';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { BIDIRECTIONAL_STORAGE } from '../../../shared/constants/ipc/storage';
|
||||
import { logger } from '../logger';
|
||||
import { resolveSafe, ensureDir, downloadToFile, generateThumbnail } from './utils';
|
||||
|
||||
// ---------- 内部工具 ----------
|
||||
|
||||
let dataDirResolver: (() => string) | null = null;
|
||||
|
||||
/** 注入自定义数据目录解析器(在 registerStorageIpcHandlers() 之前调用) */
|
||||
export function setDataDirResolver(resolver: () => string): void {
|
||||
dataDirResolver = resolver;
|
||||
}
|
||||
|
||||
/** 获取 heixiu-data 根目录 */
|
||||
function getDataDir(): string {
|
||||
return dataDirResolver ? dataDirResolver() : path.join(app.getPath('userData'), 'heixiu-data');
|
||||
}
|
||||
|
||||
// ---------- 注册 IPC 处理器 ----------
|
||||
|
||||
export function registerStorageIpcHandlers(): void {
|
||||
const dataDir = getDataDir();
|
||||
ensureDir(dataDir);
|
||||
logger.info('app', `本地存储目录:${dataDir}`);
|
||||
|
||||
// ---- 获取数据目录 ----
|
||||
ipcMain.handle(BIDIRECTIONAL_STORAGE.GET_DATA_DIR, () => {
|
||||
return { success: true, data: dataDir };
|
||||
});
|
||||
|
||||
// ---- 文件写入 ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL_STORAGE.FILE_WRITE,
|
||||
async (_event, params: { relativePath: string; data: string; encoding?: 'base64' | 'utf8' }) => {
|
||||
try {
|
||||
const filePath = resolveSafe(dataDir, params.relativePath);
|
||||
ensureDir(path.dirname(filePath));
|
||||
|
||||
const buffer = Buffer.from(params.data, params.encoding || 'base64');
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
logger.info('storage', `文件写入成功:${filePath}(${buffer.length} bytes)`);
|
||||
return { success: true, data: filePath };
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
logger.warn('storage', `文件写入失败:${params.relativePath}`, err instanceof Error ? err : undefined);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 文件读取(返回 Base64) ----
|
||||
ipcMain.handle(BIDIRECTIONAL_STORAGE.FILE_READ, async (_event, params: { relativePath: string }) => {
|
||||
try {
|
||||
const filePath = resolveSafe(dataDir, params.relativePath);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return { success: false, error: '文件不存在' };
|
||||
}
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
return { success: true, data: buffer.toString('base64') };
|
||||
} catch (err) {
|
||||
logger.warn('storage', `文件读取失败:${params.relativePath}`, err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 文件删除 ----
|
||||
ipcMain.handle(BIDIRECTIONAL_STORAGE.FILE_DELETE, async (_event, params: { relativePath: string }) => {
|
||||
try {
|
||||
const filePath = resolveSafe(dataDir, params.relativePath);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
logger.warn('storage', `文件删除失败:${params.relativePath}`, err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 创建目录 ----
|
||||
ipcMain.handle(BIDIRECTIONAL_STORAGE.MAKE_DIR, async (_event, params: { relativePath: string }) => {
|
||||
try {
|
||||
const dirPath = resolveSafe(dataDir, params.relativePath);
|
||||
ensureDir(dirPath);
|
||||
return { success: true, data: dirPath };
|
||||
} catch (err) {
|
||||
logger.warn('storage', `目录创建失败:${params.relativePath}`, err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 在文件管理器中显示 ----
|
||||
ipcMain.handle(BIDIRECTIONAL_STORAGE.SHOW_ITEM_IN_FOLDER, async (_event, params: { filePath: string }) => {
|
||||
try {
|
||||
shell.showItemInFolder(params.filePath);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
logger.warn('storage', `showItemInFolder 失败:${params.filePath}`, err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 文件大小 ----
|
||||
ipcMain.handle(BIDIRECTIONAL_STORAGE.FILE_SIZE, async (_event, params: { relativePath: string }) => {
|
||||
try {
|
||||
const filePath = resolveSafe(dataDir, params.relativePath);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return { success: false, error: '文件不存在' };
|
||||
}
|
||||
const stat = fs.statSync(filePath);
|
||||
return { success: true, data: stat.size };
|
||||
} catch (err) {
|
||||
logger.warn('storage', '获取文件大小失败', err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 文件/目录是否存在 ----
|
||||
ipcMain.handle(BIDIRECTIONAL_STORAGE.FILE_EXISTS, async (_event, params: { relativePath: string }) => {
|
||||
try {
|
||||
const filePath = resolveSafe(dataDir, params.relativePath);
|
||||
return { success: true, data: fs.existsSync(filePath) };
|
||||
} catch {
|
||||
return { success: true, data: false };
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 绝对路径文件写入(绕过沙箱,用于用户自定义输出目录) ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL_STORAGE.FILE_WRITE_ABSOLUTE,
|
||||
async (_event, params: { absolutePath: string; data: string; encoding?: 'base64' | 'utf8' }) => {
|
||||
try {
|
||||
ensureDir(path.dirname(params.absolutePath));
|
||||
const buffer = Buffer.from(params.data, params.encoding || 'base64');
|
||||
fs.writeFileSync(params.absolutePath, buffer);
|
||||
logger.info('storage', `绝对路径写入成功:${params.absolutePath}(${buffer.length} bytes)`);
|
||||
return { success: true, data: params.absolutePath };
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
logger.warn('storage', `绝对路径写入失败:${params.absolutePath}`, err instanceof Error ? err : undefined);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 绝对路径目录创建(绕过沙箱) ----
|
||||
ipcMain.handle(BIDIRECTIONAL_STORAGE.MAKE_DIR_ABSOLUTE, async (_event, params: { absolutePath: string }) => {
|
||||
try {
|
||||
ensureDir(params.absolutePath);
|
||||
return { success: true, data: params.absolutePath };
|
||||
} catch (err) {
|
||||
logger.warn('storage', `绝对路径目录创建失败:${params.absolutePath}`, err instanceof Error ? err : undefined);
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 获取系统下载目录 ----
|
||||
ipcMain.handle(BIDIRECTIONAL_STORAGE.GET_DOWNLOADS_PATH, () => {
|
||||
try {
|
||||
return { success: true, data: app.getPath('downloads') };
|
||||
} catch {
|
||||
return { success: false, error: '无法获取下载目录' };
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 主进程下载文件到绝对路径(绕过浏览器 CORS) ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL_STORAGE.DOWNLOAD_TO_PATH,
|
||||
async (
|
||||
_event,
|
||||
params: { url: string; destPath: string; headers?: Record<string, string> },
|
||||
): Promise<{ success: boolean; data?: string; error?: string }> => {
|
||||
const { url, destPath, headers } = params;
|
||||
try {
|
||||
await downloadToFile(url, destPath, headers);
|
||||
logger.info('storage', `主进程下载成功:${destPath}`);
|
||||
return { success: true, data: destPath };
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message || '下载失败';
|
||||
logger.warn('storage', `主进程下载失败:${url}`, err instanceof Error ? err : undefined);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 生成图片缩略图(sharp 流式管线) ----
|
||||
ipcMain.handle(
|
||||
BIDIRECTIONAL_STORAGE.GENERATE_THUMBNAIL,
|
||||
async (
|
||||
_event,
|
||||
params: { srcPath: string; destPath: string; maxWidth?: number; quality?: number },
|
||||
): Promise<{ success: boolean; data?: string; error?: string }> => {
|
||||
const { srcPath, destPath, maxWidth = 256, quality = 60 } = params;
|
||||
try {
|
||||
await generateThumbnail(srcPath, destPath, maxWidth, quality);
|
||||
logger.info('storage', `缩略图生成成功:${destPath}`);
|
||||
return { success: true, data: destPath };
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message || '缩略图生成失败';
|
||||
logger.warn('storage', `缩略图生成失败:${srcPath}`, err instanceof Error ? err : undefined);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
logger.info('app', '存储 IPC 处理器已注册');
|
||||
}
|
||||
97
electron/main/ipc/utils/download.ts
Normal file
97
electron/main/ipc/utils/download.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
// ============================================================
|
||||
// download.ts — 文件下载工具函数
|
||||
//
|
||||
// 职责:
|
||||
// - 通过 Electron net 模块下载文件到磁盘
|
||||
// - 支持重定向、超时、自定义请求头
|
||||
//
|
||||
// 设计:
|
||||
// - net 基于 Chromium 网络栈,自动携带浏览器标准头,不受 CORS 限制
|
||||
// - 不能用 netRequest(它 chunk.toString() 会损坏二进制数据)
|
||||
// - 不能用 Node.js https(缺少浏览器头导致 CDN 返回 400)
|
||||
// ============================================================
|
||||
|
||||
import { net } from 'electron';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { ensureDir } from './path';
|
||||
|
||||
/**
|
||||
* 通过 Electron net 模块下载文件到磁盘。
|
||||
*
|
||||
* @param url - 下载地址
|
||||
* @param destPath - 目标文件路径
|
||||
* @param headers - 可选的自定义请求头
|
||||
*/
|
||||
export function downloadToFile(
|
||||
url: string,
|
||||
destPath: string,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = net.request({ method: 'GET', url });
|
||||
|
||||
// 自定义请求头
|
||||
if (headers) {
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
req.setHeader(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// 超时 120s
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
req.abort();
|
||||
reject(new Error('下载超时(120s)'));
|
||||
}, 120_000);
|
||||
|
||||
req.on('response', (response) => {
|
||||
clearTimeout(timer);
|
||||
const status = response.statusCode;
|
||||
|
||||
// 3xx 重定向
|
||||
if (status >= 300 && status < 400) {
|
||||
const location = response.headers['location']?.[0];
|
||||
if (location && location !== url) {
|
||||
downloadToFile(location, destPath, headers).then(resolve).catch(reject);
|
||||
} else {
|
||||
reject(new Error('重定向循环或缺少 Location 头'));
|
||||
}
|
||||
response.on('data', () => {}); // 消费响应体
|
||||
return;
|
||||
}
|
||||
|
||||
if (status >= 400) {
|
||||
reject(new Error(`HTTP ${status}`));
|
||||
response.on('data', () => {});
|
||||
return;
|
||||
}
|
||||
|
||||
// 收集二进制 chunks → 写入文件
|
||||
const chunks: Buffer[] = [];
|
||||
response.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
response.on('end', () => {
|
||||
try {
|
||||
ensureDir(path.dirname(destPath));
|
||||
const buffer = Buffer.concat(chunks);
|
||||
fs.writeFileSync(destPath, buffer);
|
||||
resolve();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
response.on('error', (err) => {
|
||||
reject(new Error(`响应读取失败: ${err.message}`));
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
clearTimeout(timer);
|
||||
if (timedOut) return;
|
||||
reject(new Error(`网络请求失败: ${err.message}`));
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
7
electron/main/ipc/utils/index.ts
Normal file
7
electron/main/ipc/utils/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// ============================================================
|
||||
// utils/index.ts — 工具函数汇总导出
|
||||
// ============================================================
|
||||
|
||||
export { resolveSafe, ensureDir } from './path';
|
||||
export { downloadToFile } from './download';
|
||||
export { generateThumbnail } from './thumbnail';
|
||||
29
electron/main/ipc/utils/path.ts
Normal file
29
electron/main/ipc/utils/path.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// ============================================================
|
||||
// path.ts — 路径安全工具函数
|
||||
//
|
||||
// 职责:
|
||||
// - 路径穿越防护
|
||||
// - 目录创建
|
||||
// ============================================================
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* 将相对路径解析为绝对路径,并校验是否在 dataDir 内。
|
||||
* 防止路径穿越攻击(../ 穿越)。
|
||||
*/
|
||||
export function resolveSafe(dataDir: string, relativePath: string): string {
|
||||
const resolved = path.resolve(dataDir, relativePath);
|
||||
if (!resolved.startsWith(dataDir + path.sep) && resolved !== dataDir) {
|
||||
throw new Error(`路径越界:${relativePath}`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** 确保目录存在(递归创建) */
|
||||
export function ensureDir(dirPath: string): void {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
60
electron/main/ipc/utils/thumbnail.ts
Normal file
60
electron/main/ipc/utils/thumbnail.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// ============================================================
|
||||
// thumbnail.ts — 缩略图生成工具函数
|
||||
//
|
||||
// 职责:
|
||||
// - 使用 sharp 库生成图片缩略图
|
||||
// - 内存优化配置
|
||||
//
|
||||
// 依赖:
|
||||
// - sharp (libvips) - 可选依赖,用于高效图片处理
|
||||
// ============================================================
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
import { ensureDir } from './path';
|
||||
|
||||
// ---------- sharp/libvips 内存优化 ----------
|
||||
|
||||
// 单块内存上限 128MB(默认 512MB,降低以适配 Electron 单进程内存压力)
|
||||
sharp.cache({ memory: 128, items: 100 });
|
||||
// libvips 并发数 1(减少大图并发处理时的内存峰值)
|
||||
sharp.concurrency(1);
|
||||
|
||||
/**
|
||||
* 生成图片缩略图(sharp 流式管线)
|
||||
*
|
||||
* 特性:
|
||||
* - shrinkOnLoad:PNG/JPEG/WebP/TIFF 解码器直接降采样,8000px 原图解码即降至 ~512px
|
||||
* - sequentialRead:顺序读取减少随机 I/O 内存开销
|
||||
* - limitInputPixels:默认 268MP(16384²),覆盖 8K+ 素材
|
||||
*
|
||||
* @param srcPath - 源文件路径
|
||||
* @param destPath - 目标文件路径
|
||||
* @param maxWidth - 最大宽度(默认 256)
|
||||
* @param quality - JPEG 质量(默认 60)
|
||||
* @returns 目标文件路径
|
||||
*/
|
||||
export async function generateThumbnail(
|
||||
srcPath: string,
|
||||
destPath: string,
|
||||
maxWidth: number = 256,
|
||||
quality: number = 60,
|
||||
): Promise<string> {
|
||||
if (!fs.existsSync(srcPath)) {
|
||||
throw new Error('源文件不存在');
|
||||
}
|
||||
|
||||
ensureDir(path.dirname(destPath));
|
||||
|
||||
await sharp(srcPath, {
|
||||
animated: false, // 只取第一帧(GIF/APNG 等多帧格式)
|
||||
sequentialRead: true,
|
||||
limitInputPixels: 268402689,
|
||||
})
|
||||
.resize({ width: maxWidth, withoutEnlargement: true, fit: 'inside' })
|
||||
.jpeg({ quality, mozjpeg: true }) // mozjpeg 压缩率更优
|
||||
.toFile(destPath);
|
||||
|
||||
return destPath;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -213,19 +212,19 @@ export async function request<T = unknown>(
|
||||
export const netRequest = {
|
||||
request,
|
||||
|
||||
get<T>(url: string, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||||
return request<T>(url, { ...config, method: 'GET' });
|
||||
async get<T>(url: string, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||||
return await request<T>(url, { ...config, method: 'GET' });
|
||||
},
|
||||
|
||||
post<T>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||||
async post<T>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||||
return request<T>(url, { ...config, method: 'POST', data });
|
||||
},
|
||||
|
||||
put<T>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||||
async put<T>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||||
return request<T>(url, { ...config, method: 'PUT', data });
|
||||
},
|
||||
|
||||
del<T>(url: string, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||||
async del<T>(url: string, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||||
return request<T>(url, { ...config, method: 'DELETE' });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,435 +0,0 @@
|
||||
// 自动更新 — HeiXiuProvider + electron-updater 增量下载
|
||||
// 流程图:调用自有 API → 获取版本信息 → electron-updater 差分下载 → SHA512 校验 → 安装
|
||||
|
||||
import {BrowserWindow, ipcMain} from 'electron';
|
||||
import {URL} from 'node:url';
|
||||
|
||||
import {
|
||||
NsisUpdater,
|
||||
MacUpdater,
|
||||
AppImageUpdater,
|
||||
Provider,
|
||||
} from 'electron-updater';
|
||||
import type {ProviderRuntimeOptions} from 'electron-updater/out/providers/Provider';
|
||||
import type {UpdateInfo, UpdateFileInfo} from 'builder-util-runtime';
|
||||
import type {ResolvedUpdateFileInfo} from 'electron-updater/out/types';
|
||||
|
||||
import type {UpdateCheckResult} from '../../shared/types';
|
||||
import {APP_VERSION} from '../../shared/constants/version';
|
||||
import {logger} from './logger';
|
||||
import {netRequest} from './net-request';
|
||||
|
||||
// ---------- IPC 通道(对渲染进程暴露,与旧版兼容)----------
|
||||
|
||||
export const UPDATE_CHANNELS = {
|
||||
UPDATE_AVAILABLE: 'update-available',
|
||||
UPDATE_PROGRESS: 'update-progress',
|
||||
UPDATE_DOWNLOADED: 'update-downloaded',
|
||||
UPDATE_ERROR: 'update-error',
|
||||
UPDATE_NOT_AVAILABLE: 'update-not-available',
|
||||
INSTALL_UPDATE: 'install-update',
|
||||
CHECK_FOR_UPDATE: 'check-for-update',
|
||||
DOWNLOAD_UPDATE: 'download-update',
|
||||
} as const;
|
||||
|
||||
// ---------- 配置 ----------
|
||||
|
||||
function getApiBaseUrl(): string {
|
||||
const url = (process.env.UPDATE_API_URL || 'https://www.heixiu.com').trim();
|
||||
console.log(`[updater] UPDATE_API_URL=${process.env.UPDATE_API_URL} → 使用: ${url}`);
|
||||
return url;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 自定义 Provider
|
||||
// ============================================================
|
||||
|
||||
class HeiXiuProvider extends Provider<UpdateInfo> {
|
||||
private apiBaseUrl: string;
|
||||
/** 短时缓存:避免 checkForUpdates 预检 + electron-updater 内部调用导致两次 API 请求 */
|
||||
private _cachedResult: { data: UpdateInfo; ts: number } | null = null;
|
||||
|
||||
constructor(runtimeOptions: ProviderRuntimeOptions) {
|
||||
super(runtimeOptions);
|
||||
this.apiBaseUrl = getApiBaseUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用自有版本检查 API,返回 electron-updater 格式的 UpdateInfo。
|
||||
* blockMapSize > 0 时 electron-updater 自动启用差分下载。
|
||||
* 无更新时返回当前版本号(APP_VERSION)。
|
||||
*/
|
||||
async getLatestVersion(): Promise<UpdateInfo> {
|
||||
// 短时缓存(500ms):预检和 electron-updater 内部调用共享同一结果
|
||||
if (this._cachedResult && Date.now() - this._cachedResult.ts < 500) {
|
||||
return this._cachedResult.data;
|
||||
}
|
||||
const data = await this._doGetLatestVersion();
|
||||
this._cachedResult = { data, ts: Date.now() };
|
||||
return data;
|
||||
}
|
||||
|
||||
private async _doGetLatestVersion(): Promise<UpdateInfo> {
|
||||
const {data} = await netRequest.get<{ code: number; data?: UpdateCheckResult } & UpdateCheckResult>(
|
||||
'/api/v1/update/check',
|
||||
{
|
||||
baseURL: this.apiBaseUrl,
|
||||
params: {
|
||||
platform: process.platform,
|
||||
version: APP_VERSION,
|
||||
edition: process.env.EDITION || 'personal',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// 兼容两种 API 响应格式
|
||||
const result: UpdateCheckResult =
|
||||
(data as { data?: UpdateCheckResult }).data ?? (data as UpdateCheckResult);
|
||||
|
||||
// 无更新 → 返回当前版本号
|
||||
if (!result.hasUpdate) {
|
||||
return {
|
||||
version: APP_VERSION,
|
||||
files: [],
|
||||
path: '',
|
||||
sha512: '',
|
||||
releaseDate: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const file: UpdateFileInfo = {
|
||||
url: result.downloadUrl,
|
||||
sha512: result.sha512,
|
||||
size: result.fileSize,
|
||||
};
|
||||
|
||||
// 后端 API 返回 blockMapSize 时启用增量更新
|
||||
if (result.blockMapSize) {
|
||||
file.blockMapSize = result.blockMapSize;
|
||||
}
|
||||
|
||||
logger.info('updater', 'Provider 获取到新版本', {
|
||||
latestVersion: result.latestVersion,
|
||||
hasBlockmap: file.blockMapSize != null,
|
||||
});
|
||||
|
||||
return {
|
||||
version: result.latestVersion,
|
||||
files: [file],
|
||||
path: result.downloadUrl,
|
||||
sha512: result.sha512,
|
||||
releaseDate: result.releaseDate,
|
||||
releaseNotes: result.changelog,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 UpdateInfo 中的相对路径解析为完整的下载 URL。
|
||||
* electron-updater 在下载前调用此方法。
|
||||
*/
|
||||
resolveFiles(updateInfo: UpdateInfo): Array<ResolvedUpdateFileInfo> {
|
||||
return updateInfo.files.map((f) => {
|
||||
// 使用 downloadUrl 作为 base URL 来解析文件路径
|
||||
const baseUrl = new URL(updateInfo.path);
|
||||
const fileUrl = new URL(f.url, baseUrl.origin);
|
||||
return {
|
||||
url: fileUrl,
|
||||
info: f,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 平台特定 Updater(注入自定义 Provider)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 覆盖 getUpdateInfoAndProvider() 以注入 HeiXiuProvider。
|
||||
* 其他行为(NSIS 安装、签名校验等)完全由父类处理。
|
||||
*/
|
||||
class HeiXiuNsisUpdater extends NsisUpdater {
|
||||
private heiXiuProvider: HeiXiuProvider;
|
||||
|
||||
constructor(heiXiuProvider: HeiXiuProvider) {
|
||||
// 传 undefined 跳过内置 Provider 创建,由我们自己注入
|
||||
super(undefined);
|
||||
this.heiXiuProvider = heiXiuProvider;
|
||||
this.autoDownload = false; // 由我们手动控制流程,保持 IPC 兼容
|
||||
this.autoInstallOnAppQuit = false;
|
||||
this.forceDevUpdateConfig = true; // 绕过 electron-updater 的 dev 模式检查
|
||||
}
|
||||
|
||||
/** @internal — 注入自定义 Provider */
|
||||
protected override async getUpdateInfoAndProvider(): Promise<{
|
||||
info: UpdateInfo;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
provider: Provider<any>;
|
||||
}> {
|
||||
const info = await this.heiXiuProvider.getLatestVersion();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return {info, provider: this.heiXiuProvider as Provider<any>};
|
||||
}
|
||||
}
|
||||
|
||||
class HeiXiuMacUpdater extends MacUpdater {
|
||||
private heiXiuProvider: HeiXiuProvider;
|
||||
|
||||
constructor(heiXiuProvider: HeiXiuProvider) {
|
||||
super(undefined);
|
||||
this.heiXiuProvider = heiXiuProvider;
|
||||
this.autoDownload = false;
|
||||
this.autoInstallOnAppQuit = false;
|
||||
}
|
||||
|
||||
protected override async getUpdateInfoAndProvider(): Promise<{
|
||||
info: UpdateInfo;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
provider: Provider<any>;
|
||||
}> {
|
||||
const info = await this.heiXiuProvider.getLatestVersion();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return {info, provider: this.heiXiuProvider as Provider<any>};
|
||||
}
|
||||
}
|
||||
|
||||
class HeiXiuAppImageUpdater extends AppImageUpdater {
|
||||
private heiXiuProvider: HeiXiuProvider;
|
||||
|
||||
constructor(heiXiuProvider: HeiXiuProvider) {
|
||||
super(null);
|
||||
this.heiXiuProvider = heiXiuProvider;
|
||||
this.autoDownload = false;
|
||||
this.autoInstallOnAppQuit = false;
|
||||
}
|
||||
|
||||
protected override async getUpdateInfoAndProvider(): Promise<{
|
||||
info: UpdateInfo;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
provider: Provider<any>;
|
||||
}> {
|
||||
const info = await this.heiXiuProvider.getLatestVersion();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return {info, provider: this.heiXiuProvider as Provider<any>};
|
||||
}
|
||||
}
|
||||
|
||||
/** 工厂:按平台创建对应的 Updater 实例 */
|
||||
function createPlatformUpdater(provider: HeiXiuProvider) {
|
||||
switch (process.platform) {
|
||||
case 'win32':
|
||||
return new HeiXiuNsisUpdater(provider);
|
||||
case 'darwin':
|
||||
return new HeiXiuMacUpdater(provider);
|
||||
default:
|
||||
return new HeiXiuAppImageUpdater(provider);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 状态 ----------
|
||||
|
||||
let updateWindow: BrowserWindow | null = null;
|
||||
let provider: HeiXiuProvider | null = null;
|
||||
let platformUpdater: NsisUpdater | MacUpdater | AppImageUpdater | null = null;
|
||||
|
||||
// ============================================================
|
||||
// 初始化
|
||||
// ============================================================
|
||||
|
||||
export function initUpdater(mainWindow: BrowserWindow): void {
|
||||
updateWindow = mainWindow;
|
||||
|
||||
console.log('[updater] initUpdater 被调用,API URL:', getApiBaseUrl());
|
||||
|
||||
// if (import.meta.env.DEV) {
|
||||
// logger.info('updater', '开发模式,跳过自动检查更新', { apiUrl: getApiBaseUrl() });
|
||||
// return;
|
||||
// }
|
||||
|
||||
// 创建自定义 Provider
|
||||
provider = new HeiXiuProvider({
|
||||
isUseMultipleRangeRequest: true,
|
||||
platform: process.platform as ProviderRuntimeOptions['platform'],
|
||||
executor: null as unknown as ProviderRuntimeOptions['executor'],
|
||||
});
|
||||
|
||||
// 创建平台 Updater
|
||||
platformUpdater = createPlatformUpdater(provider);
|
||||
|
||||
// 将 electron-updater 事件桥接到现有 IPC 通道
|
||||
platformUpdater.on('checking-for-update', () => {
|
||||
logger.debug('updater', '正在检查更新');
|
||||
});
|
||||
|
||||
platformUpdater.on('update-available', (info: UpdateInfo) => {
|
||||
// releaseNotes 可能是 string | ReleaseNoteInfo[] | null,统一转为 string
|
||||
let notes = '';
|
||||
if (typeof info.releaseNotes === 'string') {
|
||||
notes = info.releaseNotes;
|
||||
} else if (Array.isArray(info.releaseNotes)) {
|
||||
notes = info.releaseNotes.map((n) => n.note).filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_AVAILABLE, {
|
||||
version: info.version,
|
||||
releaseDate: info.releaseDate,
|
||||
releaseNotes: notes,
|
||||
forceUpdate: false,
|
||||
});
|
||||
logger.info('updater', '发现新版本', {
|
||||
version: info.version,
|
||||
releaseNotes: notes ? `${notes.slice(0, 50)}...` : '(空)',
|
||||
});
|
||||
});
|
||||
|
||||
platformUpdater.on('update-not-available', (info: UpdateInfo) => {
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_NOT_AVAILABLE);
|
||||
logger.info('updater', '当前已是最新版本', {currentVersion: info.version});
|
||||
});
|
||||
|
||||
platformUpdater.on('download-progress', (progress: {
|
||||
percent: number;
|
||||
bytesPerSecond: number;
|
||||
transferred: number;
|
||||
total: number
|
||||
}) => {
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_PROGRESS, {
|
||||
percent: Math.round(progress.percent),
|
||||
bytesPerSecond: progress.bytesPerSecond,
|
||||
transferred: progress.transferred,
|
||||
total: progress.total,
|
||||
});
|
||||
});
|
||||
|
||||
platformUpdater.on('update-downloaded', () => {
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_DOWNLOADED);
|
||||
logger.info('updater', '更新已下载完成');
|
||||
});
|
||||
|
||||
platformUpdater.on('error', (error: Error) => {
|
||||
logger.error('updater', '更新流程出错', error);
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
|
||||
message: error.message || '更新失败',
|
||||
});
|
||||
});
|
||||
|
||||
// 启动 5 秒后静默检查
|
||||
setTimeout(() => {
|
||||
checkForUpdates();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 操作方法
|
||||
// ============================================================
|
||||
|
||||
let updateChecked = false;
|
||||
|
||||
/** 静默检查更新(自动下载) */
|
||||
export async function checkForUpdates(): Promise<void> {
|
||||
if (updateChecked) {
|
||||
logger.debug('updater', '已检查过更新,跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!platformUpdater || !provider) return;
|
||||
|
||||
try {
|
||||
updateChecked = true;
|
||||
|
||||
// ① 先自行检查有无更新(不经过 electron-updater)
|
||||
const info = await provider.getLatestVersion();
|
||||
if (info.version === APP_VERSION) {
|
||||
// 无更新 → 直接通知 UI,不触发 electron-updater 的任何事件
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_NOT_AVAILABLE);
|
||||
logger.debug('updater', '当前已是最新版本', {version: APP_VERSION});
|
||||
return;
|
||||
}
|
||||
|
||||
// ② 有新版本 → 交给 electron-updater 触发 'update-available' 事件(不自动下载)
|
||||
await platformUpdater.checkForUpdates();
|
||||
} catch (error) {
|
||||
updateChecked = false;
|
||||
logger.error('updater', '更新流程失败', error as Error, {apiUrl: getApiBaseUrl()});
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
|
||||
message: (error as Error).message || '更新检查失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 手动检查更新(用户点击触发) */
|
||||
export async function checkForUpdatesManual(): Promise<void> {
|
||||
if (!platformUpdater || !provider) return;
|
||||
|
||||
try {
|
||||
updateChecked = false;
|
||||
|
||||
// ① 先自行检查有无更新
|
||||
const info = await provider.getLatestVersion();
|
||||
if (info.version === APP_VERSION) {
|
||||
// 无更新 → 直接通知 UI
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_NOT_AVAILABLE);
|
||||
logger.debug('updater', '手动检查:当前已是最新版本', {version: APP_VERSION});
|
||||
return;
|
||||
}
|
||||
|
||||
// ② 有新版本 → 交给 electron-updater 触发 'update-available' 事件(不自动下载)
|
||||
await platformUpdater.checkForUpdates();
|
||||
} catch (error) {
|
||||
logger.error('updater', '手动更新失败', error as Error);
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
|
||||
message: (error as Error).message || '检查更新失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 用户确认后开始下载更新 */
|
||||
export async function downloadUpdate(): Promise<void> {
|
||||
if (!platformUpdater) {
|
||||
logger.warn('updater', 'downloadUpdate: Updater 未初始化');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await platformUpdater.downloadUpdate();
|
||||
} catch (error) {
|
||||
logger.error('updater', '下载更新失败', error as Error);
|
||||
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
|
||||
message: (error as Error).message || '下载更新失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 安装已下载的更新并退出应用 */
|
||||
export function installUpdateNow(): void {
|
||||
if (!platformUpdater) {
|
||||
logger.warn('updater', 'Updater 未初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('updater', '开始安装更新并退出应用');
|
||||
setImmediate(() => {
|
||||
platformUpdater!.quitAndInstall(false, true);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// IPC 注册
|
||||
// ============================================================
|
||||
|
||||
export function registerUpdateIpcHandlers(): void {
|
||||
ipcMain.handle(UPDATE_CHANNELS.CHECK_FOR_UPDATE, async () => {
|
||||
await checkForUpdatesManual();
|
||||
});
|
||||
|
||||
ipcMain.handle(UPDATE_CHANNELS.DOWNLOAD_UPDATE, async () => {
|
||||
await downloadUpdate();
|
||||
});
|
||||
|
||||
ipcMain.on(UPDATE_CHANNELS.INSTALL_UPDATE, () => {
|
||||
installUpdateNow();
|
||||
});
|
||||
}
|
||||
219
electron/main/updater/index.ts
Normal file
219
electron/main/updater/index.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
// ============================================================
|
||||
// updater/index.ts — 自动更新主入口
|
||||
//
|
||||
// 流程图:调用自有 API → 获取版本信息 → electron-updater 差分下载 → SHA512 校验 → 安装
|
||||
// ============================================================
|
||||
|
||||
import { BrowserWindow, ipcMain } from 'electron';
|
||||
import type { NsisUpdater, MacUpdater, AppImageUpdater } from 'electron-updater';
|
||||
import type { UpdateInfo } from 'builder-util-runtime';
|
||||
|
||||
import { APP_VERSION } from '../../../shared/constants/version';
|
||||
import { MAIN_TO_RENDERER_UPDATER, RENDERER_TO_MAIN_UPDATER } from '../../../shared/constants/ipc/updater';
|
||||
import { logger } from '../logger';
|
||||
import { HeiXiuProvider, getApiBaseUrl, getDeviceFingerprint, BUILD_CHANNEL, BUILD_EDITION } from './provider';
|
||||
import { createPlatformUpdater } from './platform-updaters';
|
||||
|
||||
// ---------- 状态 ----------
|
||||
|
||||
let updateWindow: BrowserWindow | null = null;
|
||||
let provider: HeiXiuProvider | null = null;
|
||||
let platformUpdater: NsisUpdater | MacUpdater | AppImageUpdater | null = null;
|
||||
|
||||
// ============================================================
|
||||
// 初始化
|
||||
// ============================================================
|
||||
|
||||
export function initUpdater(mainWindow: BrowserWindow): void {
|
||||
updateWindow = mainWindow;
|
||||
|
||||
console.log('[updater] initUpdater 被调用');
|
||||
console.log(`[updater] API URL: ${getApiBaseUrl()}`);
|
||||
console.log(`[updater] 渠道: ${BUILD_CHANNEL}`);
|
||||
console.log(`[updater] 版本类型: ${BUILD_EDITION}`);
|
||||
console.log(`[updater] 当前版本: ${APP_VERSION}`);
|
||||
console.log(`[updater] 设备指纹: ${getDeviceFingerprint()}`);
|
||||
|
||||
// 创建自定义 Provider
|
||||
provider = new HeiXiuProvider({
|
||||
isUseMultipleRangeRequest: true,
|
||||
platform: process.platform as 'win32' | 'darwin' | 'linux',
|
||||
executor: null as never,
|
||||
});
|
||||
|
||||
// 创建平台 Updater
|
||||
platformUpdater = createPlatformUpdater(provider);
|
||||
|
||||
// 将 electron-updater 事件桥接到现有 IPC 通道
|
||||
platformUpdater.on('checking-for-update', () => {
|
||||
logger.debug('updater', '正在检查更新');
|
||||
});
|
||||
|
||||
platformUpdater.on('update-available', (info: UpdateInfo) => {
|
||||
// releaseNotes 可能是 string | ReleaseNoteInfo[] | null,统一转为 string
|
||||
let notes = '';
|
||||
if (typeof info.releaseNotes === 'string') {
|
||||
notes = info.releaseNotes;
|
||||
} else if (Array.isArray(info.releaseNotes)) {
|
||||
notes = info.releaseNotes.map((n) => n.note).filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_AVAILABLE, {
|
||||
version: info.version,
|
||||
releaseDate: info.releaseDate,
|
||||
releaseNotes: notes,
|
||||
forceUpdate: false,
|
||||
});
|
||||
logger.info('updater', '发现新版本', {
|
||||
version: info.version,
|
||||
releaseNotes: notes ? `${notes.slice(0, 50)}...` : '(空)',
|
||||
});
|
||||
});
|
||||
|
||||
platformUpdater.on('update-not-available', (info: UpdateInfo) => {
|
||||
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_NOT_AVAILABLE);
|
||||
logger.info('updater', '当前已是最新版本', { currentVersion: info.version });
|
||||
});
|
||||
|
||||
platformUpdater.on('download-progress', (progress: {
|
||||
percent: number;
|
||||
bytesPerSecond: number;
|
||||
transferred: number;
|
||||
total: number;
|
||||
}) => {
|
||||
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_PROGRESS, {
|
||||
percent: Math.round(progress.percent),
|
||||
bytesPerSecond: progress.bytesPerSecond,
|
||||
transferred: progress.transferred,
|
||||
total: progress.total,
|
||||
});
|
||||
});
|
||||
|
||||
platformUpdater.on('update-downloaded', () => {
|
||||
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_DOWNLOADED);
|
||||
logger.info('updater', '更新已下载完成');
|
||||
});
|
||||
|
||||
platformUpdater.on('error', (error: Error) => {
|
||||
logger.error('updater', '更新流程出错', error);
|
||||
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_ERROR, {
|
||||
message: error.message || '更新失败',
|
||||
});
|
||||
});
|
||||
|
||||
// 启动 5 秒后静默检查
|
||||
setTimeout(() => {
|
||||
checkForUpdates();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 操作方法
|
||||
// ============================================================
|
||||
|
||||
let updateChecked = false;
|
||||
|
||||
/** 静默检查更新(自动下载) */
|
||||
export async function checkForUpdates(): Promise<void> {
|
||||
if (updateChecked) {
|
||||
logger.debug('updater', '已检查过更新,跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!platformUpdater || !provider) return;
|
||||
|
||||
try {
|
||||
updateChecked = true;
|
||||
|
||||
// ① 先自行检查有无更新(不经过 electron-updater)
|
||||
const info = await provider.getLatestVersion();
|
||||
if (info.version === APP_VERSION) {
|
||||
// 无更新 → 直接通知 UI,不触发 electron-updater 的任何事件
|
||||
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_NOT_AVAILABLE);
|
||||
logger.debug('updater', '当前已是最新版本', { version: APP_VERSION });
|
||||
return;
|
||||
}
|
||||
|
||||
// ② 有新版本 → 交给 electron-updater 触发 'update-available' 事件(不自动下载)
|
||||
await platformUpdater.checkForUpdates();
|
||||
} catch (error) {
|
||||
updateChecked = false;
|
||||
logger.error('updater', '更新流程失败', error as Error, { apiUrl: getApiBaseUrl() });
|
||||
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_ERROR, {
|
||||
message: (error as Error).message || '更新检查失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 手动检查更新(用户点击触发) */
|
||||
export async function checkForUpdatesManual(): Promise<void> {
|
||||
if (!platformUpdater || !provider) return;
|
||||
|
||||
try {
|
||||
updateChecked = false;
|
||||
|
||||
// ① 先自行检查有无更新
|
||||
const info = await provider.getLatestVersion();
|
||||
if (info.version === APP_VERSION) {
|
||||
// 无更新 → 直接通知 UI
|
||||
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_NOT_AVAILABLE);
|
||||
logger.debug('updater', '手动检查:当前已是最新版本', { version: APP_VERSION });
|
||||
return;
|
||||
}
|
||||
|
||||
// ② 有新版本 → 交给 electron-updater 触发 'update-available' 事件(不自动下载)
|
||||
await platformUpdater.checkForUpdates();
|
||||
} catch (error) {
|
||||
logger.error('updater', '手动更新失败', error as Error);
|
||||
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_ERROR, {
|
||||
message: (error as Error).message || '检查更新失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 用户确认后开始下载更新 */
|
||||
export async function downloadUpdate(): Promise<void> {
|
||||
if (!platformUpdater) {
|
||||
logger.warn('updater', 'downloadUpdate: Updater 未初始化');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await platformUpdater.downloadUpdate();
|
||||
} catch (error) {
|
||||
logger.error('updater', '下载更新失败', error as Error);
|
||||
updateWindow?.webContents.send(MAIN_TO_RENDERER_UPDATER.UPDATE_ERROR, {
|
||||
message: (error as Error).message || '下载更新失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 安装已下载的更新并退出应用 */
|
||||
export function installUpdateNow(): void {
|
||||
if (!platformUpdater) {
|
||||
logger.warn('updater', 'Updater 未初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('updater', '开始安装更新并退出应用');
|
||||
setImmediate(() => {
|
||||
platformUpdater!.quitAndInstall(false, true);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// IPC 注册
|
||||
// ============================================================
|
||||
|
||||
export function registerUpdateIpcHandlers(): void {
|
||||
ipcMain.handle(RENDERER_TO_MAIN_UPDATER.CHECK_FOR_UPDATE, async () => {
|
||||
await checkForUpdatesManual();
|
||||
});
|
||||
|
||||
ipcMain.handle(RENDERER_TO_MAIN_UPDATER.DOWNLOAD_UPDATE, async () => {
|
||||
await downloadUpdate();
|
||||
});
|
||||
|
||||
ipcMain.on(RENDERER_TO_MAIN_UPDATER.INSTALL_UPDATE, () => {
|
||||
installUpdateNow();
|
||||
});
|
||||
}
|
||||
102
electron/main/updater/platform-updaters.ts
Normal file
102
electron/main/updater/platform-updaters.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// ============================================================
|
||||
// platform-updaters.ts — 平台特定 Updater
|
||||
//
|
||||
// 覆盖 getUpdateInfoAndProvider() 以注入 HeiXiuProvider。
|
||||
// 其他行为(NSIS 安装、签名校验等)完全由父类处理。
|
||||
// ============================================================
|
||||
|
||||
import {
|
||||
NsisUpdater,
|
||||
MacUpdater,
|
||||
AppImageUpdater,
|
||||
Provider,
|
||||
} from 'electron-updater';
|
||||
import type { UpdateInfo } from 'builder-util-runtime';
|
||||
|
||||
import type { HeiXiuProvider } from './provider';
|
||||
|
||||
// ---------- Windows NSIS ----------
|
||||
|
||||
export class HeiXiuNsisUpdater extends NsisUpdater {
|
||||
private heiXiuProvider: HeiXiuProvider;
|
||||
|
||||
constructor(heiXiuProvider: HeiXiuProvider) {
|
||||
// 传 undefined 跳过内置 Provider 创建,由我们自己注入
|
||||
super(undefined);
|
||||
this.heiXiuProvider = heiXiuProvider;
|
||||
this.autoDownload = false; // 由我们手动控制流程,保持 IPC 兼容
|
||||
this.autoInstallOnAppQuit = false;
|
||||
this.forceDevUpdateConfig = true; // 绕过 electron-updater 的 dev 模式检查
|
||||
}
|
||||
|
||||
/** @internal — 注入自定义 Provider */
|
||||
protected override async getUpdateInfoAndProvider(): Promise<{
|
||||
info: UpdateInfo;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
provider: Provider<any>;
|
||||
}> {
|
||||
const info = await this.heiXiuProvider.getLatestVersion();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { info, provider: this.heiXiuProvider as Provider<any> };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- macOS ----------
|
||||
|
||||
export class HeiXiuMacUpdater extends MacUpdater {
|
||||
private heiXiuProvider: HeiXiuProvider;
|
||||
|
||||
constructor(heiXiuProvider: HeiXiuProvider) {
|
||||
super(undefined);
|
||||
this.heiXiuProvider = heiXiuProvider;
|
||||
this.autoDownload = false;
|
||||
this.autoInstallOnAppQuit = false;
|
||||
}
|
||||
|
||||
protected override async getUpdateInfoAndProvider(): Promise<{
|
||||
info: UpdateInfo;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
provider: Provider<any>;
|
||||
}> {
|
||||
const info = await this.heiXiuProvider.getLatestVersion();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { info, provider: this.heiXiuProvider as Provider<any> };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Linux AppImage ----------
|
||||
|
||||
export class HeiXiuAppImageUpdater extends AppImageUpdater {
|
||||
private heiXiuProvider: HeiXiuProvider;
|
||||
|
||||
constructor(heiXiuProvider: HeiXiuProvider) {
|
||||
super(null);
|
||||
this.heiXiuProvider = heiXiuProvider;
|
||||
this.autoDownload = false;
|
||||
this.autoInstallOnAppQuit = false;
|
||||
}
|
||||
|
||||
protected override async getUpdateInfoAndProvider(): Promise<{
|
||||
info: UpdateInfo;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
provider: Provider<any>;
|
||||
}> {
|
||||
const info = await this.heiXiuProvider.getLatestVersion();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { info, provider: this.heiXiuProvider as Provider<any> };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 工厂函数 ----------
|
||||
|
||||
/** 工厂:按平台创建对应的 Updater 实例 */
|
||||
export function createPlatformUpdater(provider: HeiXiuProvider) {
|
||||
switch (process.platform) {
|
||||
case 'win32':
|
||||
return new HeiXiuNsisUpdater(provider);
|
||||
case 'darwin':
|
||||
return new HeiXiuMacUpdater(provider);
|
||||
default:
|
||||
return new HeiXiuAppImageUpdater(provider);
|
||||
}
|
||||
}
|
||||
172
electron/main/updater/provider.ts
Normal file
172
electron/main/updater/provider.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
// ============================================================
|
||||
// provider.ts — 自定义更新 Provider
|
||||
//
|
||||
// 封装与自有版本检查 API 的交互逻辑,
|
||||
// 返回 electron-updater 格式的 UpdateInfo。
|
||||
// ============================================================
|
||||
|
||||
import { app } from 'electron';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { URL } from 'node:url';
|
||||
import { Provider } from 'electron-updater';
|
||||
import type { ProviderRuntimeOptions } from 'electron-updater/out/providers/Provider';
|
||||
import type { UpdateInfo, UpdateFileInfo } from 'builder-util-runtime';
|
||||
import type { ResolvedUpdateFileInfo } from 'electron-updater/out/types';
|
||||
|
||||
import type { UpdateCheckResult } from '../../../shared/types';
|
||||
import { APP_VERSION } from '../../../shared/constants/version';
|
||||
import { logger } from '../logger';
|
||||
import { netRequest } from '../net-request';
|
||||
|
||||
// ---------- 构建时注入的渠道信息 ----------
|
||||
|
||||
/** 构建渠道(由 build.mjs 通过 VITE_BUILD_CHANNEL 注入,Vite 编译时内联) */
|
||||
export const BUILD_CHANNEL: string =
|
||||
(typeof import.meta !== 'undefined' && (import.meta as unknown as Record<string, unknown>).env
|
||||
? ((import.meta as unknown as Record<string, unknown>).env as Record<string, string>).VITE_BUILD_CHANNEL
|
||||
: undefined)
|
||||
|| process.env.VITE_BUILD_CHANNEL
|
||||
|| 'stable';
|
||||
|
||||
/** 构建版本类型(由 build.mjs 通过 VITE_BUILD_EDITION 注入) */
|
||||
export const BUILD_EDITION: string =
|
||||
(typeof import.meta !== 'undefined' && (import.meta as unknown as Record<string, unknown>).env
|
||||
? ((import.meta as unknown as Record<string, unknown>).env as Record<string, string>).VITE_BUILD_EDITION
|
||||
: undefined)
|
||||
|| process.env.VITE_BUILD_EDITION
|
||||
|| 'personal';
|
||||
|
||||
// ---------- 设备指纹(稳定的机器标识,用于灰度分组)----------
|
||||
|
||||
let _deviceFingerprint: string | null = null;
|
||||
|
||||
/** 获取设备指纹(基于机器特定信息的 SHA-256 前 16 位) */
|
||||
export function getDeviceFingerprint(): string {
|
||||
if (_deviceFingerprint) return _deviceFingerprint;
|
||||
|
||||
try {
|
||||
const parts = [
|
||||
process.platform,
|
||||
process.arch,
|
||||
app.getPath('home'),
|
||||
];
|
||||
_deviceFingerprint = createHash('sha256')
|
||||
.update(parts.join('|'))
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
} catch {
|
||||
_deviceFingerprint = 'unknown-device';
|
||||
}
|
||||
|
||||
return _deviceFingerprint;
|
||||
}
|
||||
|
||||
// ---------- 配置 ----------
|
||||
|
||||
export function getApiBaseUrl(): string {
|
||||
const url = (process.env.UPDATE_API_URL || 'https://www.heixiu.com').trim();
|
||||
console.log(`[updater] UPDATE_API_URL=${process.env.UPDATE_API_URL} → 使用: ${url}`);
|
||||
return url;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HeiXiuProvider
|
||||
// ============================================================
|
||||
|
||||
export class HeiXiuProvider extends Provider<UpdateInfo> {
|
||||
private apiBaseUrl: string;
|
||||
/** 短时缓存:避免 checkForUpdates 预检 + electron-updater 内部调用导致两次 API 请求 */
|
||||
private _cachedResult: { data: UpdateInfo; ts: number } | null = null;
|
||||
|
||||
constructor(runtimeOptions: ProviderRuntimeOptions) {
|
||||
super(runtimeOptions);
|
||||
this.apiBaseUrl = getApiBaseUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用自有版本检查 API,返回 electron-updater 格式的 UpdateInfo。
|
||||
* blockMapSize > 0 时 electron-updater 自动启用差分下载。
|
||||
* 无更新时返回当前版本号(APP_VERSION)。
|
||||
*/
|
||||
async getLatestVersion(): Promise<UpdateInfo> {
|
||||
// 短时缓存(500ms):预检和 electron-updater 内部调用共享同一结果
|
||||
if (this._cachedResult && Date.now() - this._cachedResult.ts < 500) {
|
||||
return this._cachedResult.data;
|
||||
}
|
||||
const data = await this._doGetLatestVersion();
|
||||
this._cachedResult = { data, ts: Date.now() };
|
||||
return data;
|
||||
}
|
||||
|
||||
private async _doGetLatestVersion(): Promise<UpdateInfo> {
|
||||
const { data } = await netRequest.get<{ code: number; data?: UpdateCheckResult } & UpdateCheckResult>(
|
||||
'/api/v1/update/check',
|
||||
{
|
||||
baseURL: this.apiBaseUrl,
|
||||
params: {
|
||||
platform: process.platform,
|
||||
version: APP_VERSION,
|
||||
channel: BUILD_CHANNEL,
|
||||
edition: BUILD_EDITION,
|
||||
deviceFingerprint: getDeviceFingerprint(),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// 兼容两种 API 响应格式
|
||||
const result: UpdateCheckResult =
|
||||
(data as { data?: UpdateCheckResult }).data ?? (data as UpdateCheckResult);
|
||||
|
||||
// 无更新 → 返回当前版本号
|
||||
if (!result.hasUpdate) {
|
||||
return {
|
||||
version: APP_VERSION,
|
||||
files: [],
|
||||
path: '',
|
||||
sha512: '',
|
||||
releaseDate: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const file: UpdateFileInfo = {
|
||||
url: result.downloadUrl,
|
||||
sha512: result.sha512,
|
||||
size: result.fileSize,
|
||||
};
|
||||
|
||||
// 后端 API 返回 blockMapSize 时启用增量更新
|
||||
if (result.blockMapSize) {
|
||||
file.blockMapSize = result.blockMapSize;
|
||||
}
|
||||
|
||||
logger.info('updater', 'Provider 获取到新版本', {
|
||||
latestVersion: result.latestVersion,
|
||||
hasBlockmap: file.blockMapSize != null,
|
||||
});
|
||||
|
||||
return {
|
||||
version: result.latestVersion,
|
||||
files: [file],
|
||||
path: result.downloadUrl,
|
||||
sha512: result.sha512,
|
||||
releaseDate: result.releaseDate,
|
||||
releaseNotes: result.changelog,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 UpdateInfo 中的相对路径解析为完整的下载 URL。
|
||||
* electron-updater 在下载前调用此方法。
|
||||
*/
|
||||
resolveFiles(updateInfo: UpdateInfo): Array<ResolvedUpdateFileInfo> {
|
||||
return updateInfo.files.map((f) => {
|
||||
// 使用 downloadUrl 作为 base URL 来解析文件路径
|
||||
const baseUrl = new URL(updateInfo.path);
|
||||
const fileUrl = new URL(f.url, baseUrl.origin);
|
||||
return {
|
||||
url: fileUrl,
|
||||
info: f,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,45 +1,39 @@
|
||||
import { ipcRenderer, contextBridge } from 'electron';
|
||||
import { BIDIRECTIONAL } from '../shared/constants/ipc-channels';
|
||||
// ============================================================
|
||||
// preload.ts — Electron 预加载脚本(入口)
|
||||
//
|
||||
// 职责:
|
||||
// - 调用各功能模块的 expose 函数,将安全 API 暴露给渲染进程
|
||||
// - 所有 API 通过 contextBridge 桥接,确保进程隔离安全
|
||||
//
|
||||
// 模块拆分:
|
||||
// - base.ts → ipcRenderer 基础通信
|
||||
// - safe-storage.ts → 加密/解密 API
|
||||
// - file-storage.ts → 文件读写 API
|
||||
// - app-runtime.ts → 运行时 API(窗口标题等)
|
||||
// - canvas.ts → 无限画布 API
|
||||
// ============================================================
|
||||
|
||||
// --------- Expose some API to the Renderer process ---------
|
||||
contextBridge.exposeInMainWorld('ipcRenderer', {
|
||||
on(...args: Parameters<typeof ipcRenderer.on>) {
|
||||
const [channel, listener] = args;
|
||||
return ipcRenderer.on(channel, (event, ...args) => listener(event, ...args));
|
||||
},
|
||||
off(...args: Parameters<typeof ipcRenderer.off>) {
|
||||
const [channel, ...omit] = args;
|
||||
return ipcRenderer.off(channel, ...omit);
|
||||
},
|
||||
send(...args: Parameters<typeof ipcRenderer.send>) {
|
||||
const [channel, ...omit] = args;
|
||||
return ipcRenderer.send(channel, ...omit);
|
||||
},
|
||||
invoke(...args: Parameters<typeof ipcRenderer.invoke>) {
|
||||
const [channel, ...omit] = args;
|
||||
return ipcRenderer.invoke(channel, ...omit);
|
||||
},
|
||||
});
|
||||
import {
|
||||
exposeBaseIpc,
|
||||
exposeSafeStorage,
|
||||
exposeFileStorage,
|
||||
exposeAppRuntime,
|
||||
exposeCanvas,
|
||||
} from './preload/index';
|
||||
|
||||
// --------- 安全加密 API(基于 Electron safeStorage)---------
|
||||
contextBridge.exposeInMainWorld('safeStorage', {
|
||||
/**
|
||||
* 加密明文字符串
|
||||
* @param plaintext - 待加密的明文
|
||||
* @returns Base64 编码的密文,失败返回 null
|
||||
*/
|
||||
async encrypt(plaintext: string): Promise<string | null> {
|
||||
const result = await ipcRenderer.invoke(BIDIRECTIONAL.SAFESTORAGE_ENCRYPT, plaintext);
|
||||
return result?.success ? result.data : null;
|
||||
},
|
||||
// --------- 按顺序暴露各模块 API ---------
|
||||
|
||||
/**
|
||||
* 解密 Base64 密文
|
||||
* @param encryptedBase64 - Base64 编码的密文
|
||||
* @returns 解密后的明文,失败返回 null
|
||||
*/
|
||||
async decrypt(encryptedBase64: string): Promise<string | null> {
|
||||
const result = await ipcRenderer.invoke(BIDIRECTIONAL.SAFESTORAGE_DECRYPT, encryptedBase64);
|
||||
return result?.success ? result.data : null;
|
||||
},
|
||||
});
|
||||
// 1. 基础 IPC 通信(ipcRenderer.on/off/send/invoke)
|
||||
exposeBaseIpc();
|
||||
|
||||
// 2. 安全加密 API(基于 Electron safeStorage)
|
||||
exposeSafeStorage();
|
||||
|
||||
// 3. 本地存储文件 API(主进程 fs 桥接)
|
||||
exposeFileStorage();
|
||||
|
||||
// 4. 应用运行时 API(窗口标题等)
|
||||
exposeAppRuntime();
|
||||
|
||||
// 5. 无限画布 API
|
||||
exposeCanvas();
|
||||
|
||||
22
electron/preload/app-runtime.ts
Normal file
22
electron/preload/app-runtime.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// ============================================================
|
||||
// preload/app-runtime.ts — 应用运行时 API
|
||||
//
|
||||
// 提供窗口标题、版本控制等运行时接口
|
||||
// 渲染进程通过 window.appRuntime 调用
|
||||
// ============================================================
|
||||
|
||||
import { ipcRenderer, contextBridge } from 'electron';
|
||||
import { RENDERER_TO_MAIN } from '../../shared/constants/ipc';
|
||||
|
||||
/** 应用运行时 API */
|
||||
export function exposeAppRuntime(): void {
|
||||
contextBridge.exposeInMainWorld('appRuntime', {
|
||||
/**
|
||||
* 登录/登出后更新窗口标题中的版控后缀
|
||||
* @param edition - 'personal' | 'enterprise' | null(null 清除后缀)
|
||||
*/
|
||||
setWindowEdition(edition: string | null): void {
|
||||
ipcRenderer.send(RENDERER_TO_MAIN.SET_WINDOW_TITLE, edition);
|
||||
},
|
||||
});
|
||||
}
|
||||
29
electron/preload/base.ts
Normal file
29
electron/preload/base.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// ============================================================
|
||||
// preload/base.ts — 基础 ipcRenderer 桥接
|
||||
//
|
||||
// 提供安全的 IPC 通信接口,暴露给渲染进程
|
||||
// ============================================================
|
||||
|
||||
import { ipcRenderer, contextBridge } from 'electron';
|
||||
|
||||
/** 基础 IPC 通信 API */
|
||||
export function exposeBaseIpc(): void {
|
||||
contextBridge.exposeInMainWorld('ipcRenderer', {
|
||||
on(...args: Parameters<typeof ipcRenderer.on>) {
|
||||
const [channel, listener] = args;
|
||||
return ipcRenderer.on(channel, (event, ...args) => listener(event, ...args));
|
||||
},
|
||||
off(...args: Parameters<typeof ipcRenderer.off>) {
|
||||
const [channel, ...omit] = args;
|
||||
return ipcRenderer.off(channel, ...omit);
|
||||
},
|
||||
send(...args: Parameters<typeof ipcRenderer.send>) {
|
||||
const [channel, ...omit] = args;
|
||||
return ipcRenderer.send(channel, ...omit);
|
||||
},
|
||||
invoke(...args: Parameters<typeof ipcRenderer.invoke>) {
|
||||
const [channel, ...omit] = args;
|
||||
return ipcRenderer.invoke(channel, ...omit);
|
||||
},
|
||||
});
|
||||
}
|
||||
124
electron/preload/canvas.ts
Normal file
124
electron/preload/canvas.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
// ============================================================
|
||||
// preload/canvas.ts — 无限画布 API
|
||||
//
|
||||
// 提供画布窗口、文件扫描、缩略图、拖拽等接口
|
||||
// 渲染进程通过 window.canvas 调用
|
||||
// ============================================================
|
||||
|
||||
import { ipcRenderer, contextBridge } from 'electron';
|
||||
import { BIDIRECTIONAL, RENDERER_TO_MAIN } from '../../shared/constants/ipc';
|
||||
|
||||
/** 无限画布 API */
|
||||
export function exposeCanvas(): void {
|
||||
contextBridge.exposeInMainWorld('canvas', {
|
||||
/** 打开画布窗口(主进程创建新 BrowserWindow) */
|
||||
openWindow(projectPath?: string): void {
|
||||
ipcRenderer.send(RENDERER_TO_MAIN.OPEN_CANVAS_WINDOW, { projectPath });
|
||||
},
|
||||
|
||||
/** 扫描目录,返回匹配的文件列表 */
|
||||
scanDirectory(params: {
|
||||
roots: string[];
|
||||
extensions: string[];
|
||||
recursive: boolean;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>;
|
||||
error?: string;
|
||||
}> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_DIRECTORY, params);
|
||||
},
|
||||
|
||||
/** 扫描项目(主进程内部完成 buildScanRoots + walkDirectory,推荐使用) */
|
||||
scanProject(params: {
|
||||
projectPath: string;
|
||||
stage: string;
|
||||
assetTypeFilter: string;
|
||||
shotQuery: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>;
|
||||
error?: string;
|
||||
}> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_PROJECT, params);
|
||||
},
|
||||
|
||||
/** 读取旁加载 JSON 元数据 */
|
||||
readSidecar(params: { filePath: string }): Promise<{ success: boolean; data: object | null }> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_READ_SIDECAR, params);
|
||||
},
|
||||
|
||||
/** 写入旁加载 JSON 元数据 */
|
||||
writeSidecar(params: { filePath: string; metadata: object }): Promise<{ success: boolean; error?: string }> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_WRITE_SIDECAR, params);
|
||||
},
|
||||
|
||||
/** 生成缩略图(JPEG base64) */
|
||||
generateThumbnail(params: { filePath: string; size: number }): Promise<{
|
||||
success: boolean;
|
||||
data: { data: string; mimeType: string; width: number; height: number };
|
||||
error?: string;
|
||||
}> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_GENERATE_THUMBNAIL, params);
|
||||
},
|
||||
|
||||
/** 复制文件到目标目录(导入操作) */
|
||||
copyFile(params: { src: string; destDir: string; fileName: string }): Promise<{
|
||||
success: boolean;
|
||||
data: string;
|
||||
error?: string;
|
||||
}> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_COPY_FILE, params);
|
||||
},
|
||||
|
||||
/** 列出目录下的子文件夹(非递归,排除隐藏) */
|
||||
listSubdirs(params: { parentPath: string }): Promise<{ success: boolean; data: string[]; error?: string }> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_LIST_SUBDIRS, params);
|
||||
},
|
||||
|
||||
/** 扫描阶段文件(单个 episode/shot/stage 组合) */
|
||||
scanStageFiles(params: { projectPath: string; episode: string; shot: string; stage: string }): Promise<{
|
||||
success: boolean;
|
||||
data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>;
|
||||
error?: string;
|
||||
}> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_STAGE_FILES, params);
|
||||
},
|
||||
|
||||
/** 扫描资产文件(深度递归扫描 assets/ 目录) */
|
||||
scanAssetFiles(params: { projectPath: string; assetTypeFilter: string }): Promise<{
|
||||
success: boolean;
|
||||
data: Array<{ path: string; fileName: string; ext: string; fileSize: number; modifiedAt: string }>;
|
||||
error?: string;
|
||||
}> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_SCAN_ASSET_FILES, params);
|
||||
},
|
||||
|
||||
/** 创建目录 */
|
||||
createDirectory(params: { parentPath: string; dirName: string }): Promise<{
|
||||
success: boolean;
|
||||
data: string;
|
||||
error?: string;
|
||||
}> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_CREATE_DIRECTORY, params);
|
||||
},
|
||||
|
||||
/** 将本地文件路径转换为可通过 local-media:// 协议加载的 URL */
|
||||
getMediaUrl(filePath: string): string {
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
// UNC 路径(//server/share/…)开头 // 会被 URL 解析器视为 authority,
|
||||
// 必须用 local-media://<host>/<path> 格式而非 local-media:/// 前缀
|
||||
if (normalized.startsWith('//')) {
|
||||
// normalized = "//192.168.110.250/share/path"
|
||||
return 'local-media://' + normalized.slice(2);
|
||||
}
|
||||
// 本地路径:C:/path → local-media:///C:/path
|
||||
return 'local-media:///' + normalized;
|
||||
},
|
||||
|
||||
/** 启动原生文件拖拽(从缩略图拖放媒体文件到外部应用) */
|
||||
startDrag(params: { filePath: string; fileName: string }): Promise<{ success: boolean; error?: string }> {
|
||||
return ipcRenderer.invoke(BIDIRECTIONAL.CANVAS_START_DRAG, params);
|
||||
},
|
||||
});
|
||||
}
|
||||
94
electron/preload/file-storage.ts
Normal file
94
electron/preload/file-storage.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
// ============================================================
|
||||
// preload/file-storage.ts — 本地存储文件 API(主进程 fs 桥接)
|
||||
//
|
||||
// 提供文件读写、目录管理、下载等接口
|
||||
// 渲染进程通过 window.fileStorage 调用
|
||||
// ============================================================
|
||||
|
||||
import { ipcRenderer, contextBridge } from 'electron';
|
||||
import { BIDIRECTIONAL } from '../../shared/constants/ipc';
|
||||
|
||||
/** 本地存储文件 API */
|
||||
export function exposeFileStorage(): void {
|
||||
contextBridge.exposeInMainWorld('fileStorage', {
|
||||
/** 获取 heixiu-data 根目录路径 */
|
||||
async getDataDir(): Promise<string | null> {
|
||||
const r = await ipcRenderer.invoke(BIDIRECTIONAL.GET_DATA_DIR);
|
||||
return r?.success ? r.data : null;
|
||||
},
|
||||
|
||||
/** 写入文件(Base64 → 磁盘),返回写入的绝对路径 */
|
||||
async writeFile(relativePath: string, data: string, encoding?: 'base64' | 'utf8'): Promise<string | null> {
|
||||
const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_WRITE, { relativePath, data, encoding });
|
||||
return r?.success ? r.data : null;
|
||||
},
|
||||
|
||||
/** 读取文件(磁盘 → Base64) */
|
||||
async readFile(relativePath: string): Promise<string | null> {
|
||||
const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_READ, { relativePath });
|
||||
return r?.success ? r.data : null;
|
||||
},
|
||||
|
||||
/** 删除文件 */
|
||||
async deleteFile(relativePath: string): Promise<boolean> {
|
||||
const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_DELETE, { relativePath });
|
||||
return r?.success ?? false;
|
||||
},
|
||||
|
||||
/** 递归创建目录 */
|
||||
async makeDir(relativePath: string): Promise<string | null> {
|
||||
const r = await ipcRenderer.invoke(BIDIRECTIONAL.MAKE_DIR, { relativePath });
|
||||
return r?.success ? r.data : null;
|
||||
},
|
||||
|
||||
/** 在文件管理器中显示文件(绝对路径) */
|
||||
async showItemInFolder(filePath: string): Promise<boolean> {
|
||||
const r = await ipcRenderer.invoke(BIDIRECTIONAL.SHOW_ITEM_IN_FOLDER, { filePath });
|
||||
return r?.success ?? false;
|
||||
},
|
||||
|
||||
/** 获取文件大小(字节) */
|
||||
async fileSize(relativePath: string): Promise<number | null> {
|
||||
const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_SIZE, { relativePath });
|
||||
return r?.success ? r.data : null;
|
||||
},
|
||||
|
||||
/** 检查文件/目录是否存在 */
|
||||
async fileExists(relativePath: string): Promise<boolean> {
|
||||
const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_EXISTS, { relativePath });
|
||||
return r?.success ? r.data : false;
|
||||
},
|
||||
|
||||
/** 绝对路径文件写入(绕过 heixiu-data 沙箱) */
|
||||
async writeFileAbsolute(absolutePath: string, data: string, encoding?: 'base64' | 'utf8'): Promise<string | null> {
|
||||
const r = await ipcRenderer.invoke(BIDIRECTIONAL.FILE_WRITE_ABSOLUTE, { absolutePath, data, encoding });
|
||||
return r?.success ? r.data : null;
|
||||
},
|
||||
|
||||
/** 绝对路径目录创建(绕过 heixiu-data 沙箱) */
|
||||
async makeDirAbsolute(absolutePath: string): Promise<string | null> {
|
||||
const r = await ipcRenderer.invoke(BIDIRECTIONAL.MAKE_DIR_ABSOLUTE, { absolutePath });
|
||||
return r?.success ? r.data : null;
|
||||
},
|
||||
|
||||
/** 主进程下载文件到绝对路径(绕过浏览器 CORS 限制),失败时 reject 并携带错误信息 */
|
||||
async downloadToPath(url: string, destPath: string, headers?: Record<string, string>): Promise<string> {
|
||||
const r = await ipcRenderer.invoke(BIDIRECTIONAL.DOWNLOAD_TO_PATH, { url, destPath, headers });
|
||||
if (r?.success) return r.data;
|
||||
throw new Error(r?.error || '主进程下载失败');
|
||||
},
|
||||
|
||||
/** 获取系统下载目录路径 */
|
||||
async getDownloadsPath(): Promise<string | null> {
|
||||
const r = await ipcRenderer.invoke(BIDIRECTIONAL.GET_DOWNLOADS_PATH);
|
||||
return r?.success ? r.data : null;
|
||||
},
|
||||
|
||||
/** 生成图片缩略图(读取原图 → 缩放 → JPEG 写入目标路径) */
|
||||
async generateThumbnail(srcPath: string, destPath: string, maxWidth?: number, quality?: number): Promise<string> {
|
||||
const r = await ipcRenderer.invoke(BIDIRECTIONAL.GENERATE_THUMBNAIL, { srcPath, destPath, maxWidth, quality });
|
||||
if (r?.success) return r.data;
|
||||
throw new Error(r?.error || '缩略图生成失败');
|
||||
},
|
||||
});
|
||||
}
|
||||
11
electron/preload/index.ts
Normal file
11
electron/preload/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// ============================================================
|
||||
// preload/index.ts — 汇总导出所有 preload 模块
|
||||
//
|
||||
// 在主 preload.ts 中调用各模块的 expose 函数
|
||||
// ============================================================
|
||||
|
||||
export { exposeBaseIpc } from './base';
|
||||
export { exposeSafeStorage } from './safe-storage';
|
||||
export { exposeFileStorage } from './file-storage';
|
||||
export { exposeAppRuntime } from './app-runtime';
|
||||
export { exposeCanvas } from './canvas';
|
||||
33
electron/preload/safe-storage.ts
Normal file
33
electron/preload/safe-storage.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// ============================================================
|
||||
// preload/safe-storage.ts — 安全加密 API(基于 Electron safeStorage)
|
||||
//
|
||||
// 提供加密/解密接口,渲染进程通过 window.safeStorage 调用
|
||||
// ============================================================
|
||||
|
||||
import { ipcRenderer, contextBridge } from 'electron';
|
||||
import { BIDIRECTIONAL } from '../../shared/constants/ipc';
|
||||
|
||||
/** 安全加密 API */
|
||||
export function exposeSafeStorage(): void {
|
||||
contextBridge.exposeInMainWorld('safeStorage', {
|
||||
/**
|
||||
* 加密明文字符串
|
||||
* @param plaintext - 待加密的明文
|
||||
* @returns Base64 编码的密文,失败返回 null
|
||||
*/
|
||||
async encrypt(plaintext: string): Promise<string | null> {
|
||||
const result = await ipcRenderer.invoke(BIDIRECTIONAL.SAFESTORAGE_ENCRYPT, plaintext);
|
||||
return result?.success ? result.data : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* 解密 Base64 密文
|
||||
* @param encryptedBase64 - Base64 编码的密文
|
||||
* @returns 解密后的明文,失败返回 null
|
||||
*/
|
||||
async decrypt(encryptedBase64: string): Promise<string | null> {
|
||||
const result = await ipcRenderer.invoke(BIDIRECTIONAL.SAFESTORAGE_DECRYPT, encryptedBase64);
|
||||
return result?.success ? result.data : null;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -7,15 +7,16 @@
|
||||
<!-- <meta name="viewport" content="width=device-width, initial-scale=1.0" />-->
|
||||
<meta http-equiv="Content-Security-Policy" content="
|
||||
default-src 'self';
|
||||
script-src 'self' 'unsafe-inline';
|
||||
script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval';
|
||||
style-src 'self' 'unsafe-inline';
|
||||
img-src 'self' data: https://rh-images-1252422369.cos.ap-beijing.myqcloud.com https://www.heixiu.net ; ;
|
||||
connect-src 'self' http://8.160.179.64:8000 http://localhost:5173 ws://localhost:5173 https://www.heixiu.net ;
|
||||
img-src 'self' data: https: ;
|
||||
media-src 'self' https: ;
|
||||
connect-src 'self' http://8.160.179.64:8000 http://localhost:5173 ws://localhost:5173 https://www.heixiu.net https: ;
|
||||
">
|
||||
<title>船长·HeiXiu</title>
|
||||
</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()` 计算审核状态角标颜色(绿=全通过、黄=待审核、红=需返工)。
|
||||
709
package-lock.json
generated
709
package-lock.json
generated
@@ -1,19 +1,22 @@
|
||||
{
|
||||
"name": "ele-heixiu",
|
||||
"version": "0.0.13",
|
||||
"version": "0.0.18",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ele-heixiu",
|
||||
"version": "0.0.13",
|
||||
"version": "0.0.18",
|
||||
"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"
|
||||
"react-dom": "^19.2.7",
|
||||
"sharp": "^0.35.2",
|
||||
"sql.js": "^1.14.1",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.60.0",
|
||||
@@ -22,6 +25,8 @@
|
||||
"@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",
|
||||
@@ -571,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,
|
||||
@@ -1066,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",
|
||||
@@ -2594,6 +3157,13 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/emscripten": {
|
||||
"version": "1.41.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
|
||||
"integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/esrecurse": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||
@@ -2675,7 +3245,7 @@
|
||||
"version": "19.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.16.tgz",
|
||||
"integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -2701,6 +3271,27 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sharp": {
|
||||
"version": "0.31.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/sharp/-/sharp-0.31.1.tgz",
|
||||
"integrity": "sha512-5nWwamN9ZFHXaYEincMSuza8nNfOof8nmO+mcI+Agx1uMUk4/pQnNIcix+9rLPXzKrm1pS34+6WRDbDV0Jn7ag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sql.js": {
|
||||
"version": "1.4.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/sql.js/-/sql.js-1.4.11.tgz",
|
||||
"integrity": "sha512-QXIx38p2ZThJaK9vP5ZdqdlRe1FG9I8SmCZOS7FHfB/2qPAjZwkL7/vlfPg6N/oWHuuOaGg/P/IRwfP2W0kWVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/emscripten": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/verror": {
|
||||
"version": "1.10.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz",
|
||||
@@ -4194,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"
|
||||
@@ -5080,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"
|
||||
@@ -7438,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"
|
||||
@@ -7489,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",
|
||||
@@ -7599,6 +8232,12 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/sql.js": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz",
|
||||
"integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stat-mode": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz",
|
||||
@@ -7881,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
|
||||
},
|
||||
@@ -7920,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": {
|
||||
@@ -8283,6 +8921,35 @@
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.14",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",
|
||||
"integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=18.0.0",
|
||||
"use-sync-external-store": ">=1.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"use-sync-external-store": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
90
package.json
90
package.json
@@ -1,22 +1,29 @@
|
||||
{
|
||||
"name": "ele-heixiu",
|
||||
"private": true,
|
||||
"version": "0.0.18",
|
||||
"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.mjs --win --personal --build",
|
||||
"build:win": "node build.mjs --win --personal --build",
|
||||
"build:win:enterprise": "node build.mjs --win --enterprise --build",
|
||||
"build:mac": "node build.mjs --mac --personal --build",
|
||||
"build:mac:enterprise": "node build.mjs --mac --enterprise --build",
|
||||
"build:linux": "node build.mjs --linux --personal --build",
|
||||
"build:linux:enterprise": "node build.mjs --linux --enterprise --build",
|
||||
"build:clean": "node scripts/clean.mjs",
|
||||
"upload:oss": "node scripts/upload-oss.mjs",
|
||||
"publish:release": "node scripts/publish-release.mjs",
|
||||
"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\"",
|
||||
@@ -31,61 +38,10 @@
|
||||
"axios": "^1.16.1",
|
||||
"electron-updater": "^6.8.3",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7"
|
||||
},
|
||||
"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}-${env.EDITION}-Setup.${ext}"
|
||||
},
|
||||
"mac": {
|
||||
"icon": "src/assets/logo/heixiu.icns",
|
||||
"target": [
|
||||
"dmg"
|
||||
],
|
||||
"artifactName": "${productName}-Mac-${version}-${env.EDITION}-Installer.${ext}"
|
||||
},
|
||||
"linux": {
|
||||
"icon": "src/assets/logo/HX.png",
|
||||
"target": [
|
||||
"AppImage"
|
||||
],
|
||||
"artifactName": "${productName}-Linux-${version}-${env.EDITION}.${ext}"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"deleteAppDataOnUninstall": false,
|
||||
"differentialPackage": true,
|
||||
"installerIcon": "src/assets/logo/heixiu.ico",
|
||||
"uninstallerIcon": "src/assets/logo/heixiu.ico"
|
||||
}
|
||||
"react-dom": "^19.2.7",
|
||||
"sharp": "^0.35.2",
|
||||
"sql.js": "^1.14.1",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.60.0",
|
||||
@@ -94,6 +50,8 @@
|
||||
"@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",
|
||||
|
||||
BIN
public/sql-wasm-browser.wasm
Normal file
BIN
public/sql-wasm-browser.wasm
Normal file
Binary file not shown.
BIN
public/sql-wasm.wasm
Normal file
BIN
public/sql-wasm.wasm
Normal file
Binary file not shown.
@@ -1,248 +0,0 @@
|
||||
// ============================================================
|
||||
// 上传安装包 + blockmap 文件到阿里云 OSS
|
||||
//
|
||||
// 用法:
|
||||
// node scripts/upload-oss.mjs <platform> <edition> [version]
|
||||
//
|
||||
// 环境变量(使用 Node.js 直传时需要):
|
||||
// OSS_ENDPOINT — OSS endpoint(如 oss-cn-hangzhou.aliyuncs.com)
|
||||
// OSS_BUCKET — Bucket 名称
|
||||
// OSS_ACCESS_KEY_ID — AccessKey ID
|
||||
// OSS_ACCESS_KEY_SECRET — AccessKey Secret
|
||||
// OSS_BASE_PATH — Bucket 内基路径,默认 releases
|
||||
//
|
||||
// 不传 OSS 环境变量时 → 打印手动上传命令(ossutil / 控制台)
|
||||
//
|
||||
// 示例:
|
||||
// # 仅打印上传命令
|
||||
// node scripts/upload-oss.mjs win32 personal 0.1.0
|
||||
//
|
||||
// # 直接上传
|
||||
// OSS_BUCKET=heixiu OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com \
|
||||
// OSS_ACCESS_KEY_ID=xxx OSS_ACCESS_KEY_SECRET=xxx \
|
||||
// node scripts/upload-oss.mjs win32 personal 0.1.0
|
||||
// ============================================================
|
||||
|
||||
import {readFileSync, readdirSync} from 'node:fs';
|
||||
import {join, dirname} from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {createHmac} from 'node:crypto';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, '..');
|
||||
|
||||
// ---------- 参数 ----------
|
||||
|
||||
const platform = process.argv[2];
|
||||
const edition = process.argv[3];
|
||||
|
||||
if (!platform || !edition) {
|
||||
console.error('用法: node scripts/upload-oss.mjs <platform> <edition> [version]');
|
||||
console.error('示例: node scripts/upload-oss.mjs win32 personal 0.1.0');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
|
||||
const version = process.argv[4] || pkg.version;
|
||||
|
||||
// ---------- 查找安装包 ----------
|
||||
|
||||
const releaseDir = join(ROOT, 'release', version);
|
||||
|
||||
let installerName;
|
||||
try {
|
||||
const all = readdirSync(releaseDir);
|
||||
const exts = platform === 'win32' ? ['.exe'] : platform === 'darwin' ? ['.dmg'] : ['.AppImage'];
|
||||
installerName = all.find((f) => exts.some((e) => f.endsWith(e)));
|
||||
if (!installerName) {
|
||||
console.error(`错误: ${releaseDir} — 未找到安装包`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`错误: ${releaseDir} — ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const installerPath = join(releaseDir, installerName);
|
||||
|
||||
// ---------- 查找 blockmap 文件 ----------
|
||||
|
||||
let blockmapName = null;
|
||||
let blockmapPath = null;
|
||||
try {
|
||||
const all = readdirSync(releaseDir);
|
||||
blockmapName = all.find((f) => f.endsWith('.blockmap'));
|
||||
if (blockmapName) {
|
||||
blockmapPath = join(releaseDir, blockmapName);
|
||||
}
|
||||
} catch {
|
||||
// blockmap 不存在不影响主流程
|
||||
}
|
||||
|
||||
// ---------- OSS 配置 ----------
|
||||
|
||||
const {
|
||||
OSS_ENDPOINT,
|
||||
OSS_BUCKET,
|
||||
OSS_ACCESS_KEY_ID,
|
||||
OSS_ACCESS_KEY_SECRET,
|
||||
} = process.env;
|
||||
|
||||
const basePath = (process.env.OSS_BASE_PATH || 'releases').replace(/^\/+|\/+$/g, '');
|
||||
const objectKey = `${basePath}/${version}/${installerName}`;
|
||||
|
||||
// ============================================================
|
||||
// 模式 A:无 OSS 配置 → 打印手动命令
|
||||
// ============================================================
|
||||
|
||||
if (!OSS_ENDPOINT || !OSS_BUCKET || !OSS_ACCESS_KEY_ID) {
|
||||
const exampleBucket = OSS_BUCKET || '<bucket>';
|
||||
const exampleEndpoint = OSS_ENDPOINT || '<endpoint>';
|
||||
const downloadUrl = `https://${exampleBucket}.${exampleEndpoint}/${objectKey}`;
|
||||
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('📦 上传安装包 + blockmap(手动)');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log(`文件: ${installerPath}`);
|
||||
console.log(`大小: ${(readFileSync(installerPath).length / 1024 / 1024).toFixed(2)} MB\n`);
|
||||
|
||||
if (blockmapPath) {
|
||||
console.log(`Blockmap: ${blockmapPath}`);
|
||||
console.log(`Blockmap 大小: ${(readFileSync(blockmapPath).length / 1024).toFixed(2)} KB\n`);
|
||||
}
|
||||
|
||||
console.log('方式 1 — ossutil CLI(推荐):');
|
||||
console.log(` ossutil cp "${installerPath}" oss://${exampleBucket}/${objectKey}`);
|
||||
if (blockmapPath) {
|
||||
const blockmapKey = `${basePath}/${version}/${blockmapName}`;
|
||||
console.log(` ossutil cp "${blockmapPath}" oss://${exampleBucket}/${blockmapKey}`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
console.log('方式 2 — 阿里云控制台:');
|
||||
console.log(` 登录 OSS 控制台 → ${exampleBucket} → 上传 → 目标路径: ${objectKey}`);
|
||||
if (blockmapPath) {
|
||||
console.log(` 同时上传 ${blockmapName} 到相同目录`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
const blockmapUrl = blockmapPath
|
||||
? `https://${exampleBucket}.${exampleEndpoint}/${basePath}/${version}/${blockmapName}`
|
||||
: '';
|
||||
|
||||
console.log('上传后执行以下命令,用实际 URL 重新生成 info:');
|
||||
if (blockmapUrl) {
|
||||
console.log(` node scripts/generate-update-info.mjs ${platform} ${edition} ${version} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`);
|
||||
console.log('');
|
||||
console.log('或直接发布到后端 API:');
|
||||
console.log(` node scripts/publish-release.mjs ${platform} ${edition} ${version} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`);
|
||||
} else {
|
||||
console.log(` node scripts/generate-update-info.mjs ${platform} ${edition} ${version} --url "${downloadUrl}"`);
|
||||
console.log('');
|
||||
console.log('或直接发布到后端 API:');
|
||||
console.log(` node scripts/publish-release.mjs ${platform} ${edition} ${version} --url "${downloadUrl}"`);
|
||||
}
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 模式 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} ${edition} ${version} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`);
|
||||
console.log('\n或直接发布到后端 API:');
|
||||
console.log(` node scripts/publish-release.mjs ${platform} ${edition} ${version} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`);
|
||||
} else {
|
||||
console.log(` node scripts/generate-update-info.mjs ${platform} ${edition} ${version} --url "${downloadUrl}"`);
|
||||
console.log('\n或直接发布到后端 API:');
|
||||
console.log(` node scripts/publish-release.mjs ${platform} ${edition} ${version} --url "${downloadUrl}"`);
|
||||
}
|
||||
@@ -6,25 +6,18 @@
|
||||
import type { Edition, Platform } from '../types';
|
||||
import { APP_VERSION } from './version';
|
||||
|
||||
/** 应用显示名称 */
|
||||
export const APP_NAME = '船长·HeiXiu';
|
||||
/** 应用显示名称(文件/标题通用前缀) */
|
||||
export const APP_NAME = '船长-HeiXiu';
|
||||
|
||||
// APP_VERSION 从 ./version.ts 导入(读取 package.json),不再硬编码
|
||||
|
||||
// ============================================================
|
||||
// 版本类型(企业版 / 个人版)
|
||||
//
|
||||
// 注意:edition 由后端 account_type 运行时决定,不由构建变量控制。
|
||||
// 不再需要 parseEdition() —— 构建时 EDITION 环境变量已移除。
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 解析版本类型字符串
|
||||
* @param raw - 环境变量值,如 "enterprise" / undefined
|
||||
* @returns 版本类型,默认 personal
|
||||
*/
|
||||
export function parseEdition(raw: string | undefined): Edition {
|
||||
if (raw === 'enterprise') return 'enterprise';
|
||||
return 'personal';
|
||||
}
|
||||
|
||||
/** 版本中文名称 */
|
||||
export function getEditionName(edition: Edition): string {
|
||||
return edition === 'enterprise' ? '企业版' : '个人版';
|
||||
@@ -42,18 +35,24 @@ function platformShortName(platform: Platform): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建完整窗口标题
|
||||
* 格式:船长·HeiXiu V{版本号} {平台}{版本}
|
||||
* 示例:"船长·HeiXiu V0.0.1 MAC企业版"
|
||||
* "船长·HeiXiu V0.0.1 Windows个人版"
|
||||
* 构建窗口标题
|
||||
*
|
||||
* 格式(无版控):船长-HeiXiu-{平台}-{版本号}
|
||||
* 示例:船长-HeiXiu-Windows-0.0.23
|
||||
*
|
||||
* 格式(含版控):船长-HeiXiu-{平台}-{版本号}·{版控}
|
||||
* 示例:船长-HeiXiu-Windows-0.0.23·企业版
|
||||
*
|
||||
* @param platform - 当前操作系统平台
|
||||
* @param edition - 版本类型
|
||||
* @param version - 可选覆盖版本号(默认 APP_VERSION)
|
||||
* @param edition - 可选版本类型(登录后追加,未登录不显示)
|
||||
*/
|
||||
export function buildWindowTitle(platform: Platform, edition: Edition, version?: string): string {
|
||||
export function buildWindowTitle(platform: Platform, version?: string, edition?: Edition): string {
|
||||
const ver = version ?? APP_VERSION;
|
||||
const os = platformShortName(platform);
|
||||
const edName = getEditionName(edition);
|
||||
return `${APP_NAME} V${ver} ${os}${edName}`;
|
||||
const base = `${APP_NAME}-${os}-${ver}`;
|
||||
if (edition) {
|
||||
return `${base}·${getEditionName(edition)}`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
@@ -1,49 +1,18 @@
|
||||
// ============================================================
|
||||
// IPC 通道名称常量 —— 避免魔法字符串
|
||||
// IPC 通道名称常量 — 兼容层(已拆分为 ipc/ 子模块)
|
||||
//
|
||||
// @deprecated 请使用新的模块化导入:
|
||||
// import { BIDIRECTIONAL } from '@/shared/constants/ipc';
|
||||
// import { BIDIRECTIONAL_STORAGE } from '@/shared/constants/ipc/storage';
|
||||
// ============================================================
|
||||
|
||||
/** 主进程 → 渲染进程 */
|
||||
export const MAIN_TO_RENDERER = {
|
||||
/** 主进程消息推送 */
|
||||
MAIN_PROCESS_MESSAGE: 'main-process-message',
|
||||
/** 主题变更通知 */
|
||||
THEME_CHANGED: 'theme-changed',
|
||||
/** 窗口聚焦通知 */
|
||||
WINDOW_FOCUSED: 'window-focused',
|
||||
/** 窗口失焦通知 */
|
||||
WINDOW_BLURRED: 'window-blurred',
|
||||
} as const;
|
||||
|
||||
/** 渲染进程 → 主进程 */
|
||||
export const RENDERER_TO_MAIN = {
|
||||
/** 打开子窗口 */
|
||||
OPEN_SUB_WINDOW: 'open-sub-window',
|
||||
/** 关闭子窗口 */
|
||||
CLOSE_SUB_WINDOW: 'close-sub-window',
|
||||
/** 设置窗口标题 */
|
||||
SET_WINDOW_TITLE: 'set-window-title',
|
||||
/** 获取平台信息 */
|
||||
GET_PLATFORM_INFO: 'get-platform-info',
|
||||
/** 最小化窗口 */
|
||||
MINIMIZE_WINDOW: 'minimize-window',
|
||||
/** 最大化/还原窗口 */
|
||||
TOGGLE_MAXIMIZE: 'toggle-maximize',
|
||||
/** 关闭窗口 */
|
||||
CLOSE_WINDOW: 'close-window',
|
||||
/** 渲染进程日志上报 */
|
||||
LOG_MESSAGE: 'log-message',
|
||||
} as const;
|
||||
|
||||
/** 双向通信通道 */
|
||||
export const BIDIRECTIONAL = {
|
||||
/** 读取应用配置 */
|
||||
READ_CONFIG: 'read-config',
|
||||
/** 写入应用配置 */
|
||||
WRITE_CONFIG: 'write-config',
|
||||
/** 文件对话框 */
|
||||
FILE_DIALOG: 'file-dialog',
|
||||
/** safeStorage 加密(明文 → Base64 密文) */
|
||||
SAFESTORAGE_ENCRYPT: 'safe-storage:encrypt',
|
||||
/** safeStorage 解密(Base64 密文 → 明文) */
|
||||
SAFESTORAGE_DECRYPT: 'safe-storage:decrypt',
|
||||
} as const;
|
||||
export {
|
||||
MAIN_TO_RENDERER,
|
||||
RENDERER_TO_MAIN,
|
||||
BIDIRECTIONAL,
|
||||
MAIN_TO_RENDERER_CANVAS,
|
||||
RENDERER_TO_MAIN_CANVAS,
|
||||
BIDIRECTIONAL_CANVAS,
|
||||
MAIN_TO_RENDERER_UPDATER,
|
||||
RENDERER_TO_MAIN_UPDATER,
|
||||
} from './ipc';
|
||||
|
||||
45
shared/constants/ipc/base.ts
Normal file
45
shared/constants/ipc/base.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// ============================================================
|
||||
// IPC 通道常量 — 基础通道(窗口、日志、主题等)
|
||||
// ============================================================
|
||||
|
||||
/** 主进程 → 渲染进程 */
|
||||
export const MAIN_TO_RENDERER = {
|
||||
/** 主进程消息推送 */
|
||||
MAIN_PROCESS_MESSAGE: 'main-process-message',
|
||||
/** 主题变更通知 */
|
||||
THEME_CHANGED: 'theme-changed',
|
||||
/** 窗口聚焦通知 */
|
||||
WINDOW_FOCUSED: 'window-focused',
|
||||
/** 窗口失焦通知 */
|
||||
WINDOW_BLURRED: 'window-blurred',
|
||||
} as const;
|
||||
|
||||
/** 渲染进程 → 主进程 */
|
||||
export const RENDERER_TO_MAIN = {
|
||||
/** 打开子窗口 */
|
||||
OPEN_SUB_WINDOW: 'open-sub-window',
|
||||
/** 关闭子窗口 */
|
||||
CLOSE_SUB_WINDOW: 'close-sub-window',
|
||||
/** 设置窗口标题 */
|
||||
SET_WINDOW_TITLE: 'set-window-title',
|
||||
/** 获取平台信息 */
|
||||
GET_PLATFORM_INFO: 'get-platform-info',
|
||||
/** 最小化窗口 */
|
||||
MINIMIZE_WINDOW: 'minimize-window',
|
||||
/** 最大化/还原窗口 */
|
||||
TOGGLE_MAXIMIZE: 'toggle-maximize',
|
||||
/** 关闭窗口 */
|
||||
CLOSE_WINDOW: 'close-window',
|
||||
/** 渲染进程日志上报 */
|
||||
LOG_MESSAGE: 'log-message',
|
||||
} as const;
|
||||
|
||||
/** 双向通信通道 — 应用配置 */
|
||||
export const BIDIRECTIONAL_CONFIG = {
|
||||
/** 读取应用配置 */
|
||||
READ_CONFIG: 'read-config',
|
||||
/** 写入应用配置 */
|
||||
WRITE_CONFIG: 'write-config',
|
||||
/** 文件对话框 */
|
||||
FILE_DIALOG: 'file-dialog',
|
||||
} as const;
|
||||
45
shared/constants/ipc/canvas.ts
Normal file
45
shared/constants/ipc/canvas.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// ============================================================
|
||||
// IPC 通道常量 — 无限画布相关通道
|
||||
// ============================================================
|
||||
|
||||
/** 主进程 → 渲染进程 — 画布 */
|
||||
export const MAIN_TO_RENDERER_CANVAS = {
|
||||
/** 画布窗口准备就绪 */
|
||||
CANVAS_READY: 'canvas:ready',
|
||||
/** 画布扫描进度 */
|
||||
CANVAS_SCAN_PROGRESS: 'canvas:scan-progress',
|
||||
} as const;
|
||||
|
||||
/** 渲染进程 → 主进程 — 画布 */
|
||||
export const RENDERER_TO_MAIN_CANVAS = {
|
||||
/** 画布:打开无限画布窗口 */
|
||||
OPEN_CANVAS_WINDOW: 'canvas:open-window',
|
||||
} as const;
|
||||
|
||||
/** 双向通信通道 — 画布 */
|
||||
export const BIDIRECTIONAL_CANVAS = {
|
||||
/** 画布:扫描目录 */
|
||||
CANVAS_SCAN_DIRECTORY: 'canvas:scan-directory',
|
||||
/** 画布:扫描项目(接收 projectPath,主进程内部完成 buildScanRoots + walkDirectory) */
|
||||
CANVAS_SCAN_PROJECT: 'canvas:scan-project',
|
||||
/** 画布:读取 sidecar 元数据 */
|
||||
CANVAS_READ_SIDECAR: 'canvas:read-sidecar',
|
||||
/** 画布:写入 sidecar 元数据 */
|
||||
CANVAS_WRITE_SIDECAR: 'canvas:write-sidecar',
|
||||
/** 画布:生成缩略图 */
|
||||
CANVAS_GENERATE_THUMBNAIL: 'canvas:generate-thumbnail',
|
||||
/** 画布:复制文件(导入) */
|
||||
CANVAS_COPY_FILE: 'canvas:copy-file',
|
||||
/** 画布:获取原生图片尺寸 */
|
||||
CANVAS_GET_IMAGE_SIZE: 'canvas:get-image-size',
|
||||
/** 画布:列出目录下的子文件夹(非递归,排除隐藏) */
|
||||
CANVAS_LIST_SUBDIRS: 'canvas:list-subdirs',
|
||||
/** 画布:扫描阶段文件(单个 episode/shot/stage 组合) */
|
||||
CANVAS_SCAN_STAGE_FILES: 'canvas:scan-stage-files',
|
||||
/** 画布:扫描资产文件(深度递归扫描 assets/ 目录) */
|
||||
CANVAS_SCAN_ASSET_FILES: 'canvas:scan-asset-files',
|
||||
/** 画布:创建目录 */
|
||||
CANVAS_CREATE_DIRECTORY: 'canvas:create-directory',
|
||||
/** 画布:启动原生文件拖拽(拖放媒体文件到外部应用) */
|
||||
CANVAS_START_DRAG: 'canvas:start-drag',
|
||||
} as const;
|
||||
25
shared/constants/ipc/index.ts
Normal file
25
shared/constants/ipc/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// ============================================================
|
||||
// IPC 通道常量 — 汇总导出
|
||||
//
|
||||
// 使用方式:
|
||||
// import { MAIN_TO_RENDERER, BIDIRECTIONAL } from '@/shared/constants/ipc';
|
||||
// import { BIDIRECTIONAL_STORAGE } from '@/shared/constants/ipc/storage';
|
||||
// ============================================================
|
||||
|
||||
export { MAIN_TO_RENDERER, RENDERER_TO_MAIN, BIDIRECTIONAL_CONFIG } from './base';
|
||||
export { BIDIRECTIONAL_STORAGE } from './storage';
|
||||
export { MAIN_TO_RENDERER_CANVAS, RENDERER_TO_MAIN_CANVAS, BIDIRECTIONAL_CANVAS } from './canvas';
|
||||
export { MAIN_TO_RENDERER_UPDATER, RENDERER_TO_MAIN_UPDATER } from './updater';
|
||||
|
||||
// ---------- 兼容性汇总(向后兼容) ----------
|
||||
|
||||
import { BIDIRECTIONAL_CONFIG } from './base';
|
||||
import { BIDIRECTIONAL_STORAGE } from './storage';
|
||||
import { BIDIRECTIONAL_CANVAS } from './canvas';
|
||||
|
||||
/** @deprecated 请使用按功能域拆分的导出 */
|
||||
export const BIDIRECTIONAL = {
|
||||
...BIDIRECTIONAL_CONFIG,
|
||||
...BIDIRECTIONAL_STORAGE,
|
||||
...BIDIRECTIONAL_CANVAS,
|
||||
} as const;
|
||||
37
shared/constants/ipc/storage.ts
Normal file
37
shared/constants/ipc/storage.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// ============================================================
|
||||
// IPC 通道常量 — 存储相关通道
|
||||
// ============================================================
|
||||
|
||||
/** 双向通信通道 — 文件存储 */
|
||||
export const BIDIRECTIONAL_STORAGE = {
|
||||
/** safeStorage 加密(明文 → Base64 密文) */
|
||||
SAFESTORAGE_ENCRYPT: 'safe-storage:encrypt',
|
||||
/** safeStorage 解密(Base64 密文 → 明文) */
|
||||
SAFESTORAGE_DECRYPT: 'safe-storage:decrypt',
|
||||
/** 文件写入(Blob Buffer → 磁盘) */
|
||||
FILE_WRITE: 'storage:file-write',
|
||||
/** 文件读取(磁盘 → Base64) */
|
||||
FILE_READ: 'storage:file-read',
|
||||
/** 文件删除 */
|
||||
FILE_DELETE: 'storage:file-delete',
|
||||
/** 创建目录(递归) */
|
||||
MAKE_DIR: 'storage:make-dir',
|
||||
/** 在文件管理器中显示文件 */
|
||||
SHOW_ITEM_IN_FOLDER: 'storage:show-item-in-folder',
|
||||
/** 获取应用数据目录路径 */
|
||||
GET_DATA_DIR: 'storage:get-data-dir',
|
||||
/** 获取文件大小 */
|
||||
FILE_SIZE: 'storage:file-size',
|
||||
/** 文件/目录是否存在 */
|
||||
FILE_EXISTS: 'storage:file-exists',
|
||||
/** 绝对路径文件写入(绕过 heixiu-data 沙箱,用于用户自定义输出目录) */
|
||||
FILE_WRITE_ABSOLUTE: 'storage:file-write-absolute',
|
||||
/** 绝对路径目录创建(绕过 heixiu-data 沙箱) */
|
||||
MAKE_DIR_ABSOLUTE: 'storage:make-dir-absolute',
|
||||
/** 主进程下载文件到绝对路径(绕过浏览器 CORS 限制) */
|
||||
DOWNLOAD_TO_PATH: 'storage:download-to-path',
|
||||
/** 获取系统下载目录路径 */
|
||||
GET_DOWNLOADS_PATH: 'storage:get-downloads-path',
|
||||
/** 生成图片缩略图(读取原图 → 缩放 → 写入目标路径) */
|
||||
GENERATE_THUMBNAIL: 'storage:generate-thumbnail',
|
||||
} as const;
|
||||
27
shared/constants/ipc/updater.ts
Normal file
27
shared/constants/ipc/updater.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// ============================================================
|
||||
// IPC 通道常量 — 自动更新相关通道
|
||||
// ============================================================
|
||||
|
||||
/** 主进程 → 渲染进程 — 更新 */
|
||||
export const MAIN_TO_RENDERER_UPDATER = {
|
||||
/** 发现新版本 */
|
||||
UPDATE_AVAILABLE: 'update-available',
|
||||
/** 下载进度 */
|
||||
UPDATE_PROGRESS: 'update-progress',
|
||||
/** 更新已下载完成 */
|
||||
UPDATE_DOWNLOADED: 'update-downloaded',
|
||||
/** 更新出错 */
|
||||
UPDATE_ERROR: 'update-error',
|
||||
/** 当前已是最新版本 */
|
||||
UPDATE_NOT_AVAILABLE: 'update-not-available',
|
||||
} as const;
|
||||
|
||||
/** 渲染进程 → 主进程 — 更新 */
|
||||
export const RENDERER_TO_MAIN_UPDATER = {
|
||||
/** 安装更新 */
|
||||
INSTALL_UPDATE: 'install-update',
|
||||
/** 检查更新 */
|
||||
CHECK_FOR_UPDATE: 'check-for-update',
|
||||
/** 下载更新 */
|
||||
DOWNLOAD_UPDATE: 'download-update',
|
||||
} as const;
|
||||
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];
|
||||
@@ -14,7 +14,10 @@ export type LogCategory =
|
||||
| 'app' // 应用生命周期
|
||||
| 'ui' // UI 层错误 / 警告
|
||||
| 'ipc' // IPC 通信
|
||||
| 'safe-storage'; // 安全存储(safeStorage 加密/解密)
|
||||
| 'safe-storage' // 安全存储(safeStorage 加密/解密)
|
||||
| 'storage' // 本地存储(IndexedDB / sql.js / 文件缓存)
|
||||
| 'canvas' // 无限画布 IPC 处理
|
||||
| 'canvas-window'; // 画布窗口生命周期
|
||||
|
||||
/**
|
||||
* 结构化错误快照
|
||||
|
||||
80
src/App.tsx
80
src/App.tsx
@@ -1,80 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
import { useAppContext } from '@/components/AppProvider';
|
||||
import { LoginPage } from './pages/login';
|
||||
import { SettingsPage } from './pages/settings';
|
||||
import { on, off, EVENTS } from './utils/event-bus';
|
||||
import { Layout } from '@/components/Layout';
|
||||
import { HomePage } from '@/pages/home/HomePage';
|
||||
import { PageDispatcher } from '@/components/PageDispatcher';
|
||||
|
||||
/**
|
||||
* 根组件 — 主窗口
|
||||
*
|
||||
* 层级:
|
||||
* Layout(Banner + NavBar)
|
||||
* └── HomePage(主页面,始终挂载)
|
||||
* LoginPage(Modal 弹层,事件总线驱动)
|
||||
* SettingsPage(Modal 居中叠加,事件总线驱动 — 对应 QT 原版设置窗口)
|
||||
*
|
||||
* 设计原则(对应 QT 原版多窗口模型):
|
||||
* - 叠加层(设置、公告、页面等)→ Modal/Drawer + 事件总线,主页面不卸载
|
||||
* - 不再使用 React Router,所有导航由事件总线驱动
|
||||
*/
|
||||
function App() {
|
||||
const { isLoggedIn } = useAppContext();
|
||||
|
||||
// 登录 Modal 状态
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
|
||||
// 首次加载时未登录 → 弹出
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn) {
|
||||
setLoginOpen(true);
|
||||
}
|
||||
}, [isLoggedIn]);
|
||||
|
||||
// 监听事件总线:401 → 弹出登录 / 导航栏点击登录 → 弹出登录
|
||||
useEffect(() => {
|
||||
const handler = () => setLoginOpen(true);
|
||||
on(EVENTS.AUTH_REQUIRED, handler);
|
||||
on(EVENTS.SHOW_LOGIN, handler);
|
||||
return () => {
|
||||
off(EVENTS.AUTH_REQUIRED, handler);
|
||||
off(EVENTS.SHOW_LOGIN, handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleLoginClose = useCallback(() => {
|
||||
setLoginOpen(false);
|
||||
}, []);
|
||||
|
||||
// 设置 Drawer 状态
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
// 监听打开设置 Drawer
|
||||
useEffect(() => {
|
||||
const handler = () => setSettingsOpen(true);
|
||||
on(EVENTS.OPEN_SETTINGS, handler);
|
||||
return () => { off(EVENTS.OPEN_SETTINGS, handler); };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Layout>
|
||||
<HomePage />
|
||||
</Layout>
|
||||
|
||||
{/* 登录 Modal — 可关闭,关闭后主页可操作 */}
|
||||
<LoginPage open={loginOpen && !isLoggedIn} onClose={handleLoginClose} />
|
||||
|
||||
{/* 设置 Modal — 事件驱动,居中叠加,主页面始终挂载(对应 QT 原版独立设置窗口) */}
|
||||
<SettingsPage open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
|
||||
{/* 页面调度器 — 监听 OPEN_PAGE 事件,根据 modalType 渲染 Modal/Drawer/FloatingPanel */}
|
||||
<PageDispatcher />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
121
src/app/App.tsx
Normal file
121
src/app/App.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { App as AntdApp } from 'antd';
|
||||
|
||||
import { useAppContext } from '@/app/providers/AppProvider';
|
||||
import { LoginPage } from '@/modules/auth/views/AuthPage';
|
||||
import { SettingsPage } from '@/modules/settings';
|
||||
import { on, off, emit, EVENTS } from '@/shared/utils/bus';
|
||||
import type { BusinessErrorEvent } from '@/shared/utils/bus';
|
||||
import { Layout } from '@/app/layout/Layout';
|
||||
import { HomePage } from '@/modules/home/shared/views/HomePage';
|
||||
import { PageDispatcher } from '@/app/layout/PageDispatcher';
|
||||
|
||||
/**
|
||||
* 根组件 — 主窗口
|
||||
*
|
||||
* 层级:
|
||||
* Layout(Banner + NavBar)
|
||||
* └── HomePage(主页面,始终挂载)
|
||||
* LoginPage(Modal 弹层,事件总线驱动)
|
||||
* SettingsPage(Modal 居中叠加,事件总线驱动 — 对应 QT 原版设置窗口)
|
||||
*
|
||||
* 设计原则(对应 QT 原版多窗口模型):
|
||||
* - 叠加层(设置、公告、页面等)→ Modal/Drawer + 事件总线,主页面不卸载
|
||||
* - 不再使用 React Router,所有导航由事件总线驱动
|
||||
*/
|
||||
function App() {
|
||||
const { isLoggedIn } = useAppContext();
|
||||
const { modal } = AntdApp.useApp();
|
||||
|
||||
// 登录 Modal 状态
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
|
||||
// 首次加载时未登录 → 弹出
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn) {
|
||||
setLoginOpen(true);
|
||||
}
|
||||
}, [isLoggedIn]);
|
||||
|
||||
// 监听事件总线:401 → 弹出登录 / 导航栏点击登录 → 弹出登录
|
||||
useEffect(() => {
|
||||
const handler = () => setLoginOpen(true);
|
||||
on(EVENTS.AUTH_REQUIRED, handler);
|
||||
on(EVENTS.SHOW_LOGIN, handler);
|
||||
return () => {
|
||||
off(EVENTS.AUTH_REQUIRED, handler);
|
||||
off(EVENTS.SHOW_LOGIN, handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleLoginClose = useCallback(() => {
|
||||
setLoginOpen(false);
|
||||
}, []);
|
||||
|
||||
// 设置 Drawer 状态
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
// 监听打开设置 Drawer
|
||||
useEffect(() => {
|
||||
const handler = () => setSettingsOpen(true);
|
||||
on(EVENTS.OPEN_SETTINGS, handler);
|
||||
return () => { off(EVENTS.OPEN_SETTINGS, handler); };
|
||||
}, []);
|
||||
|
||||
// 监听余额不足业务错误 → 弹窗引导充值
|
||||
useEffect(() => {
|
||||
const handler = (payload: BusinessErrorEvent) => {
|
||||
console.log('[App] 收到 BALANCE_INSUFFICIENT 事件:', payload);
|
||||
modal.confirm({
|
||||
title: '余额不足',
|
||||
content: payload.message || '当前账户余额不足,请充值后重试',
|
||||
okText: '去充值',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
console.log('[App] 用户点击"去充值",打开充值页面');
|
||||
emit(EVENTS.OPEN_PAGE, { target: '/recharge', modalType: 'drawer' });
|
||||
},
|
||||
});
|
||||
};
|
||||
on(EVENTS.BALANCE_INSUFFICIENT, handler as (...args: unknown[]) => void);
|
||||
return () => { off(EVENTS.BALANCE_INSUFFICIENT, handler as (...args: unknown[]) => void); };
|
||||
}, [modal]);
|
||||
|
||||
// 监听软件使用权到期(HTTP 403)→ 弹窗引导购买 VIP
|
||||
useEffect(() => {
|
||||
const handler = (payload: BusinessErrorEvent) => {
|
||||
console.log('[App] 收到 SUBSCRIPTION_EXPIRED 事件:', payload);
|
||||
modal.confirm({
|
||||
title: '软件使用权已到期',
|
||||
content: payload.message || '软件使用权已到期,请购买 VIP 套餐后继续使用',
|
||||
okText: '去购买 VIP',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
console.log('[App] 用户点击"去购买 VIP",打开充值页面');
|
||||
emit(EVENTS.OPEN_PAGE, { target: '/recharge', modalType: 'drawer' });
|
||||
},
|
||||
});
|
||||
};
|
||||
on(EVENTS.SUBSCRIPTION_EXPIRED, handler as (...args: unknown[]) => void);
|
||||
return () => { off(EVENTS.SUBSCRIPTION_EXPIRED, handler as (...args: unknown[]) => void); };
|
||||
}, [modal]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Layout>
|
||||
<HomePage />
|
||||
</Layout>
|
||||
|
||||
{/* 登录 Modal — 可关闭,关闭后主页可操作 */}
|
||||
<LoginPage open={loginOpen && !isLoggedIn} onClose={handleLoginClose} />
|
||||
|
||||
{/* 设置 Modal — 事件驱动,居中叠加,主页面始终挂载(对应 QT 原版独立设置窗口) */}
|
||||
<SettingsPage open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
|
||||
{/* 页面调度器 — 监听 OPEN_PAGE 事件,根据 modalType 渲染 Modal/Drawer/FloatingPanel */}
|
||||
<PageDispatcher />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { theme as antTheme } from 'antd';
|
||||
import { NavBar } from './navbar';
|
||||
import { BannerCarousel } from '@/pages/home/components/BannerCarousel';
|
||||
import { NavBar } from '@/modules/navbar';
|
||||
import { BannerCarousel } from '@/modules/home/shared/views/BannerCarousel';
|
||||
|
||||
export function Layout({ children }: { children: ReactNode }) {
|
||||
const { token } = antTheme.useToken();
|
||||
@@ -18,12 +18,15 @@
|
||||
|
||||
import {type ComponentType, useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {Drawer, Modal} from 'antd';
|
||||
import {EVENTS, off, on} from '@/utils/event-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/headers/WechatWorkPage';
|
||||
import {AccountInfo} from '@/pages/headers/AccountInfo.tsx';
|
||||
import {BillingInfo} from "@/pages/headers/BillingInfo.tsx";
|
||||
import {WechatWorkPage} from '@/modules/account/views/wechat-work/WechatWorkPage';
|
||||
import {AccountInfo} from '@/modules/account/views/info/AccountPage';
|
||||
import {BillingInfo} from '@/modules/account/views/billing/BillingPage';
|
||||
import {RechargePage} from '@/modules/account/views/recharge/RechargePage';
|
||||
import {OrdersPage} from '@/modules/account/views/orders/OrdersPage';
|
||||
import {VipPage} from '@/modules/account/views/vip/VipPage';
|
||||
|
||||
// ---------- 类型 ----------
|
||||
|
||||
@@ -80,14 +83,17 @@ const PAGE_MAP: Record<string, PageConfig> = {
|
||||
width: () => Math.min(window.outerHeight * 0.65, 720),
|
||||
height: () => Math.min(window.outerHeight * 0.65, 680),
|
||||
},
|
||||
'/vip': {label: '开会员/激活'},
|
||||
'/recharge': {label: '充值积分'},
|
||||
'/vip': {component: VipPage, label: 'VIP 会员开通', width: 560},
|
||||
'/recharge': {component: RechargePage, label: '充值', width: 520},
|
||||
'/billing': {label: '账单记录',
|
||||
component: BillingInfo,
|
||||
width: 960,
|
||||
height: 680,},
|
||||
'/orders': {
|
||||
component: OrdersPage,
|
||||
label: '订单明细',
|
||||
width: 820,
|
||||
height: 640,
|
||||
},
|
||||
'/projects': {label: '项目管理'},
|
||||
'/quota': {
|
||||
@@ -126,6 +132,12 @@ function resolveHeight(target: string): number | undefined {
|
||||
export function PageDispatcher() {
|
||||
const [activePanel, setActivePanel] = useState<ActivePanel | null>(null);
|
||||
const nextIdRef = useRef(0);
|
||||
const [locked, setLocked] = useState(false);
|
||||
|
||||
// 关闭面板
|
||||
const closePanel = useCallback(() => {
|
||||
setActivePanel(null);
|
||||
}, []);
|
||||
|
||||
// 监听 OPEN_PAGE 事件
|
||||
useEffect(() => {
|
||||
@@ -149,11 +161,26 @@ export function PageDispatcher() {
|
||||
return () => off(EVENTS.OPEN_PAGE, handler);
|
||||
}, []);
|
||||
|
||||
// 关闭面板
|
||||
const closePanel = useCallback(() => {
|
||||
setActivePanel(null);
|
||||
// 监听 CLOSE_PAGE 事件(允许页面组件内部"取消"按钮关闭容器)
|
||||
useEffect(() => {
|
||||
on(EVENTS.CLOSE_PAGE, closePanel);
|
||||
return () => off(EVENTS.CLOSE_PAGE, closePanel);
|
||||
}, [closePanel]);
|
||||
|
||||
// 监听 PAGE_LOCK 事件(页面组件操作进行中时禁止关闭容器)
|
||||
useEffect(() => {
|
||||
const handler = (payload: unknown) => {
|
||||
setLocked((payload as { locked: boolean }).locked);
|
||||
};
|
||||
on(EVENTS.PAGE_LOCK, handler);
|
||||
return () => off(EVENTS.PAGE_LOCK, handler);
|
||||
}, []);
|
||||
|
||||
// 面板关闭时重置锁定状态(防止下次打开时仍处于锁定)
|
||||
useEffect(() => {
|
||||
if (!activePanel) setLocked(false);
|
||||
}, [activePanel]);
|
||||
|
||||
// 页面组件(useMemo 稳定引用)
|
||||
const PanelPage = useMemo<ComponentType | null>(
|
||||
() => (activePanel ? resolvePage(activePanel.target) : null),
|
||||
@@ -178,7 +205,7 @@ export function PageDispatcher() {
|
||||
onCancel={closePanel}
|
||||
footer={null}
|
||||
destroyOnHidden={true}
|
||||
mask={{closable: true}}
|
||||
mask={{closable: !locked}}
|
||||
width={panelWidth ?? defaultModalWidth}
|
||||
>
|
||||
{/* eslint-disable-next-line react-hooks/static-components */}
|
||||
@@ -193,6 +220,9 @@ export function PageDispatcher() {
|
||||
title={activePanel.label}
|
||||
onClose={closePanel}
|
||||
destroyOnHidden={true}
|
||||
mask={{
|
||||
closable:locked
|
||||
}}
|
||||
size={panelWidth ?? defaultDrawerWidth}
|
||||
>
|
||||
{/* eslint-disable-next-line react-hooks/static-components */}
|
||||
@@ -3,38 +3,48 @@ import ReactDOM from 'react-dom/client';
|
||||
import {App as AntdApp} from 'antd';
|
||||
|
||||
import App from './App';
|
||||
import {ThemeProvider} from './components/ThemeProvider';
|
||||
import {AppProvider} from './components/AppProvider';
|
||||
import {SettingsProvider} from './components/SettingsProvider';
|
||||
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');
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
{/* AppProvider — 全局应用状态(平台/版本/登录态) */}
|
||||
<AppProvider>
|
||||
{/* ThemeProvider 内部管理 ConfigProvider(亮色/暗色切换) */}
|
||||
{isCanvasMode ? (
|
||||
// Canvas 模式:轻量级 Provider,无 Auth/Storage/Settings 依赖
|
||||
<ThemeProvider>
|
||||
<AntdApp>
|
||||
<CanvasApp />
|
||||
</AntdApp>
|
||||
</ThemeProvider>
|
||||
) : (
|
||||
// 正常模式:完整的 AppProvider 链
|
||||
<AppProvider>
|
||||
<ThemeProvider>
|
||||
{/* SettingsProvider — 集中管理设置项(存储路径等) */}
|
||||
<SettingsProvider>
|
||||
{/* AntdApp 提供 message / notification / modal 的静态方法上下文 */}
|
||||
<AntdApp>
|
||||
<App />
|
||||
</AntdApp>
|
||||
</SettingsProvider>
|
||||
</ThemeProvider>
|
||||
</AppProvider>
|
||||
)}
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
// 仅在 Electron 环境中监听主进程消息(浏览器预览时跳过)
|
||||
import { safeIpcOn } from './utils/ipc';
|
||||
import { safeIpcOn } from '@/shared/utils/bus';
|
||||
safeIpcOn('main-process-message', (_event: unknown, message: unknown) => {
|
||||
console.log('[Main Process Message]:', message);
|
||||
});
|
||||
|
||||
// 渲染进程全局未捕获错误(兜底记录到日志文件)
|
||||
import {logger} from './utils/logger';
|
||||
import {logger} from '@/shared/utils/bus';
|
||||
|
||||
window.addEventListener('error', (event) => {
|
||||
logger.error('ui', 'Uncaught error (renderer)', event.error, {
|
||||
@@ -10,12 +10,12 @@
|
||||
import { createContext, useContext, useState, useMemo, useCallback, useEffect, type ReactNode } from 'react';
|
||||
import type { Platform, Edition } from '@shared/types';
|
||||
|
||||
import { getPlatform, isDev } from '@/utils/platform';
|
||||
import { safeIpcOn, safeIpcOff } from '@/utils/ipc';
|
||||
import { AuthAPI, type LoginResponseBody } from '@/services/modules/auth';
|
||||
import { getPlatform, isDev } from '@/shared/utils/env';
|
||||
import { safeIpcOn, safeIpcOff } from '@/shared/utils/bus';
|
||||
import { AuthAPI, type LoginResponseBody } from '@/services/modules/auth/auth';
|
||||
import { setToken, clearToken, initTokenStore } from '@/services/request';
|
||||
import { emit, EVENTS } from '@/utils/event-bus';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { emit, EVENTS } from '@/shared/utils/bus';
|
||||
import { logger } from '@/shared/utils/bus';
|
||||
import { getAutoLoginFlag, clearAutoLoginFlag } from '@/services/auth-persistence';
|
||||
import {
|
||||
initAuthRefresh,
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
clearProactiveRefresh,
|
||||
} from '@/services/auth-token';
|
||||
import {initModelCache} from '@/services/cache/model-cache';
|
||||
import {initStorage} from '@/shared/infrastructure/storage';
|
||||
|
||||
// ---------- Context 类型 ----------
|
||||
|
||||
@@ -86,6 +87,11 @@ export function AppProvider({ children }: AppProviderProps) {
|
||||
return user.account_type === 'individual' ? 'personal' : 'enterprise';
|
||||
}, [user]);
|
||||
|
||||
// 登录/登出后同步窗口标题版控后缀到主进程
|
||||
useEffect(() => {
|
||||
window.appRuntime?.setWindowEdition(isLoggedIn ? edition : null);
|
||||
}, [edition, isLoggedIn]);
|
||||
|
||||
// ---------- 登录 / 退出 ----------
|
||||
|
||||
const login = useCallback(async (auth: LoginResponseBody) => {
|
||||
@@ -110,6 +116,9 @@ export function AppProvider({ children }: AppProviderProps) {
|
||||
setUser(null);
|
||||
setIsLoggedIn(false);
|
||||
emit(EVENTS.LOGOUT);
|
||||
// 注意:退出登录不自动清理本地缓存
|
||||
// —— 数据已按 user_id 隔离,同一用户重新登录后缓存仍可命中
|
||||
// 用户主动清理请使用设置页面的「清理缓存」按钮
|
||||
}, []);
|
||||
|
||||
// ---------- 启动时初始化 token 存储 + 自动登录 ----------
|
||||
@@ -121,7 +130,7 @@ export function AppProvider({ children }: AppProviderProps) {
|
||||
let aborted = false;
|
||||
|
||||
// 从加密存储恢复 token + 模型缓存 到内存缓存
|
||||
Promise.all([initTokenStore(), initRefreshTokenStore(), initModelCache()]).then(() => {
|
||||
Promise.all([initTokenStore(), initRefreshTokenStore(), initModelCache(), initStorage()]).then(() => {
|
||||
if (aborted) return;
|
||||
|
||||
// 初始化 401 刷新回调(token 缓存就绪后才注册)
|
||||
@@ -165,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(() => {
|
||||
@@ -174,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,6 +8,7 @@
|
||||
// ============================================================
|
||||
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||
import { setOutputPath as saveOutputPath, setTeamRepoPath as saveTeamRepoPath } from '@/shared/infrastructure/storage';
|
||||
|
||||
// ---------- localStorage 键 ----------
|
||||
|
||||
@@ -108,6 +109,7 @@ export function SettingsProvider({ children }: SettingsProviderProps) {
|
||||
const setOutputPath = useCallback((path: string) => {
|
||||
setOutputPathState(path);
|
||||
writeStoredOutputPath(path);
|
||||
try { saveOutputPath(path); } catch { /* db 未初始化则跳过 */ }
|
||||
}, []);
|
||||
|
||||
const clearOutputPath = useCallback(() => {
|
||||
@@ -120,6 +122,7 @@ export function SettingsProvider({ children }: SettingsProviderProps) {
|
||||
const setTeamRepoPath = useCallback((path: string) => {
|
||||
setTeamRepoPathState(path);
|
||||
writeStoredTeamRepoPath(path);
|
||||
try { saveTeamRepoPath(path); } catch { /* db 未初始化则跳过 */ }
|
||||
}, []);
|
||||
|
||||
const clearTeamRepoPath = useCallback(() => {
|
||||
@@ -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';
|
||||
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 '@/services/modules/account/billing';
|
||||
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 '@/services/modules/account/vip-api';
|
||||
import { useAccountInfo } from '@/shared/hooks/use-api';
|
||||
import { centToYuan, formatDate } from '@/shared/utils/display';
|
||||
|
||||
const {Text, Title} = Typography;
|
||||
|
||||
543
src/modules/account/views/orders/OrdersPage.tsx
Normal file
543
src/modules/account/views/orders/OrdersPage.tsx
Normal file
@@ -0,0 +1,543 @@
|
||||
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { logger } from '@/shared/utils/bus';
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
Button,
|
||||
Typography,
|
||||
Tag,
|
||||
Segmented,
|
||||
Result,
|
||||
App,
|
||||
theme as antTheme,
|
||||
} from 'antd';
|
||||
import { DollarOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
|
||||
import {
|
||||
PaymentAPI,
|
||||
PAYMENT_STATUS,
|
||||
type PaymentStatus,
|
||||
type OrderListResponseBody,
|
||||
type OrderInfoResponseBody,
|
||||
} from '@/services/modules/account/payments';
|
||||
import {
|
||||
VipAPI,
|
||||
VipOrderStatus,
|
||||
type VipOrderStatusValue,
|
||||
type VipOrderRead,
|
||||
} from '@/services/modules/account/vip-api';
|
||||
import { centToYuan, formatDate } from '@/shared/utils/display';
|
||||
import { EnumResolver } from '@/shared/utils/enum-resolver';
|
||||
import { RequestError, RequestErrorType } from '@/services/request';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// ---------- 常量 ----------
|
||||
|
||||
/** 订单状态轮询间隔(毫秒) */
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
|
||||
/** 状态筛选项 */
|
||||
const STATUS_FILTERS = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '待支付', value: PAYMENT_STATUS.Pending },
|
||||
{ label: '已支付', value: PAYMENT_STATUS.Paid },
|
||||
{ label: '已过期', value: PAYMENT_STATUS.Expired },
|
||||
{ label: '支付失败', value: PAYMENT_STATUS.Failed },
|
||||
];
|
||||
|
||||
/** 订单类型 */
|
||||
type OrderType = 'recharge' | 'vip';
|
||||
|
||||
const ORDER_TYPE_OPTIONS = [
|
||||
{ label: '充值订单', value: 'recharge' as OrderType },
|
||||
{ label: 'VIP订单', value: 'vip' as OrderType },
|
||||
];
|
||||
|
||||
// ---------- 组件 ----------
|
||||
|
||||
export function OrdersPage() {
|
||||
const { message } = App.useApp();
|
||||
const { token } = antTheme.useToken();
|
||||
|
||||
// ======== 筛选 & 分页 ========
|
||||
|
||||
const [orderType, setOrderType] = useState<OrderType>('recharge');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [pagination, setPagination] = useState({ page: 1, page_size: 10 });
|
||||
|
||||
// ======== 数据状态 ========
|
||||
|
||||
const [rechargeOrders, setRechargeOrders] = useState<OrderListResponseBody[]>([]);
|
||||
const [vipOrders, setVipOrders] = useState<VipOrderRead[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 轮询定时器 Map(orderId → timer)
|
||||
const pollingRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
// 组件是否已挂载
|
||||
const mountedRef = useRef(true);
|
||||
// fetchOrders 的 AbortController(切换筛选/分页时取消旧请求)
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// ======== 数据获取 ========
|
||||
|
||||
const fetchOrders = useCallback(async () => {
|
||||
// 取消上一次未完成的请求(防竞态)
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (orderType === 'recharge') {
|
||||
const params: { page: number; page_size: number; status?: PaymentStatus } = {
|
||||
page: pagination.page,
|
||||
page_size: pagination.page_size,
|
||||
};
|
||||
if (statusFilter !== 'all') {
|
||||
params.status = statusFilter as PaymentStatus;
|
||||
}
|
||||
const data = await PaymentAPI.getOrdersListInfo(params, controller.signal);
|
||||
if (mountedRef.current) {
|
||||
setRechargeOrders(data);
|
||||
}
|
||||
} else {
|
||||
const params: { page?: number; page_size?: number; status?: VipOrderStatusValue } = {
|
||||
page: pagination.page,
|
||||
page_size: pagination.page_size,
|
||||
};
|
||||
if (statusFilter !== 'all') {
|
||||
params.status = statusFilter as VipOrderStatusValue;
|
||||
}
|
||||
const data = await VipAPI.getVipOrders(params);
|
||||
if (mountedRef.current) {
|
||||
setVipOrders(data);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// axios 拦截器将取消错误转为 RequestError(CANCELLED),非原始 AbortError
|
||||
if (err instanceof RequestError && err.type === RequestErrorType.CANCELLED) return;
|
||||
logger.warn('ui', '加载订单列表失败', err instanceof Error ? err : new Error(String(err)));
|
||||
if (mountedRef.current) {
|
||||
setError((err as Error)?.message || '加载订单列表失败');
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [pagination, statusFilter, orderType]);
|
||||
|
||||
// 筛选或分页变化 → 重新获取
|
||||
useEffect(() => {
|
||||
fetchOrders();
|
||||
}, [fetchOrders]);
|
||||
|
||||
// 挂载/卸载管理
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
const polling = pollingRef.current;
|
||||
const abort = abortRef.current;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
// 清理所有轮询定时器
|
||||
polling.forEach((timer) => clearTimeout(timer));
|
||||
polling.clear();
|
||||
// 取消进行中的列表请求
|
||||
abort?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ======== 单订单轮询 ========
|
||||
|
||||
/**
|
||||
* 单订单轮询:对指定 orderId 启动轮询,每 POLL_INTERVAL_MS 查一次
|
||||
* - 幂等启动:取消同一订单的旧轮询再创建新的
|
||||
* - 局部更新 orders state,终态时调 fetchOrders 保证一致性
|
||||
* - 网络异常时延长间隔(2×)重试
|
||||
* - 通过 mountedRef 防止卸载后 setState
|
||||
*/
|
||||
const startPolling = useCallback((orderId: string) => {
|
||||
// ① 取消同一订单的旧轮询(幂等启动,避免重复轮询)
|
||||
const existing = pollingRef.current.get(orderId);
|
||||
if (existing) {
|
||||
clearTimeout(existing);
|
||||
pollingRef.current.delete(orderId);
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const poll = async () => {
|
||||
if (cancelled || !mountedRef.current) return;
|
||||
|
||||
try {
|
||||
const order: OrderInfoResponseBody = await PaymentAPI.getOrderInfo({ order_id: orderId });
|
||||
if (cancelled || !mountedRef.current) return;
|
||||
|
||||
const newStatus = order.status;
|
||||
|
||||
// 局部更新 orders state(merge 最新订单信息)
|
||||
setRechargeOrders((prev) =>
|
||||
prev.map((o) => (o.id === orderId ? { ...o, ...order } : o)),
|
||||
);
|
||||
|
||||
if (newStatus === PAYMENT_STATUS.Pending) {
|
||||
// 仍待支付 → 继续轮询
|
||||
const timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||||
pollingRef.current.set(orderId, timer);
|
||||
} else {
|
||||
// 终态 → 停止轮询,刷新列表保证整体一致性
|
||||
pollingRef.current.delete(orderId);
|
||||
fetchOrders();
|
||||
|
||||
if (newStatus === PAYMENT_STATUS.Paid) {
|
||||
message.success('支付成功!');
|
||||
} else if (newStatus === PAYMENT_STATUS.Failed) {
|
||||
message.error('支付失败,请重试');
|
||||
} else if (newStatus === PAYMENT_STATUS.Expired) {
|
||||
message.warning('订单已过期');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 网络异常 → 延长间隔后重试
|
||||
if (!cancelled && mountedRef.current) {
|
||||
const timer = setTimeout(poll, POLL_INTERVAL_MS * 2);
|
||||
pollingRef.current.set(orderId, timer);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 启动首次轮询
|
||||
const timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||||
pollingRef.current.set(orderId, timer);
|
||||
}, [message, fetchOrders]);
|
||||
|
||||
/** VIP 订单轮询(与充值订单轮询模式一致,使用 VipAPI.getVipOrder) */
|
||||
const startVipPolling = useCallback((orderId: string) => {
|
||||
const existing = pollingRef.current.get(orderId);
|
||||
if (existing) {
|
||||
clearTimeout(existing);
|
||||
pollingRef.current.delete(orderId);
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const poll = async () => {
|
||||
if (cancelled || !mountedRef.current) return;
|
||||
|
||||
try {
|
||||
const order = await VipAPI.getVipOrder(orderId);
|
||||
if (cancelled || !mountedRef.current) return;
|
||||
|
||||
const newStatus = order.status;
|
||||
|
||||
setVipOrders((prev) =>
|
||||
prev.map((o) => (o.id === orderId ? { ...o, ...order } : o)),
|
||||
);
|
||||
|
||||
if (newStatus === VipOrderStatus.Pending) {
|
||||
const timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||||
pollingRef.current.set(orderId, timer);
|
||||
} else {
|
||||
pollingRef.current.delete(orderId);
|
||||
fetchOrders();
|
||||
|
||||
if (newStatus === VipOrderStatus.Paid) {
|
||||
message.success('支付成功!VIP 权益已生效');
|
||||
} else if (newStatus === VipOrderStatus.Expired) {
|
||||
message.warning('订单已过期');
|
||||
} else if (newStatus === VipOrderStatus.Cancelled) {
|
||||
message.info('订单已取消');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled && mountedRef.current) {
|
||||
const timer = setTimeout(poll, POLL_INTERVAL_MS * 2);
|
||||
pollingRef.current.set(orderId, timer);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||||
pollingRef.current.set(orderId, timer);
|
||||
}, [message, fetchOrders]);
|
||||
|
||||
// ======== 操作回调 ========
|
||||
|
||||
/** "继续支付":打开支付页面 + 启动轮询 */
|
||||
const handleContinuePay = useCallback(
|
||||
(orderId: string, payUrl: string) => {
|
||||
window.open(payUrl, '_blank');
|
||||
if (orderType === 'recharge') {
|
||||
startPolling(orderId);
|
||||
} else {
|
||||
startVipPolling(orderId);
|
||||
}
|
||||
message.info('已打开支付页面,请完成支付');
|
||||
},
|
||||
[orderType, startPolling, startVipPolling, message],
|
||||
);
|
||||
|
||||
// ======== 表格列定义 ========
|
||||
|
||||
/** 充值订单列 */
|
||||
const rechargeColumns = useMemo(() => [
|
||||
{
|
||||
title: '订单编号',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (id: string) => (
|
||||
<Text copyable style={{ fontSize: 12 }}>{id}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount_cent',
|
||||
key: 'amount_cent',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
render: (amount: number) => (
|
||||
<Text strong style={{ fontSize: 13, color: token.colorPrimary }}>¥{centToYuan(amount)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '支付方式',
|
||||
dataIndex: 'payment_channel',
|
||||
key: 'payment_channel',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
render: (channel: string) => (
|
||||
<Text style={{ fontSize: 12 }}>{EnumResolver.payments.method.label(channel)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
render: (status: string) => (
|
||||
<Tag
|
||||
color={EnumResolver.payments.status.color(status as PaymentStatus)}
|
||||
style={{ fontSize: 11, lineHeight: '18px' }}
|
||||
>
|
||||
{EnumResolver.payments.status.label(status as PaymentStatus)}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 120,
|
||||
align: 'center' as const,
|
||||
render: (date: Date) => (
|
||||
<Text style={{ fontSize: 12, whiteSpace: 'nowrap' }}>{formatDate(date)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, record: OrderListResponseBody) => {
|
||||
if (record.status === PAYMENT_STATUS.Pending && record.pay_url) {
|
||||
return (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<DollarOutlined />}
|
||||
onClick={() => handleContinuePay(record.id, record.pay_url)}
|
||||
>
|
||||
继续支付
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return <Text type="secondary" style={{ fontSize: 12 }}>—</Text>;
|
||||
},
|
||||
},
|
||||
], [token.colorPrimary, handleContinuePay]);
|
||||
|
||||
/** VIP 订单列 */
|
||||
const vipColumns = useMemo(() => [
|
||||
{
|
||||
title: '订单编号',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 220,
|
||||
ellipsis: true,
|
||||
render: (id: string) => (
|
||||
<Text copyable style={{ fontSize: 12 }}>{id}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount_cent',
|
||||
key: 'amount_cent',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
render: (amount: number) => (
|
||||
<Text strong style={{ fontSize: 13, color: token.colorPrimary }}>¥{centToYuan(amount)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
render: (status: string) => (
|
||||
<Tag
|
||||
color={EnumResolver.vip.orderStatus.color(status as VipOrderStatusValue)}
|
||||
style={{ fontSize: 11, lineHeight: '18px' }}
|
||||
>
|
||||
{EnumResolver.vip.orderStatus.label(status as VipOrderStatusValue)}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 130,
|
||||
align: 'center' as const,
|
||||
render: (date: Date) => (
|
||||
<Text style={{ fontSize: 12, whiteSpace: 'nowrap' }}>{formatDate(date)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, record: VipOrderRead) => {
|
||||
if (record.status === VipOrderStatus.Pending && record.pay_url) {
|
||||
return (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<DollarOutlined />}
|
||||
onClick={() => handleContinuePay(record.id, record.pay_url ?? '')}
|
||||
>
|
||||
继续支付
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return <Text type="secondary" style={{ fontSize: 12 }}>—</Text>;
|
||||
},
|
||||
},
|
||||
], [token.colorPrimary, handleContinuePay]);
|
||||
|
||||
/** 当前类型对应的列和数据 */
|
||||
const columns = orderType === 'recharge' ? rechargeColumns : vipColumns;
|
||||
const orders = orderType === 'recharge' ? rechargeOrders : vipOrders;
|
||||
|
||||
// ============================================================
|
||||
// 渲染:错误态(无数据时)
|
||||
// ============================================================
|
||||
|
||||
if (error && orders.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: '8px 0' }}>
|
||||
<Card size="small" className="rounded-md!">
|
||||
<Result
|
||||
status="error"
|
||||
title="订单加载失败"
|
||||
subTitle={error}
|
||||
extra={
|
||||
<Button type="primary" size="small" onClick={fetchOrders}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 渲染:正常
|
||||
// ============================================================
|
||||
|
||||
/** 当前订单类型对应的状态筛选项 */
|
||||
const currentStatusFilters = orderType === 'recharge'
|
||||
? STATUS_FILTERS
|
||||
: [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '待支付', value: VipOrderStatus.Pending },
|
||||
{ label: '已支付', value: VipOrderStatus.Paid },
|
||||
{ label: '已过期', value: VipOrderStatus.Expired },
|
||||
{ label: '已取消', value: VipOrderStatus.Cancelled },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '8px 0', width: '100%' }}>
|
||||
<div className="flex flex-col gap-3" style={{ width: '100%' }}>
|
||||
{/* ---- 订单类型 + 状态筛选 ---- */}
|
||||
<Segmented
|
||||
options={ORDER_TYPE_OPTIONS}
|
||||
value={orderType}
|
||||
onChange={(val) => {
|
||||
setOrderType(val as OrderType);
|
||||
setStatusFilter('all');
|
||||
setPagination((prev) => ({ ...prev, page: 1 }));
|
||||
}}
|
||||
block
|
||||
style={{ backgroundColor: token.colorFillSecondary }}
|
||||
/>
|
||||
<Segmented
|
||||
options={currentStatusFilters}
|
||||
value={statusFilter}
|
||||
onChange={(val) => {
|
||||
setStatusFilter(val as string);
|
||||
setPagination((prev) => ({ ...prev, page: 1 }));
|
||||
}}
|
||||
block
|
||||
style={{ backgroundColor: token.colorFillSecondary }}
|
||||
/>
|
||||
|
||||
{/* ---- 手动刷新 ---- */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button size="small" icon={<ReloadOutlined />} onClick={fetchOrders} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ---- 订单表格 ---- */}
|
||||
<Card size="small" styles={{ body: { padding: 0 } }} className="rounded-md!">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<Table<any>
|
||||
columns={columns}
|
||||
dataSource={orders}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
size="small"
|
||||
pagination={{
|
||||
current: pagination.page,
|
||||
pageSize: pagination.page_size,
|
||||
total:
|
||||
orders.length >= pagination.page_size
|
||||
? pagination.page * pagination.page_size + 1
|
||||
: pagination.page * pagination.page_size,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['10', '20', '50'],
|
||||
showTotal: (total: number, range: [number, number]) =>
|
||||
`${range[0]}-${range[1]},共 ${total}+ 条`,
|
||||
onChange: (page: number, pageSize: number) => {
|
||||
setPagination({ page, page_size: pageSize });
|
||||
},
|
||||
placement: ['bottomCenter'],
|
||||
}}
|
||||
locale={{ emptyText: '暂无订单记录' }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
526
src/modules/account/views/recharge/RechargePage.tsx
Normal file
526
src/modules/account/views/recharge/RechargePage.tsx
Normal file
@@ -0,0 +1,526 @@
|
||||
// ============================================================
|
||||
// RechargePage — 积分充值表单 + 支付状态跟踪
|
||||
//
|
||||
// 通过 PageDispatcher 调度(Modal 容器),导航栏"充值"按钮触发。
|
||||
// 两阶段视图:
|
||||
// form — 金额选择 → 自定义输入 → 支付方式 → 协议 → 提交
|
||||
// pending — 订单已创建,轮询支付状态,可重新打开支付页面
|
||||
// paid — 支付成功
|
||||
// expired — 订单过期,可重新下单
|
||||
// failed — 支付失败,可重新下单
|
||||
// ============================================================
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { logger } from '@/shared/utils/bus';
|
||||
import {
|
||||
Radio,
|
||||
InputNumber,
|
||||
Button,
|
||||
Checkbox,
|
||||
Typography,
|
||||
App,
|
||||
Space,
|
||||
Divider,
|
||||
theme as antTheme,
|
||||
Result,
|
||||
} from 'antd';
|
||||
import {
|
||||
AlipayOutlined,
|
||||
WechatOutlined,
|
||||
WalletOutlined,
|
||||
DollarOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { useTheme } from '@/app/providers/ThemeProvider';
|
||||
import { LegalTextModal } from '@/shared/components/LegalTextModal';
|
||||
import {
|
||||
PaymentAPI,
|
||||
PAYMENT_METHODS,
|
||||
PAYMENT_STATUS,
|
||||
getPaymentStatusText,
|
||||
type PaymentStatus,
|
||||
} from '@/services/modules/account/payments';
|
||||
import { emit, EVENTS } from '@/shared/utils/bus';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// ---------- 常量 ----------
|
||||
|
||||
const PRESET_AMOUNTS = [10, 100, 500, 1000, 5000, 10000] as const;
|
||||
|
||||
/** 单次充值上限(元) */
|
||||
const MAX_AMOUNT_YUAN = 100000;
|
||||
|
||||
/** 订单状态轮询间隔(毫秒) */
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
|
||||
/** 页面阶段 */
|
||||
type RechargePhase = 'form' | PaymentStatus;
|
||||
|
||||
// ---------- 组件 ----------
|
||||
|
||||
export function RechargePage() {
|
||||
const { isDark } = useTheme();
|
||||
const { message } = App.useApp();
|
||||
const { token } = antTheme.useToken();
|
||||
|
||||
// ======== 表单状态 ========
|
||||
|
||||
const [selectedAmount, setSelectedAmount] = useState<number | null>(null);
|
||||
const [customAmount, setCustomAmount] = useState<number | null>(null);
|
||||
const [paymentMethod, setPaymentMethod] = useState<string>(PAYMENT_METHODS.Alipay);
|
||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [legalOpen, setLegalOpen] = useState(false);
|
||||
|
||||
// ======== 订单状态 ========
|
||||
|
||||
const [phase, setPhase] = useState<RechargePhase>('form');
|
||||
const [orderId, setOrderId] = useState<string | null>(null);
|
||||
const [orderStatus, setOrderStatus] = useState<PaymentStatus | null>(null);
|
||||
const [payUrl, setPayUrl] = useState<string | null>(null);
|
||||
|
||||
// ======== 派生值 ========
|
||||
|
||||
/** 最终充值金额(元):自定义金额优先,否则预设金额,否则 0 */
|
||||
const finalAmountYuan = useMemo(() => {
|
||||
if (customAmount !== null && customAmount !== undefined) {
|
||||
return customAmount;
|
||||
}
|
||||
return selectedAmount ?? 0;
|
||||
}, [customAmount, selectedAmount]);
|
||||
|
||||
/** 最终充值金额(分):元 × 100,用于 API 调用 */
|
||||
const finalAmountCent = finalAmountYuan * 100;
|
||||
|
||||
// ======== 工具函数 ========
|
||||
|
||||
const clearError = useCallback((name: string) => {
|
||||
setErrors((prev) => {
|
||||
if (!(name in prev)) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[name];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ======== 校验 ========
|
||||
|
||||
const validate = useCallback((): Record<string, string> => {
|
||||
const errs: Record<string, string> = {};
|
||||
|
||||
if (!finalAmountYuan || finalAmountYuan <= 0) {
|
||||
errs.amount = '请选择或输入充值金额';
|
||||
} else if (!Number.isInteger(finalAmountYuan) || finalAmountYuan % 10 !== 0) {
|
||||
errs.customAmount = '金额必须为 10 的倍数';
|
||||
} else if (finalAmountYuan > MAX_AMOUNT_YUAN) {
|
||||
errs.customAmount = `单次充值上限为 ${MAX_AMOUNT_YUAN.toLocaleString()} 元`;
|
||||
}
|
||||
|
||||
if (!agreedToTerms) {
|
||||
errs.agreedToTerms = '请阅读并同意充值协议';
|
||||
}
|
||||
|
||||
return errs;
|
||||
}, [finalAmountYuan, agreedToTerms]);
|
||||
|
||||
// ======== 支付方式切换 ========
|
||||
|
||||
const handlePaymentChange = useCallback(
|
||||
(value: string) => {
|
||||
if (value === PAYMENT_METHODS.WeChat) {
|
||||
void message.info('微信支付正在接入中,敬请期待');
|
||||
return; // 不更新 state,保持支付宝选中
|
||||
}
|
||||
setPaymentMethod(value);
|
||||
},
|
||||
[message],
|
||||
);
|
||||
|
||||
// ======== 提交流程 ========
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const validationErrors = validate();
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await PaymentAPI.createOrder({
|
||||
amount_cent: finalAmountCent,
|
||||
payment_channel: PAYMENT_METHODS.Alipay,
|
||||
});
|
||||
|
||||
// 保存订单信息,进入等待支付阶段
|
||||
setOrderId(res.id);
|
||||
setOrderStatus(PAYMENT_STATUS.Pending);
|
||||
setPayUrl(res.pay_url);
|
||||
setPhase(PAYMENT_STATUS.Pending);
|
||||
|
||||
window.open(res.pay_url, '_blank');
|
||||
void message.success('订单已创建,请在新页面中完成支付');
|
||||
} catch (err: unknown) {
|
||||
logger.warn('ui', '充值创建订单失败', err instanceof Error ? err : new Error(String(err)));
|
||||
message.error((err as Error)?.message || '创建订单失败,请重试');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [finalAmountCent, validate, message]);
|
||||
|
||||
// ======== 订单状态轮询 ========
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== PAYMENT_STATUS.Pending || !orderId) return;
|
||||
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
let cancelled = false;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const order = await PaymentAPI.getOrderInfo({ order_id: orderId });
|
||||
if (cancelled) return;
|
||||
|
||||
const newStatus = order.status;
|
||||
setOrderStatus(newStatus);
|
||||
|
||||
if (newStatus === PAYMENT_STATUS.Pending) {
|
||||
// 仍待支付 → 继续轮询
|
||||
timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||||
} else {
|
||||
// 终态 → 停止轮询,切换阶段
|
||||
setPhase(newStatus);
|
||||
if (newStatus === PAYMENT_STATUS.Paid) {
|
||||
void message.success('支付成功,积分已到账');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 网络异常 → 延长间隔后继续轮询
|
||||
if (!cancelled) timer = setTimeout(poll, POLL_INTERVAL_MS * 2);
|
||||
}
|
||||
};
|
||||
|
||||
timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [phase, orderId, message]);
|
||||
|
||||
// ======== 页面锁定(待支付时禁止关闭容器) ========
|
||||
|
||||
useEffect(() => {
|
||||
const locked = phase === PAYMENT_STATUS.Pending;
|
||||
emit(EVENTS.PAGE_LOCK, { locked });
|
||||
// 组件卸载时确保解锁(防止锁定残留)
|
||||
return () => {
|
||||
if (locked) emit(EVENTS.PAGE_LOCK, { locked: false });
|
||||
};
|
||||
}, [phase]);
|
||||
|
||||
// ======== 操作回调 ========
|
||||
|
||||
/** 重新下单:回到表单,保留之前填写的金额 */
|
||||
const handleRetry = useCallback(() => {
|
||||
setPhase('form');
|
||||
setOrderId(null);
|
||||
setOrderStatus(null);
|
||||
setPayUrl(null);
|
||||
}, []);
|
||||
|
||||
/** 关闭充值页面 */
|
||||
const handleClose = useCallback(() => {
|
||||
emit(EVENTS.CLOSE_PAGE);
|
||||
}, []);
|
||||
|
||||
// ================================================================
|
||||
// 渲染:支付状态视图(非 form 阶段)
|
||||
// ================================================================
|
||||
|
||||
if (phase !== 'form') {
|
||||
const statusText = orderStatus ? getPaymentStatusText(orderStatus) : '';
|
||||
const isPending = phase === PAYMENT_STATUS.Pending;
|
||||
const isPaid = phase === PAYMENT_STATUS.Paid;
|
||||
const isTerminal = !isPending;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ padding: '16px 0' }}>
|
||||
<Result
|
||||
status={
|
||||
isPaid
|
||||
? 'success'
|
||||
: isPending
|
||||
? 'info'
|
||||
: 'error'
|
||||
}
|
||||
title={
|
||||
isPaid
|
||||
? '支付成功'
|
||||
: isPending
|
||||
? `等待支付(${statusText})`
|
||||
: statusText
|
||||
}
|
||||
subTitle={
|
||||
isPaid
|
||||
? `已到账积分:${finalAmountYuan.toLocaleString()} 积分`
|
||||
: isPending
|
||||
? '请在新打开的浏览器页面中完成支付'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* ---- 订单信息卡片 ---- */}
|
||||
{isPending && (
|
||||
<div
|
||||
style={{
|
||||
background: isDark ? '#2E2A5A' : '#F5F3FF',
|
||||
padding: '12px 16px',
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
textAlign: 'left',
|
||||
maxWidth: 360,
|
||||
margin: '0 auto 16px',
|
||||
}}
|
||||
>
|
||||
<Space orientation="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Text style={{ fontSize: 12 }} type="secondary">
|
||||
订单编号:<Text copyable style={{ fontSize: 12 }}>{orderId}</Text>
|
||||
</Text>
|
||||
<Text style={{ fontSize: 13 }}>
|
||||
<DollarOutlined style={{ marginRight: 6, color: token.colorPrimary }} />
|
||||
充值金额:
|
||||
<Text strong>¥{finalAmountYuan.toLocaleString()} 元</Text>
|
||||
</Text>
|
||||
<Text style={{ fontSize: 13 }}>
|
||||
<WalletOutlined style={{ marginRight: 6, color: token.colorSuccess }} />
|
||||
到账积分:
|
||||
<Text strong>{finalAmountYuan.toLocaleString()}</Text> 积分
|
||||
</Text>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- 操作按钮 ---- */}
|
||||
<Space size={12}>
|
||||
{isPending && payUrl && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DollarOutlined />}
|
||||
onClick={() => window.open(payUrl, '_blank')}
|
||||
>
|
||||
打开支付页面
|
||||
</Button>
|
||||
)}
|
||||
{isTerminal && !isPaid && (
|
||||
<Button type="primary" onClick={handleRetry}>
|
||||
重新下单
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleClose}>
|
||||
{isPaid ? '完成' : '关闭(取消支付)'}
|
||||
</Button>
|
||||
</Space>
|
||||
</Result>
|
||||
</div>
|
||||
|
||||
{/* ---- 协议弹窗(待支付阶段仍可查看) ---- */}
|
||||
<LegalTextModal
|
||||
open={legalOpen}
|
||||
type="points"
|
||||
onClose={() => setLegalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 渲染:表单视图(form 阶段)
|
||||
// ================================================================
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ padding: '4px 0' }}>
|
||||
{/* ---- 页面副标题 ---- */}
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 13, marginBottom: 20, display: 'block' }}
|
||||
>
|
||||
选择充值金额或输入自定义金额(元)
|
||||
</Text>
|
||||
|
||||
{/* ---- Part 1:预设金额单选组 ---- */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Radio.Group
|
||||
value={selectedAmount}
|
||||
onChange={(e) => {
|
||||
setSelectedAmount(e.target.value);
|
||||
setCustomAmount(null);
|
||||
clearError('amount');
|
||||
}}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}
|
||||
>
|
||||
{PRESET_AMOUNTS.map((amt) => (
|
||||
<Radio.Button key={amt} value={amt}>
|
||||
{amt} 元
|
||||
</Radio.Button>
|
||||
))}
|
||||
</Radio.Group>
|
||||
{errors.amount && (
|
||||
<Text type="danger" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
|
||||
{errors.amount}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ---- Part 2:自定义金额输入 ---- */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<InputNumber
|
||||
value={customAmount}
|
||||
onChange={(val) => {
|
||||
setCustomAmount(val);
|
||||
setSelectedAmount(null);
|
||||
clearError('customAmount');
|
||||
}}
|
||||
min={10}
|
||||
step={10}
|
||||
max={MAX_AMOUNT_YUAN}
|
||||
placeholder="输入自定义金额(10的倍数)"
|
||||
style={{ flex: 1 }}
|
||||
size="large"
|
||||
precision={0}
|
||||
/>
|
||||
<Button size="large" disabled style={{ minWidth: 44 }}>
|
||||
元
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
{errors.customAmount && (
|
||||
<Text type="danger" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
|
||||
{errors.customAmount}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ---- Part 3:到账信息 + 支付金额 ---- */}
|
||||
{finalAmountYuan > 0 && (
|
||||
<div
|
||||
style={{
|
||||
background: isDark ? '#2E2A5A' : '#F5F3FF',
|
||||
padding: '12px 16px',
|
||||
borderRadius: 8,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Space orientation="vertical" size={6}>
|
||||
<Text style={{ fontSize: 13 }}>
|
||||
<WalletOutlined style={{ marginRight: 6, color: token.colorPrimary }} />
|
||||
到账积分:
|
||||
<Text strong>{finalAmountYuan.toLocaleString()}</Text> 积分
|
||||
</Text>
|
||||
<Text style={{ fontSize: 13 }}>
|
||||
<DollarOutlined style={{ marginRight: 6, color: token.colorSuccess }} />
|
||||
支付金额:
|
||||
<Text strong style={{ color: token.colorPrimary, fontSize: 15 }}>
|
||||
¥{finalAmountYuan.toLocaleString()}
|
||||
</Text>
|
||||
{' '}元
|
||||
</Text>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
{/* ---- Part 4:支付方式 ---- */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 12, marginBottom: 8, display: 'block' }}
|
||||
>
|
||||
选择支付方式
|
||||
</Text>
|
||||
<Radio.Group
|
||||
value={paymentMethod}
|
||||
onChange={(e) => handlePaymentChange(e.target.value)}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
>
|
||||
<Radio.Button value={PAYMENT_METHODS.Alipay}>
|
||||
<Space size={4}>
|
||||
<AlipayOutlined />
|
||||
支付宝
|
||||
</Space>
|
||||
</Radio.Button>
|
||||
<Radio.Button value={PAYMENT_METHODS.WeChat}>
|
||||
<Space size={4}>
|
||||
<WechatOutlined />
|
||||
微信支付
|
||||
</Space>
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
{/* ---- Part 5:充值协议 ---- */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Checkbox
|
||||
checked={agreedToTerms}
|
||||
onChange={(e) => {
|
||||
setAgreedToTerms(e.target.checked);
|
||||
clearError('agreedToTerms');
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 12 }}>
|
||||
我已阅读并同意
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => setLegalOpen(true)}
|
||||
style={{
|
||||
padding: '0 2px',
|
||||
fontSize: 12,
|
||||
height: 'auto',
|
||||
lineHeight: 'inherit',
|
||||
}}
|
||||
>
|
||||
《积分充值服务协议》
|
||||
</Button>
|
||||
</Text>
|
||||
</Checkbox>
|
||||
{errors.agreedToTerms && (
|
||||
<Text type="danger" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
|
||||
{errors.agreedToTerms}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ---- Part 6:操作按钮 ---- */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<Button onClick={handleClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={submitting}
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={finalAmountYuan <= 0}
|
||||
>
|
||||
提交
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---- 协议弹窗 ---- */}
|
||||
<LegalTextModal
|
||||
open={legalOpen}
|
||||
type="points"
|
||||
onClose={() => setLegalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
640
src/modules/account/views/vip/VipPage.tsx
Normal file
640
src/modules/account/views/vip/VipPage.tsx
Normal file
@@ -0,0 +1,640 @@
|
||||
// ============================================================
|
||||
// VipPage — VIP 会员开通页面
|
||||
//
|
||||
// 通过 PageDispatcher 调度(Modal 容器),导航栏"开会员/激活"按钮触发。
|
||||
// 四部分布局:
|
||||
// 激活码 — Space.Compact 输入框 + 激活按钮
|
||||
// 套餐选择 — Radio.Group 卡片式单选,API 获取套餐列表
|
||||
// 支付协议 — Checkbox + LegalTextModal(用户服务协议) + 确认开通
|
||||
// 权益说明 — 选中套餐的大图展示
|
||||
// ============================================================
|
||||
|
||||
import {useState, useCallback, useEffect, useRef} from 'react';
|
||||
import { logger } from '@/shared/utils/bus';
|
||||
import {
|
||||
Radio,
|
||||
Button,
|
||||
Checkbox,
|
||||
Input,
|
||||
Image,
|
||||
Typography,
|
||||
App,
|
||||
Space,
|
||||
Divider,
|
||||
theme as antTheme,
|
||||
Spin,
|
||||
Result,
|
||||
} from 'antd';
|
||||
import {CrownOutlined, GiftOutlined, DollarOutlined} from '@ant-design/icons';
|
||||
|
||||
import {useTheme} from '@/app/providers/ThemeProvider';
|
||||
import {LegalTextModal} from '@/shared/components/LegalTextModal';
|
||||
import {
|
||||
VipAPI,
|
||||
VipOrderStatus,
|
||||
type VipLevelsItem,
|
||||
type VipOrderStatusValue,
|
||||
} from '@/services/modules/account/vip-api';
|
||||
import {centToYuan} from '@/shared/utils/display';
|
||||
import {EnumResolver} from '@/shared/utils/enum-resolver';
|
||||
import {emit, EVENTS} from '@/shared/utils/bus';
|
||||
import {RequestError, RequestErrorType} from '@/services/request';
|
||||
|
||||
const {Text} = Typography;
|
||||
|
||||
// ---------- 常量 ----------
|
||||
|
||||
/** 订单状态轮询间隔(毫秒) */
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
|
||||
/** 页面阶段 */
|
||||
type VipPhase = 'form' | 'activate' | VipOrderStatusValue;
|
||||
|
||||
/** 状态 → Result status 映射 */
|
||||
function getResultStatus(status: VipOrderStatusValue): 'success' | 'info' | 'error' {
|
||||
if (status === VipOrderStatus.Paid) return 'success';
|
||||
if (status === VipOrderStatus.Pending) return 'info';
|
||||
return 'error';
|
||||
}
|
||||
|
||||
// ---------- 组件 ----------
|
||||
|
||||
export function VipPage() {
|
||||
const {isDark} = useTheme();
|
||||
const {message} = App.useApp();
|
||||
const {token} = antTheme.useToken();
|
||||
|
||||
// ======== 套餐列表 ========
|
||||
|
||||
const [levels, setLevels] = useState<VipLevelsItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
// ======== 表单状态 ========
|
||||
|
||||
const [selectedLevelId, setSelectedLevelId] = useState<number | null>(null);
|
||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [legalOpen, setLegalOpen] = useState(false);
|
||||
|
||||
// ======== 激活码 ========
|
||||
|
||||
const [activateCode, setActivateCode] = useState('');
|
||||
const [activating, setActivating] = useState(false);
|
||||
|
||||
// ======== 支付订单状态 ========
|
||||
|
||||
const [phase, setPhase] = useState<VipPhase>('form');
|
||||
const [vipOrderId, setVipOrderId] = useState<string | null>(null);
|
||||
const [vipOrderStatus, setVipOrderStatus] = useState<VipOrderStatusValue | null>(null);
|
||||
const [vipPayUrl, setVipPayUrl] = useState<string | null>(null);
|
||||
|
||||
// ======== 工具函数 ========
|
||||
|
||||
const clearError = useCallback((name: string) => {
|
||||
setErrors((prev) => {
|
||||
if (!(name in prev)) return prev;
|
||||
const next = {...prev};
|
||||
delete next[name];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ======== 获取套餐列表 ========
|
||||
|
||||
const fetchLevels = useCallback(async () => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setLoading(true);
|
||||
setFetchError(null);
|
||||
try {
|
||||
const data = await VipAPI.getVipLevels(controller.signal);
|
||||
if (mountedRef.current) {
|
||||
setLevels(data.filter((l) => l.is_active && l.image));
|
||||
}
|
||||
} catch (err) {
|
||||
// axios 拦截器将取消错误转为 RequestError(CANCELLED),非原始 AbortError
|
||||
if (err instanceof RequestError && err.type === RequestErrorType.CANCELLED) return;
|
||||
logger.warn('ui', '加载VIP套餐列表失败', err instanceof Error ? err : new Error(String(err)));
|
||||
if (mountedRef.current) {
|
||||
setFetchError((err as Error)?.message || '加载套餐列表失败');
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
void fetchLevels();
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [fetchLevels]);
|
||||
|
||||
// ======== 校验 ========
|
||||
|
||||
const validate = useCallback((): Record<string, string> => {
|
||||
const errs: Record<string, string> = {};
|
||||
|
||||
if (selectedLevelId === null) {
|
||||
errs.selectedLevel = '请选择会员套餐';
|
||||
}
|
||||
|
||||
if (!agreedToTerms) {
|
||||
errs.agreedToTerms = '请阅读并同意用户服务协议';
|
||||
}
|
||||
|
||||
return errs;
|
||||
}, [selectedLevelId, agreedToTerms]);
|
||||
|
||||
// ======== 提交 ========
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const validationErrors = validate();
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await VipAPI.createVipOrder({
|
||||
vip_level_id: selectedLevelId!,
|
||||
payment_channel: 'alipay',
|
||||
});
|
||||
|
||||
// 保存订单信息,进入待支付阶段
|
||||
setVipOrderId(res.id);
|
||||
setVipOrderStatus(VipOrderStatus.Pending);
|
||||
setVipPayUrl(res.pay_url);
|
||||
setPhase(VipOrderStatus.Pending);
|
||||
|
||||
window.open(res.pay_url ?? '', '_blank');
|
||||
void message.success('订单已创建,请在新页面中完成支付');
|
||||
} catch (err: unknown) {
|
||||
logger.warn('ui', 'VIP创建订单失败', err instanceof Error ? err : new Error(String(err)));
|
||||
message.error((err as Error)?.message || '创建订单失败,请重试');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [selectedLevelId, validate, message]);
|
||||
|
||||
// ======== 激活码提交 ========
|
||||
|
||||
const handleActivate = useCallback(async () => {
|
||||
const code = activateCode.trim();
|
||||
if (!code) {
|
||||
message.warning('请输入激活码');
|
||||
return;
|
||||
}
|
||||
|
||||
setActivating(true);
|
||||
try {
|
||||
await VipAPI.activateVipCode({code});
|
||||
void message.success('激活成功!VIP 权益已生效');
|
||||
emit(EVENTS.CLOSE_PAGE);
|
||||
} catch (err: unknown) {
|
||||
logger.warn('ui', 'VIP激活失败', err instanceof Error ? err : new Error(String(err)));
|
||||
message.error((err as Error)?.message || '激活失败,请检查激活码是否正确');
|
||||
} finally {
|
||||
setActivating(false);
|
||||
}
|
||||
}, [activateCode, message]);
|
||||
|
||||
// ======== 操作回调 ========
|
||||
|
||||
// ======== 订单状态轮询 ========
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== VipOrderStatus.Pending || !vipOrderId) return;
|
||||
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
let cancelled = false;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const order = await VipAPI.getVipOrder(vipOrderId);
|
||||
if (cancelled) return;
|
||||
|
||||
const newStatus = order.status;
|
||||
setVipOrderStatus(newStatus);
|
||||
|
||||
if (newStatus === VipOrderStatus.Pending) {
|
||||
timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||||
} else {
|
||||
setPhase(newStatus);
|
||||
if (newStatus === VipOrderStatus.Paid) {
|
||||
void message.success('支付成功!VIP 权益已生效');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) timer = setTimeout(poll, POLL_INTERVAL_MS * 2);
|
||||
}
|
||||
};
|
||||
|
||||
timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [phase, vipOrderId, message]);
|
||||
|
||||
// ======== 页面锁定(待支付时禁止关闭容器) ========
|
||||
|
||||
useEffect(() => {
|
||||
const locked = phase === VipOrderStatus.Pending;
|
||||
emit(EVENTS.PAGE_LOCK, { locked });
|
||||
return () => {
|
||||
if (locked) emit(EVENTS.PAGE_LOCK, { locked: false });
|
||||
};
|
||||
}, [phase]);
|
||||
|
||||
// ======== 操作回调 ========
|
||||
|
||||
/** 重新下单:回到表单,保留套餐选择 */
|
||||
const handleRetry = useCallback(() => {
|
||||
setPhase('form');
|
||||
setVipOrderId(null);
|
||||
setVipOrderStatus(null);
|
||||
setVipPayUrl(null);
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
emit(EVENTS.CLOSE_PAGE);
|
||||
}, []);
|
||||
|
||||
// ================================================================
|
||||
// 渲染:加载态
|
||||
// ================================================================
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{display: 'flex', justifyContent: 'center', padding: '48px 0'}}>
|
||||
<Spin size="medium">
|
||||
<div style={{padding: 24, textAlign: 'center'}}>
|
||||
<Text type="secondary" style={{fontSize: 13}}>加载套餐列表…</Text>
|
||||
</div>
|
||||
</Spin>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 渲染:错误态
|
||||
// ================================================================
|
||||
|
||||
if (fetchError) {
|
||||
return (
|
||||
<div style={{padding: '8px 0'}}>
|
||||
<Result
|
||||
status="error"
|
||||
title="套餐加载失败"
|
||||
subTitle={fetchError}
|
||||
extra={
|
||||
<Button type="primary" size="small" onClick={fetchLevels}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 渲染:支付状态视图(非 form / activate 阶段)
|
||||
// ================================================================
|
||||
|
||||
if (phase !== 'form' && phase !== 'activate') {
|
||||
const statusText = vipOrderStatus ? EnumResolver.vip.orderStatus.label(vipOrderStatus) : '';
|
||||
const isPending = phase === VipOrderStatus.Pending;
|
||||
const isPaid = phase === VipOrderStatus.Paid;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ padding: '16px 0' }}>
|
||||
<Result
|
||||
status={vipOrderStatus ? getResultStatus(vipOrderStatus) : 'info'}
|
||||
title={
|
||||
isPaid
|
||||
? '支付成功'
|
||||
: isPending
|
||||
? `等待支付(${statusText})`
|
||||
: statusText
|
||||
}
|
||||
subTitle={
|
||||
isPaid
|
||||
? 'VIP 会员权益已生效'
|
||||
: isPending
|
||||
? '请在新打开的浏览器页面中完成支付'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* ---- 订单信息卡片 ---- */}
|
||||
{isPending && (
|
||||
<div
|
||||
style={{
|
||||
background: isDark ? '#2E2A5A' : '#F5F3FF',
|
||||
padding: '12px 16px',
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
textAlign: 'left',
|
||||
maxWidth: 360,
|
||||
margin: '0 auto 16px',
|
||||
}}
|
||||
>
|
||||
<Space orientation="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Text style={{ fontSize: 12 }} type="secondary">
|
||||
订单编号:<Text copyable style={{ fontSize: 12 }}>{vipOrderId}</Text>
|
||||
</Text>
|
||||
<Text style={{ fontSize: 13 }}>
|
||||
<DollarOutlined style={{ marginRight: 6, color: token.colorPrimary }} />
|
||||
支付金额:
|
||||
<Text strong>
|
||||
¥{(() => {
|
||||
const lvl = levels.find((l) => l.id === selectedLevelId);
|
||||
return lvl ? centToYuan(lvl.price_cent) : '—';
|
||||
})()}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text style={{ fontSize: 13 }}>
|
||||
<CrownOutlined style={{ marginRight: 6, color: token.colorSuccess }} />
|
||||
套餐:
|
||||
<Text strong>{levels.find((l) => l.id === selectedLevelId)?.name ?? '—'}</Text>
|
||||
</Text>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- 操作按钮 ---- */}
|
||||
<Space size={12}>
|
||||
{isPending && vipPayUrl && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DollarOutlined />}
|
||||
onClick={() => window.open(vipPayUrl, '_blank')}
|
||||
>
|
||||
打开支付页面
|
||||
</Button>
|
||||
)}
|
||||
{!isPending && !isPaid && (
|
||||
<Button type="primary" onClick={handleRetry}>
|
||||
重新下单
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleClose}>
|
||||
{isPaid ? '完成' : '关闭'}
|
||||
</Button>
|
||||
</Space>
|
||||
</Result>
|
||||
</div>
|
||||
|
||||
{/* ---- 协议弹窗(待支付阶段仍可查看) ---- */}
|
||||
<LegalTextModal
|
||||
open={legalOpen}
|
||||
type="service"
|
||||
onClose={() => setLegalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 渲染:正常
|
||||
// ================================================================
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{padding: '4px 0'}}>
|
||||
<div>
|
||||
<Text type="secondary" style={{fontSize: 13, marginBottom: 10, display: 'block'}}>
|
||||
<GiftOutlined style={{marginRight: 4}} />
|
||||
使用激活码
|
||||
</Text>
|
||||
<Space.Compact style={{width: '100%'}}>
|
||||
<Input
|
||||
value={activateCode}
|
||||
onChange={(e) => setActivateCode(e.target.value)}
|
||||
placeholder="输入 VIP 激活码"
|
||||
allowClear
|
||||
onPressEnter={() => void handleActivate()}
|
||||
style={{flex: 1}}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<GiftOutlined />}
|
||||
loading={activating}
|
||||
onClick={() => void handleActivate()}
|
||||
disabled={!activateCode.trim()}
|
||||
>
|
||||
激活
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</div>
|
||||
<Divider style={{margin: '20px 0 16px'}} />
|
||||
<Text type="secondary" style={{fontSize: 13, marginBottom: 12, display: 'block'}}>
|
||||
选择会员套餐
|
||||
</Text>
|
||||
|
||||
{/* 双列布局:左列套餐选择 | 右列权益图库 */}
|
||||
<div style={{display: 'flex', gap: 12, marginBottom: 16}}>
|
||||
{/* ---- 左列:套餐选择 ---- */}
|
||||
<div style={{flex: '0 0 52%', minWidth: 0}}>
|
||||
<Radio.Group
|
||||
value={selectedLevelId}
|
||||
onChange={(e) => {
|
||||
setSelectedLevelId(e.target.value);
|
||||
clearError('selectedLevel');
|
||||
}}
|
||||
style={{width: '100%'}}
|
||||
>
|
||||
<Space orientation="vertical" style={{width: '100%'}} size={6}>
|
||||
{levels.map((level) => (
|
||||
<Radio
|
||||
key={level.id}
|
||||
value={level.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${
|
||||
selectedLevelId === level.id
|
||||
? token.colorPrimary
|
||||
: isDark
|
||||
? '#2E2A5A'
|
||||
: '#E5E7EB'
|
||||
}`,
|
||||
background:
|
||||
selectedLevelId === level.id
|
||||
? isDark
|
||||
? '#2E2A5A'
|
||||
: '#F5F3FF'
|
||||
: isDark
|
||||
? '#1A1730'
|
||||
: '#FFFFFF',
|
||||
width: '100%',
|
||||
margin: 0,
|
||||
transition: 'border-color 0.2s, background 0.2s',
|
||||
}}
|
||||
>
|
||||
<div style={{flex: 1, marginLeft: 8, minWidth: 0}}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'baseline',
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
<Text strong style={{fontSize: 13}}>
|
||||
{level.name}
|
||||
</Text>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: token.colorPrimary,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
¥{centToYuan(level.price_cent)}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{display: 'flex', gap: 8, flexWrap: 'wrap'}}>
|
||||
<Text type="secondary" style={{fontSize: 11}}>
|
||||
{level.duration_days}天
|
||||
</Text>
|
||||
{level.gift_balance_cent > 0 && (
|
||||
<Text type="secondary" style={{fontSize: 11}}>
|
||||
赠{centToYuan(level.gift_balance_cent)}积分
|
||||
</Text>
|
||||
)}
|
||||
<Text type="secondary" style={{fontSize: 11}}>
|
||||
{EnumResolver.vip.targetAccountType.label(level.target_account_type)}
|
||||
</Text>
|
||||
</div>
|
||||
{level.description && (
|
||||
<Text type="secondary" style={{fontSize: 10, display: 'block', marginTop: 2}}>
|
||||
{level.description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Radio>
|
||||
))}
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
|
||||
{errors.selectedLevel && (
|
||||
<Text type="danger" style={{fontSize: 12, marginTop: 4, display: 'block'}}>
|
||||
{errors.selectedLevel}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ---- 右列:权益图库 ---- */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<Text type="secondary" style={{fontSize: 11, textAlign: 'center'}}>
|
||||
权益说明(点击放大)
|
||||
</Text>
|
||||
{levels.map((level) => (
|
||||
<div
|
||||
key={level.id}
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
overflow: 'hidden',
|
||||
border: `2px solid ${
|
||||
selectedLevelId === level.id
|
||||
? token.colorPrimary
|
||||
: 'transparent'
|
||||
}`,
|
||||
opacity: selectedLevelId === null || selectedLevelId === level.id ? 1 : 0.45,
|
||||
transition: 'border-color 0.2s, opacity 0.2s',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={level.image!}
|
||||
alt={`${level.name} 权益`}
|
||||
width="100%"
|
||||
style={{display: 'block'}}
|
||||
preview={{mask: '查看大图'}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---- Part 2:支付协议 + 确认开通 ---- */}
|
||||
<div style={{marginBottom: 16}}>
|
||||
<Checkbox
|
||||
checked={agreedToTerms}
|
||||
onChange={(e) => {
|
||||
setAgreedToTerms(e.target.checked);
|
||||
clearError('agreedToTerms');
|
||||
}}
|
||||
>
|
||||
<Text style={{fontSize: 12}}>
|
||||
我已阅读并同意
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => setLegalOpen(true)}
|
||||
style={{
|
||||
padding: '0 2px',
|
||||
fontSize: 12,
|
||||
height: 'auto',
|
||||
lineHeight: 'inherit',
|
||||
}}
|
||||
>
|
||||
《用户服务协议》
|
||||
</Button>
|
||||
</Text>
|
||||
</Checkbox>
|
||||
{errors.agreedToTerms && (
|
||||
<Text type="danger" style={{fontSize: 12, marginTop: 4, display: 'block'}}>
|
||||
{errors.agreedToTerms}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div style={{display: 'flex', justifyContent: 'flex-end', gap: 8}}>
|
||||
<Button onClick={handleClose}>取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CrownOutlined />}
|
||||
loading={submitting}
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={selectedLevelId === null}
|
||||
>
|
||||
确认开通
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---- 协议弹窗 ---- */}
|
||||
<LegalTextModal
|
||||
open={legalOpen}
|
||||
type="service"
|
||||
onClose={() => setLegalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 '@/services/modules/home/banner';
|
||||
|
||||
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);
|
||||
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 '@/services/modules/announcement/announcement';
|
||||
export type { Announcement, AnnouncementType, AnnouncementTypeValue, AnnouncementListResponse } from '@/services/modules/announcement/announcement';
|
||||
export { AnnouncementTypeEnum, AnnouncementTypeLabelMap, AnnouncementTypeColorMap } from '@/services/modules/announcement/announcement';
|
||||
@@ -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 '@/services/modules/announcement/announcement';
|
||||
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={
|
||||
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 '@/services/modules/auth/auth';
|
||||
export type { LoginResponseBody } from '@/services/modules/auth/auth';
|
||||
export { initAuthRefresh } from '@/services/auth-token';
|
||||
export { getAutoLoginFlag, clearAutoLoginFlag } from '@/services/auth-persistence';
|
||||
@@ -25,28 +25,5 @@ export interface RegisterFormValues {
|
||||
referral_code: string;
|
||||
}
|
||||
|
||||
// ---------- 字段配置(数据驱动 UI 渲染) ----------
|
||||
|
||||
export interface FieldRule {
|
||||
required?: boolean;
|
||||
minLen?: number;
|
||||
maxLen?: number;
|
||||
pattern?: RegExp;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type InputType = 'text' | 'password' | 'tel';
|
||||
|
||||
export interface FieldConfig {
|
||||
name: string;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
inputType: InputType;
|
||||
rules: FieldRule[];
|
||||
/** 仅注册表单的选填字段 */
|
||||
optional?: boolean;
|
||||
/** 附带操作按钮标识 */
|
||||
extraAction?: 'send-sms';
|
||||
/** 冷却秒数(发送验证码按钮用) */
|
||||
cooldown?: number;
|
||||
}
|
||||
// 从 ui/ 重新导出通用字段类型(保持向后兼容)
|
||||
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/AppProvider';
|
||||
import { useTheme } from '@/components/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/device';
|
||||
import type { LoginFormValues, RegisterFormValues } from './types';
|
||||
import { useAuthState } from '../hooks/use-auth-state';
|
||||
import { AuthAPI, type LoginResponseBody } from '@/services/modules/auth/auth';
|
||||
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/ThemeProvider';
|
||||
import { AuthAPI } from '@/services/modules/auth';
|
||||
import { validatePassword, validateConfirmPassword, runFieldChecks } from '../config/validators';
|
||||
import { useTheme } from '@/app/providers/ThemeProvider';
|
||||
import { AuthAPI } from '@/services/modules/auth/auth';
|
||||
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/ThemeProvider';
|
||||
import { AuthAPI } from '@/services/modules/auth';
|
||||
import { useTheme } from '@/app/providers/ThemeProvider';
|
||||
import { AuthAPI } from '@/services/modules/auth/auth';
|
||||
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,
|
||||
},
|
||||
];
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user