feat: 去除构建时版控变量 + 窗口标题动态版控 + 退出登录保留缓存 (0.0.24)
版控体系重构(13 文件):
- 移除 build.mjs --personal/--enterprise 参数和 EDITION 环境变量
- 统一安装包文件名:船长-HeiXiu-{os}-{版本号}-Setup.{ext}
- 简化 package.json 构建脚本,移除 :enterprise 变体
- scripts/*.mjs 不再接受 edition 参数
- 移除 shared/constants/app.ts 的 parseEdition()
窗口标题动态版控:
- 新格式:船长-HeiXiu-{os}-{版本号}(登录前)/ ·{版控} 后缀(登录后)
- 链路:AppProvider(account_type) → useEffect → appRuntime IPC → main process
- 移除 updater.ts 更新检查 API 的 edition 查询参数
本地存储生命周期修正:
- 退出登录不再自动清空缓存(user_id 隔离,重新登录命中缓存)
- clearUserStorage 添加 isOpen() 竞态守卫
- 移除 closeDatabase() 避免退出→重新登录后数据库不可用
- 手动「清理缓存」按钮保留
文档:
- CHANGELOG.md 新增 0.0.24 条目
- TODO.md 新增已完成项
This commit is contained in:
@@ -9,9 +9,5 @@ VITE_API_BASE_URL=https://www.heixiu.net
|
|||||||
# 主进程 — 更新检查 API(指向本地测试服务器)
|
# 主进程 — 更新检查 API(指向本地测试服务器)
|
||||||
UPDATE_API_URL=http://127.0.0.1:8000
|
UPDATE_API_URL=http://127.0.0.1:8000
|
||||||
|
|
||||||
# 版本类型
|
|
||||||
VITE_EDITION=personal
|
|
||||||
EDITION=personal
|
|
||||||
|
|
||||||
# 应用标题后缀
|
# 应用标题后缀
|
||||||
VITE_APP_TITLE=船长·HeiXiu
|
VITE_APP_TITLE=船长·HeiXiu
|
||||||
|
|||||||
@@ -10,9 +10,5 @@ VITE_API_BASE_URL=https://www.heixiu.net
|
|||||||
# 主进程 — 更新检查 API(生产服务器)
|
# 主进程 — 更新检查 API(生产服务器)
|
||||||
UPDATE_API_URL=https://www.heixiu.net
|
UPDATE_API_URL=https://www.heixiu.net
|
||||||
|
|
||||||
# 版本类型
|
|
||||||
VITE_EDITION=personal
|
|
||||||
EDITION=personal
|
|
||||||
|
|
||||||
# 应用标题后缀
|
# 应用标题后缀
|
||||||
VITE_APP_TITLE=船长·HeiXiu
|
VITE_APP_TITLE=船长·HeiXiu
|
||||||
|
|||||||
51
CHANGELOG.md
51
CHANGELOG.md
@@ -1,6 +1,57 @@
|
|||||||
# 更新日志
|
# 更新日志
|
||||||
|
|
||||||
> 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。
|
> 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。
|
||||||
|
---
|
||||||
|
## 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)
|
## 0.0.22(2026-06-18)
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,9 @@
|
|||||||
|
|
||||||
- 进行任务时都需要考虑安全、性能、主题切换、业务自定义错误、事件总线驱动方式以及日志记录;
|
- 进行任务时都需要考虑安全、性能、主题切换、业务自定义错误、事件总线驱动方式以及日志记录;
|
||||||
- 进行系统级别的操作时,需要考虑系统差异,并处理兼容性。如WINDOWS、MAC OS;
|
- 进行系统级别的操作时,需要考虑系统差异,并处理兼容性。如WINDOWS、MAC OS;
|
||||||
- 处理业务时,需要同时考虑兜底机制和业务自定义错误捕获;
|
- 处理业务时,需要同时考虑兜底机制和业务自定义错误捕获,比如后端在修改了某些数据的枚举值但没有通知时,需要能够正常转化对用户友好的提示信息;
|
||||||
- 所有设计思路、代码实现均需要考虑以上三个条件。
|
- 新增文件时,需要注意各个文件夹当前平铺的文件数量,如果过多需要考虑合并文件或者合并为子文件夹;
|
||||||
|
- 所有设计思路、代码实现均需要考虑以上四个条件。
|
||||||
|
|
||||||
# 相关文档说明
|
# 相关文档说明
|
||||||
|
|
||||||
|
|||||||
70
PROJECT.md
70
PROJECT.md
@@ -66,12 +66,13 @@
|
|||||||
│ └── main/ # 主进程核心模块
|
│ └── main/ # 主进程核心模块
|
||||||
│ │ ├── load-env.ts # .env.{mode} + .env.local 加载器
|
│ │ ├── load-env.ts # .env.{mode} + .env.local 加载器
|
||||||
│ │ ├── logger.ts # 日志核心:JSON Lines / 每日轮转 / 批量冲刷
|
│ │ ├── logger.ts # 日志核心:JSON Lines / 每日轮转 / 批量冲刷
|
||||||
│ │ ├── log-ipc.ts # 渲染进程日志 → 主进程日志桥接
|
|
||||||
│ │ ├── net-request.ts # Electron net.request 的 axios 风格封装
|
│ │ ├── net-request.ts # Electron net.request 的 axios 风格封装
|
||||||
│ │ ├── safe-storage-ipc.ts # safeStorage 加密/解密 IPC handler
|
|
||||||
│ │ ├── storage-ipc.ts # 沙箱文件存储 IPC handler(heixiu-data/)
|
|
||||||
│ │ ├── updater.ts # 自动更新:自定义 Provider + 平台 Updater
|
│ │ ├── updater.ts # 自动更新:自定义 Provider + 平台 Updater
|
||||||
│ │ ├── menu/index.ts # 系统菜单(macOS Edit / Win 无菜单)
|
│ │ ├── 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/ # 平台判断 / 图标路径
|
│ │ └── utils/ # 平台判断 / 图标路径
|
||||||
│ └── preload/ # 预加载脚本(contextBridge 桥接层)
|
│ └── preload/ # 预加载脚本(contextBridge 桥接层)
|
||||||
│ ├── device.ts # 硬件指纹采集(wmic/powershell | ioreg/sysctl)
|
│ ├── device.ts # 硬件指纹采集(wmic/powershell | ioreg/sysctl)
|
||||||
@@ -85,32 +86,39 @@
|
|||||||
│ │ │ ├── AppProvider.tsx # 认证状态(login/logout/Token 刷新)
|
│ │ │ ├── AppProvider.tsx # 认证状态(login/logout/Token 刷新)
|
||||||
│ │ │ ├── ThemeProvider.tsx # 亮/暗主题切换 + antd ConfigProvider
|
│ │ │ ├── ThemeProvider.tsx # 亮/暗主题切换 + antd ConfigProvider
|
||||||
│ │ │ └── SettingsProvider.tsx # 本地持久化设置(存储路径等)
|
│ │ │ └── SettingsProvider.tsx # 本地持久化设置(存储路径等)
|
||||||
│ │ ├── shell/ # 应用外壳
|
│ │ ├── frame/ # 应用框架(外层结构 + 页面调度)
|
||||||
│ │ │ ├── Layout.tsx # 全高 flex 列布局壳
|
│ │ │ ├── Layout.tsx # 全高 flex 列布局壳
|
||||||
│ │ │ ├── PageDispatcher.tsx # 通用页面调度器(Modal/Drawer/FloatingPanel)
|
│ │ │ ├── PageDispatcher.tsx # 通用页面调度器(Modal/Drawer/FloatingPanel)
|
||||||
│ │ │ └── navbar/ # 导航栏(nav-config → NavBar → NavItem)
|
│ │ │ └── navbar/ # 导航栏(nav-config → NavBar → NavItem)
|
||||||
│ │ └── ui/ # 通用 UI 原子组件
|
│ │ └── ui/ # 通用 UI 原子组件
|
||||||
│ │ ├── FloatingPanel.tsx # 可拖拽非模态浮动面板
|
│ │ ├── FloatingPanel.tsx # 可拖拽非模态浮动面板
|
||||||
│ │ └── LegalTextModal.tsx # 法律/协议文本 Modal
|
│ │ ├── LegalTextModal.tsx # 法律/协议文本 Modal
|
||||||
|
│ │ ├── FormField.tsx # 通用表单字段渲染器(数据驱动,从 auth/components 提升)
|
||||||
|
│ │ └── types.ts # FieldConfig / FieldRule / InputType 通用类型
|
||||||
│ ├── hooks/ # 自定义 Hooks
|
│ ├── hooks/ # 自定义 Hooks
|
||||||
│ │ ├── use-async.ts # 通用异步基础设施(竞态/取消/门控)
|
│ │ ├── use-async.ts # 通用异步基础设施(竞态/取消/门控)
|
||||||
│ │ ├── use-api.ts # 业务 API Hooks(Banner/模型/任务/账户/财务)
|
│ │ ├── use-api.ts # 业务 API Hooks(Banner/模型/任务/账户/财务)
|
||||||
│ │ └── use-updater.ts # 更新状态机(单例 IPC 监听)
|
│ │ └── use-updater.ts # 更新状态机(单例 IPC 监听)
|
||||||
│ ├── infrastructure/ # 基础设施层
|
│ ├── infrastructure/ # 基础设施层
|
||||||
│ │ └── storage/ # 本地加密数据库(sql.js + safeStorage + IPC)
|
│ │ └── storage/ # 本地加密数据库(core/ + stores/ + services/)
|
||||||
│ │ ├── db-core.ts # sql.js WASM 初始化 + 通用 CRUD
|
│ │ ├── index.ts # 统一入口:init/clear/stats + 全部 re-export
|
||||||
│ │ ├── db-encrypt.ts # 序列化 → safeStorage 加密 → IPC 写磁盘
|
|
||||||
│ │ ├── schema.ts # 6 张业务表 DDL(含索引)
|
|
||||||
│ │ ├── task-store.ts # 任务记录本地 CRUD
|
|
||||||
│ │ ├── order-store.ts # 订单记录本地 CRUD(充值 + VIP)
|
|
||||||
│ │ ├── param-store.ts # 参数历史快照(每模型最多 20 条)
|
|
||||||
│ │ ├── media-store.ts # 媒体缓存元数据管理
|
|
||||||
│ │ ├── media-loader.ts # 媒体下载与三级缓存策略
|
|
||||||
│ │ ├── settings-store.ts # 应用设置 key-value 存储
|
|
||||||
│ │ ├── sync-manager.ts # 分页同步游标管理
|
|
||||||
│ │ ├── user-scope.ts # 用户作用域隔离(JWT user_id)
|
|
||||||
│ │ ├── ipc-file.ts # 渲染进程端文件操作桥接
|
│ │ ├── ipc-file.ts # 渲染进程端文件操作桥接
|
||||||
│ │ └── index.ts # 统一入口:init/clear/stats
|
│ │ ├── core/ # 数据库核心(4 文件)
|
||||||
|
│ │ │ ├── db-core.ts # sql.js WASM 初始化 + 通用 CRUD
|
||||||
|
│ │ │ ├── db-encrypt.ts # 序列化 → safeStorage 加密 → IPC 写磁盘
|
||||||
|
│ │ │ ├── schema.ts # 6 张业务表 DDL(含索引)
|
||||||
|
│ │ │ └── types.ts # 实体类型定义
|
||||||
|
│ │ ├── stores/ # 实体 CRUD Store(5 文件)
|
||||||
|
│ │ │ ├── task-store.ts # 任务记录
|
||||||
|
│ │ │ ├── order-store.ts # 订单记录(充值 + VIP)
|
||||||
|
│ │ │ ├── param-store.ts # 参数历史快照
|
||||||
|
│ │ │ ├── media-store.ts # 媒体缓存元数据
|
||||||
|
│ │ │ └── settings-store.ts # 应用设置 key-value
|
||||||
|
│ │ └── services/ # 高级服务(4 文件)
|
||||||
|
│ │ ├── media-loader.ts # 媒体下载与三级缓存
|
||||||
|
│ │ ├── sync-manager.ts # 分页同步游标
|
||||||
|
│ │ ├── data-sync.ts # 数据同步辅助(从 settings/sections 归位)
|
||||||
|
│ │ └── user-scope.ts # 用户作用域隔离(JWT user_id)
|
||||||
│ ├── pages/ # 业务页面(按功能模块组织)
|
│ ├── pages/ # 业务页面(按功能模块组织)
|
||||||
│ │ ├── auth/ # 认证模块
|
│ │ ├── auth/ # 认证模块
|
||||||
│ │ │ ├── index.tsx # LoginPage Modal(登录/注册 Tabs)
|
│ │ │ ├── index.tsx # LoginPage Modal(登录/注册 Tabs)
|
||||||
@@ -123,7 +131,13 @@
|
|||||||
│ │ │ ├── HomeContext.tsx # 共享状态(选中模型/任务/分页)
|
│ │ │ ├── HomeContext.tsx # 共享状态(选中模型/任务/分页)
|
||||||
│ │ │ ├── panels/ # 三列面板容器(Left/Center/Right)
|
│ │ │ ├── panels/ # 三列面板容器(Left/Center/Right)
|
||||||
│ │ │ ├── features/ # 功能模块(Banner/ModelSelector/ModelInputForm/TaskHistory/OutputPreview)
|
│ │ │ ├── features/ # 功能模块(Banner/ModelSelector/ModelInputForm/TaskHistory/OutputPreview)
|
||||||
│ │ │ ├── controls/ # Widget 控件体系(12 种 Qt→React 映射 + WIDGET_MAP)
|
│ │ │ ├── controls/ # Widget 控件体系(6 种 Qt→React 映射 + WIDGET_MAP)
|
||||||
|
│ │ │ │ ├── types.ts # WidgetProps 统一接口
|
||||||
|
│ │ │ │ ├── index.ts # WIDGET_MAP 注册表
|
||||||
|
│ │ │ │ ├── simple-widgets.tsx # 6 个简单控件(Checkbox/LineInput/TextInput/ComboBox/SpinBox/DoubleSpinBox)
|
||||||
|
│ │ │ │ ├── upload-widgets.tsx # 单文件上传(ImageInput/VideoInput,共享 enable_switch)
|
||||||
|
│ │ │ │ ├── list-upload-widgets.tsx # 多文件拖拽上传(ImageListInput/AudioListInput)
|
||||||
|
│ │ │ │ └── SeedanceContent.tsx # 复合内容编排
|
||||||
│ │ │ └── hooks/ # useModelUI(param_schema → UIFieldConfig)
|
│ │ │ └── hooks/ # useModelUI(param_schema → UIFieldConfig)
|
||||||
│ │ ├── panels/ # NavBar 触发的功能面板页
|
│ │ ├── panels/ # NavBar 触发的功能面板页
|
||||||
│ │ │ ├── AccountInfo.tsx # 账户信息(用户/积分/套餐/统计)
|
│ │ │ ├── AccountInfo.tsx # 账户信息(用户/积分/套餐/统计)
|
||||||
@@ -158,18 +172,14 @@
|
|||||||
│ │ ├── transitions.css # 主题切换过渡(View Transitions API + CSS fallback)
|
│ │ ├── transitions.css # 主题切换过渡(View Transitions API + CSS fallback)
|
||||||
│ │ ├── base.css # 全局重置(盒模型/滚动条/聚焦环/拖拽区域)
|
│ │ ├── base.css # 全局重置(盒模型/滚动条/聚焦环/拖拽区域)
|
||||||
│ │ └── globals.css # 入口文件(按序导入以上 CSS)
|
│ │ └── globals.css # 入口文件(按序导入以上 CSS)
|
||||||
│ └── utils/ # 渲染进程工具函数
|
│ └── utils/ # 渲染进程工具函数(7 文件,已按功能合并)
|
||||||
│ ├── event-bus.ts # 事件总线(on/off/emit + 日志审计钩子)
|
│ ├── env.ts # 运行时环境:平台检测 + 设备 ID(← platform + device)
|
||||||
│ ├── ipc.ts # IPC 安全守卫(hasIpc → safeIpcOn/send/invoke)
|
│ ├── bus.ts # 通信层:事件总线 + IPC 守卫 + 日志(← event-bus + ipc + logger)
|
||||||
│ ├── logger.ts # 渲染进程日志(IPC 转发 | DEV 控制台)
|
│ ├── display.ts # 展示/格式化:金额、日期、Logo、Nullable<T>(← display + logo + type)
|
||||||
│ ├── platform.ts # 平台判定(isMacOS/isWindows/isLinux)
|
|
||||||
│ ├── device.ts # 设备 ID 生成与持久化(UUID v4)
|
|
||||||
│ ├── safe-storage.ts # 加密 localStorage 封装(safeStorage 桥接)
|
|
||||||
│ ├── logo.ts # Logo 资产 URL 解析
|
|
||||||
│ ├── pricing.ts # 成本计算(Python 六种定价模式移植)
|
|
||||||
│ ├── enum-resolver.ts # 枚举外观模式(7 模块标签/颜色统一解析)
|
│ ├── enum-resolver.ts # 枚举外观模式(7 模块标签/颜色统一解析)
|
||||||
│ ├── display.ts # 金额/日期格式化
|
│ ├── pricing.ts # 成本计算(Python 六种定价模式移植)
|
||||||
│ └── type.ts # Nullable<T> 类型别名
|
│ ├── safe-storage.ts # 加密 localStorage 封装(safeStorage 桥接)
|
||||||
|
│ └── media-upload.ts # 媒体上传共享工具(← controls/media-upload-utils)
|
||||||
├── shared/ # 主进程 & 渲染进程共享
|
├── shared/ # 主进程 & 渲染进程共享
|
||||||
│ ├── constants/
|
│ ├── constants/
|
||||||
│ │ ├── app.ts # 应用名称/版本类型/窗口标题
|
│ │ ├── app.ts # 应用名称/版本类型/窗口标题
|
||||||
|
|||||||
10
TODO.md
10
TODO.md
@@ -1,6 +1,14 @@
|
|||||||
# TODO — 船长·HeiXiu 待办与优化清单
|
# TODO — 船长·HeiXiu 待办与优化清单
|
||||||
|
|
||||||
> 最后更新:2026-06-18
|
> 最后更新:2026-06-22
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 近期完成
|
||||||
|
|
||||||
|
- **构建时 EDITION 变量已移除**:版控全部收敛到后端 `account_type` 运行时判断,不再区分个人版/企业版构建产物
|
||||||
|
- **窗口标题动态版控**:登录后通过 IPC 追加 `·个人版`/`·企业版` 后缀
|
||||||
|
- **退出登录不再自动清空本地缓存**:`user_id` 隔离 + 手动清理按钮
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
60
build.mjs
60
build.mjs
@@ -8,10 +8,6 @@
|
|||||||
// --mac macOS
|
// --mac macOS
|
||||||
// --linux Linux
|
// --linux Linux
|
||||||
//
|
//
|
||||||
// 版本(可选,默认 personal):
|
|
||||||
// --personal 个人版
|
|
||||||
// --enterprise 企业版
|
|
||||||
//
|
|
||||||
// 操作(可选组合,默认只执行 --build):
|
// 操作(可选组合,默认只执行 --build):
|
||||||
// --build 仅构建(默认)
|
// --build 仅构建(默认)
|
||||||
// --clean 构建前清理 release/ dist/ dist-electron/
|
// --clean 构建前清理 release/ dist/ dist-electron/
|
||||||
@@ -25,10 +21,10 @@
|
|||||||
// --cert-password <p> 证书密码
|
// --cert-password <p> 证书密码
|
||||||
//
|
//
|
||||||
// 示例:
|
// 示例:
|
||||||
// node build.mjs --win --personal # 仅打包
|
// node build.mjs --win # 仅打包
|
||||||
// node build.mjs --win --personal --upload --publish # 打包 + 上传 + 发布
|
// node build.mjs --win --upload --publish # 打包 + 上传 + 发布
|
||||||
// node build.mjs --win --enterprise --all # 全流程
|
// node build.mjs --win --all # 全流程
|
||||||
// node build.mjs --win --personal --sign --cert ./cert.pfx --cert-password xxx
|
// node build.mjs --win --sign --cert ./cert.pfx --cert-password xxx
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
@@ -55,8 +51,6 @@ const opts = {
|
|||||||
win: false,
|
win: false,
|
||||||
mac: false,
|
mac: false,
|
||||||
linux: false,
|
linux: false,
|
||||||
personal: false,
|
|
||||||
enterprise: false,
|
|
||||||
clean: false,
|
clean: false,
|
||||||
build: false,
|
build: false,
|
||||||
sign: false,
|
sign: false,
|
||||||
@@ -73,8 +67,6 @@ while (i < args.length) {
|
|||||||
case '--win': opts.win = true; break;
|
case '--win': opts.win = true; break;
|
||||||
case '--mac': opts.mac = true; break;
|
case '--mac': opts.mac = true; break;
|
||||||
case '--linux': opts.linux = 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 '--clean': opts.clean = true; break;
|
||||||
case '--build': opts.build = true; break;
|
case '--build': opts.build = true; break;
|
||||||
case '--sign': opts.sign = true; break;
|
case '--sign': opts.sign = true; break;
|
||||||
@@ -109,21 +101,12 @@ if (platformCount !== 1) {
|
|||||||
process.exit(1);
|
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
|
// 默认执行 build
|
||||||
if (!opts.clean && !opts.build && !opts.sign && !opts.upload && !opts.publish) {
|
if (!opts.clean && !opts.build && !opts.sign && !opts.upload && !opts.publish) {
|
||||||
opts.build = true;
|
opts.build = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const platform = opts.win ? 'win32' : opts.mac ? 'darwin' : 'linux';
|
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 pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
|
||||||
const version = pkg.version;
|
const version = pkg.version;
|
||||||
@@ -162,10 +145,9 @@ function step(title) {
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const electronBuilderPlatform = opts.win ? '--win' : opts.mac ? '--mac' : '--linux';
|
const electronBuilderPlatform = opts.win ? '--win' : opts.mac ? '--mac' : '--linux';
|
||||||
const editionEnv = { EDITION: edition };
|
|
||||||
|
|
||||||
console.log('┌──────────────────────────────────────┐');
|
console.log('┌──────────────────────────────────────┐');
|
||||||
console.log(`│ HeiXiu 构建 v${version} ${platform}/${edition}`.padEnd(45) + '│');
|
console.log(`│ HeiXiu 构建 v${version} ${platform}`.padEnd(45) + '│');
|
||||||
console.log('└──────────────────────────────────────┘');
|
console.log('└──────────────────────────────────────┘');
|
||||||
console.log(`操作: ${[
|
console.log(`操作: ${[
|
||||||
opts.clean && '清理',
|
opts.clean && '清理',
|
||||||
@@ -195,33 +177,33 @@ async function main() {
|
|||||||
|
|
||||||
// 2a. TypeScript 编译
|
// 2a. TypeScript 编译
|
||||||
console.log('[tsc] 类型检查...');
|
console.log('[tsc] 类型检查...');
|
||||||
await run('npx', ['tsc'], { env: editionEnv });
|
await run('npx', ['tsc']);
|
||||||
|
|
||||||
// 2b. Vite 构建
|
// 2b. Vite 构建
|
||||||
console.log('[vite] 打包前端 + Electron...');
|
console.log('[vite] 打包前端 + Electron...');
|
||||||
await run('npx', ['vite', 'build'], { env: editionEnv });
|
await run('npx', ['vite', 'build']);
|
||||||
|
|
||||||
// 2c. electron-builder 打包
|
// 2c. electron-builder 打包
|
||||||
console.log(`[electron-builder] ${electronBuilderPlatform} 打包...`);
|
console.log(`[electron-builder] ${electronBuilderPlatform} 打包...`);
|
||||||
await run('npx', ['electron-builder', electronBuilderPlatform], {
|
await run('npx', ['electron-builder', electronBuilderPlatform], {
|
||||||
env: { ...editionEnv, ...signEnv },
|
env: { ...signEnv },
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2d. 生成 update-info.json
|
// 2d. 生成 update-info.json
|
||||||
console.log('[info] 生成 update-info.json...');
|
console.log('[info] 生成 update-info.json...');
|
||||||
await runScript('generate-update-info', platform, edition, version);
|
await runScript('generate-update-info', platform, version);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 3: 上传 OSS ──
|
// ── Step 3: 上传 OSS ──
|
||||||
if (opts.upload) {
|
if (opts.upload) {
|
||||||
step('3/ 上传 OSS');
|
step('3/ 上传 OSS');
|
||||||
await runScript('upload-oss', platform, edition, version);
|
await runScript('upload-oss', platform, version);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 4: 发布到 API ──
|
// ── Step 4: 发布到 API ──
|
||||||
if (opts.publish) {
|
if (opts.publish) {
|
||||||
step('4/ 发布版本到 API');
|
step('4/ 发布版本到 API');
|
||||||
await runScript('publish-release', platform, edition, version);
|
await runScript('publish-release', platform, version);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('\n✅ 全部完成');
|
console.log('\n✅ 全部完成');
|
||||||
@@ -256,11 +238,6 @@ function printHelp() {
|
|||||||
row('--mac', 'macOS');
|
row('--mac', 'macOS');
|
||||||
row('--linux', 'Linux');
|
row('--linux', 'Linux');
|
||||||
|
|
||||||
// 版本
|
|
||||||
hdr('版本(可选,默认 --personal)');
|
|
||||||
row('--personal', '个人版');
|
|
||||||
row('--enterprise', '企业版');
|
|
||||||
|
|
||||||
// 操作
|
// 操作
|
||||||
hdr('操作(可选组合,不指定则默认 --build)');
|
hdr('操作(可选组合,不指定则默认 --build)');
|
||||||
row('--build', '构建应用(tsc + vite + electron-builder + info)');
|
row('--build', '构建应用(tsc + vite + electron-builder + info)');
|
||||||
@@ -278,15 +255,15 @@ function printHelp() {
|
|||||||
|
|
||||||
// 示例
|
// 示例
|
||||||
box('示例');
|
box('示例');
|
||||||
console.log(` ${dim('# 日常开发')}`);
|
console.log(` ${dim('# 日常构建')}`);
|
||||||
console.log(` ${bold('node build.mjs --win --personal')}`);
|
console.log(` ${bold('node build.mjs --win')}`);
|
||||||
console.log(` ${bold('npm run build:win')} ${dim('— 等效')}`);
|
console.log(` ${bold('npm run build:win')} ${dim('— 等效')}`);
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(` ${dim('# 打包 + 上传 OSS + 发布到 API')}`);
|
console.log(` ${dim('# 打包 + 上传 OSS + 发布到 API')}`);
|
||||||
console.log(` ${bold('node build.mjs --win --personal --upload --publish')}`);
|
console.log(` ${bold('node build.mjs --win --upload --publish')}`);
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(` ${dim('# 全流程(清理 → 构建 → 签名 → 上传 → 发布)')}`);
|
console.log(` ${dim('# 全流程(清理 → 构建 → 签名 → 上传 → 发布)')}`);
|
||||||
console.log(` ${bold('node build.mjs --win --enterprise --all --cert ./cert.pfx --cert-password xxx')}`);
|
console.log(` ${bold('node build.mjs --win --all --cert ./cert.pfx --cert-password xxx')}`);
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(` ${dim('# 仅清理')}`);
|
console.log(` ${dim('# 仅清理')}`);
|
||||||
console.log(` ${bold('npm run build:clean')}`);
|
console.log(` ${bold('npm run build:clean')}`);
|
||||||
@@ -305,10 +282,9 @@ function printHelp() {
|
|||||||
|
|
||||||
// 快捷命令
|
// 快捷命令
|
||||||
box('npm 快捷命令');
|
box('npm 快捷命令');
|
||||||
console.log(` ${bold('npm run build:win')} ${dim('→ node build.mjs --win --personal --build')}`);
|
console.log(` ${bold('npm run build:win')} ${dim('→ node build.mjs --win --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 --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 --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 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 upload:oss')} ${dim('→ node scripts/upload-oss.mjs')}`);
|
||||||
console.log(` ${bold('npm run publish:release')} ${dim('→ node scripts/publish-release.mjs')}`);
|
console.log(` ${bold('npm run publish:release')} ${dim('→ node scripts/publish-release.mjs')}`);
|
||||||
|
|||||||
5
electron/electron-env.d.ts
vendored
5
electron/electron-env.d.ts
vendored
@@ -29,4 +29,9 @@ declare interface Window {
|
|||||||
encrypt(plaintext: string): Promise<string | null>;
|
encrypt(plaintext: string): Promise<string | null>;
|
||||||
decrypt(encryptedBase64: string): Promise<string | null>;
|
decrypt(encryptedBase64: string): Promise<string | null>;
|
||||||
};
|
};
|
||||||
|
/** 应用运行时 API */
|
||||||
|
appRuntime: {
|
||||||
|
/** 登录/登出后更新窗口标题中的版控后缀 */
|
||||||
|
setWindowEdition(edition: string | null): void;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,15 @@ import path from 'node:path';
|
|||||||
|
|
||||||
import {getPlatform, isMacOS} from './main/utils/platform';
|
import {getPlatform, isMacOS} from './main/utils/platform';
|
||||||
import {getWindowIconPath} from './main/utils/logo';
|
import {getWindowIconPath} from './main/utils/logo';
|
||||||
import {buildWindowTitle, parseEdition} from '../shared/constants/app';
|
import {buildWindowTitle} from '../shared/constants/app';
|
||||||
import {loadEnvFile} from './main/load-env';
|
import {loadEnvFile} from './main/load-env';
|
||||||
import {initUpdater, registerUpdateIpcHandlers} from './main/updater';
|
import {initUpdater, registerUpdateIpcHandlers} from './main/updater';
|
||||||
import {setupAppMenu} from './main/menu';
|
import {setupAppMenu} from './main/menu';
|
||||||
import {initLogger, flushLogger, logger} from './main/logger';
|
import {initLogger, flushLogger, logger} from './main/logger';
|
||||||
import {registerLogIpcHandlers} from './main/log-ipc';
|
import {registerLogIpcHandlers} from './main/ipc/log-ipc';
|
||||||
import {registerSafeStorageIpcHandlers} from './main/safe-storage-ipc';
|
import {registerSafeStorageIpcHandlers} from './main/ipc/safe-storage-ipc';
|
||||||
import {registerStorageIpcHandlers} from './main/storage-ipc';
|
import {registerStorageIpcHandlers} from './main/ipc/storage-ipc';
|
||||||
import {BIDIRECTIONAL} from '../shared/constants/ipc-channels';
|
import {BIDIRECTIONAL, RENDERER_TO_MAIN} from '../shared/constants/ipc-channels';
|
||||||
|
|
||||||
createRequire(import.meta.url);
|
createRequire(import.meta.url);
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -34,8 +34,8 @@ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
|
|||||||
|
|
||||||
// ---------- 平台 & 版本信息 ----------
|
// ---------- 平台 & 版本信息 ----------
|
||||||
const currentPlatform = getPlatform();
|
const currentPlatform = getPlatform();
|
||||||
const currentEdition = parseEdition(process.env.EDITION);
|
/** 基础标题(无版控后缀),登录后通过 IPC 追加 */
|
||||||
const WINDOW_TITLE = buildWindowTitle(currentPlatform, currentEdition);
|
const WINDOW_TITLE = buildWindowTitle(currentPlatform);
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 全局未捕获异常(主进程兜底记录)
|
// 全局未捕获异常(主进程兜底记录)
|
||||||
@@ -174,6 +174,15 @@ app.whenReady().then(() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 窗口标题版控后缀(渲染进程登录/登出后调用)
|
||||||
|
ipcMain.on(RENDERER_TO_MAIN.SET_WINDOW_TITLE, (_event, edition: string | null) => {
|
||||||
|
if (!mainWindow) return;
|
||||||
|
const title = edition
|
||||||
|
? buildWindowTitle(currentPlatform, undefined, edition as 'personal' | 'enterprise')
|
||||||
|
: buildWindowTitle(currentPlatform);
|
||||||
|
mainWindow.setTitle(title);
|
||||||
|
});
|
||||||
|
|
||||||
// 始终打开主窗口
|
// 始终打开主窗口
|
||||||
// 登录/注册由渲染进程内的 Modal 弹层处理,不再新开窗口
|
// 登录/注册由渲染进程内的 Modal 弹层处理,不再新开窗口
|
||||||
const win = createMainWindow();
|
const win = createMainWindow();
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { ipcMain } from 'electron';
|
import { ipcMain } from 'electron';
|
||||||
import { RENDERER_TO_MAIN } from '../../shared/constants/ipc-channels';
|
import { RENDERER_TO_MAIN } from '../../../shared/constants/ipc-channels';
|
||||||
import { writeRendererLog } from './logger';
|
import { writeRendererLog } from '../logger';
|
||||||
import type { LogEntry } from '../../shared/types/logging';
|
import type { LogEntry } from '../../../shared/types/logging';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注册日志 IPC 监听器
|
* 注册日志 IPC 监听器
|
||||||
@@ -16,8 +16,8 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { ipcMain, safeStorage } from 'electron';
|
import { ipcMain, safeStorage } from 'electron';
|
||||||
import { BIDIRECTIONAL } from '../../shared/constants/ipc-channels';
|
import { BIDIRECTIONAL } from '../../../shared/constants/ipc-channels';
|
||||||
import { logger } from './logger';
|
import { logger } from '../logger';
|
||||||
|
|
||||||
/** 检查 safeStorage 是否可用,不可用时记录警告 */
|
/** 检查 safeStorage 是否可用,不可用时记录警告 */
|
||||||
function checkAvailability(): boolean {
|
function checkAvailability(): boolean {
|
||||||
@@ -16,8 +16,8 @@
|
|||||||
import { ipcMain, shell, app } from 'electron';
|
import { ipcMain, shell, app } from 'electron';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { BIDIRECTIONAL } from '../../shared/constants/ipc-channels';
|
import { BIDIRECTIONAL } from '../../../shared/constants/ipc-channels';
|
||||||
import { logger } from './logger';
|
import { logger } from '../logger';
|
||||||
|
|
||||||
// ---------- 内部工具 ----------
|
// ---------- 内部工具 ----------
|
||||||
|
|
||||||
@@ -77,7 +77,6 @@ class HeiXiuProvider extends Provider<UpdateInfo> {
|
|||||||
params: {
|
params: {
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
version: APP_VERSION,
|
version: APP_VERSION,
|
||||||
edition: process.env.EDITION || 'personal',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ipcRenderer, contextBridge } from 'electron';
|
import { ipcRenderer, contextBridge } from 'electron';
|
||||||
import { BIDIRECTIONAL } from '../shared/constants/ipc-channels';
|
import { BIDIRECTIONAL, RENDERER_TO_MAIN } from '../shared/constants/ipc-channels';
|
||||||
|
|
||||||
// --------- Expose some API to the Renderer process ---------
|
// --------- Expose some API to the Renderer process ---------
|
||||||
contextBridge.exposeInMainWorld('ipcRenderer', {
|
contextBridge.exposeInMainWorld('ipcRenderer', {
|
||||||
@@ -87,3 +87,14 @@ contextBridge.exposeInMainWorld('fileStorage', {
|
|||||||
.then((r: { success: boolean; data: boolean }) => r?.success ? r.data : false);
|
.then((r: { success: boolean; data: boolean }) => r?.success ? r.data : false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --------- 应用运行时 API ---------
|
||||||
|
contextBridge.exposeInMainWorld('appRuntime', {
|
||||||
|
/**
|
||||||
|
* 登录/登出后更新窗口标题中的版控后缀
|
||||||
|
* @param edition - 'personal' | 'enterprise' | null(null 清除后缀)
|
||||||
|
*/
|
||||||
|
setWindowEdition(edition: string | null): void {
|
||||||
|
ipcRenderer.send(RENDERER_TO_MAIN.SET_WINDOW_TITLE, edition);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
17
package.json
17
package.json
@@ -7,13 +7,10 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "chcp 65001 > null && cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ vite",
|
"dev": "chcp 65001 > null && cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ vite",
|
||||||
"build": "node build.mjs --win --personal --build",
|
"build": "node build.mjs --win --build",
|
||||||
"build:win": "node build.mjs --win --personal --build",
|
"build:win": "node build.mjs --win --build",
|
||||||
"build:win:enterprise": "node build.mjs --win --enterprise --build",
|
"build:mac": "node build.mjs --mac --build",
|
||||||
"build:mac": "node build.mjs --mac --personal --build",
|
"build:linux": "node build.mjs --linux --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",
|
"build:clean": "node scripts/clean.mjs",
|
||||||
"upload:oss": "node scripts/upload-oss.mjs",
|
"upload:oss": "node scripts/upload-oss.mjs",
|
||||||
"publish:release": "node scripts/publish-release.mjs",
|
"publish:release": "node scripts/publish-release.mjs",
|
||||||
@@ -62,21 +59,21 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"artifactName": "${productName}-Windows-${version}-${env.EDITION}-Setup.${ext}"
|
"artifactName": "${productName}-Windows-${version}-Setup.${ext}"
|
||||||
},
|
},
|
||||||
"mac": {
|
"mac": {
|
||||||
"icon": "src/assets/logo/heixiu.icns",
|
"icon": "src/assets/logo/heixiu.icns",
|
||||||
"target": [
|
"target": [
|
||||||
"dmg"
|
"dmg"
|
||||||
],
|
],
|
||||||
"artifactName": "${productName}-Mac-${version}-${env.EDITION}-Installer.${ext}"
|
"artifactName": "${productName}-Mac-${version}-Installer.${ext}"
|
||||||
},
|
},
|
||||||
"linux": {
|
"linux": {
|
||||||
"icon": "src/assets/logo/HX.png",
|
"icon": "src/assets/logo/HX.png",
|
||||||
"target": [
|
"target": [
|
||||||
"AppImage"
|
"AppImage"
|
||||||
],
|
],
|
||||||
"artifactName": "${productName}-Linux-${version}-${env.EDITION}.${ext}"
|
"artifactName": "${productName}-Linux-${version}.${ext}"
|
||||||
},
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
// 打包后自动生成 update-info.json
|
// 打包后自动生成 update-info.json
|
||||||
//
|
//
|
||||||
// 用法:
|
// 用法:
|
||||||
// node scripts/generate-update-info.mjs <platform> <edition> [version] [--url <url>]
|
// node scripts/generate-update-info.mjs <platform> [version] [--url <url>]
|
||||||
//
|
//
|
||||||
// 两种用法:
|
// 两种用法:
|
||||||
// 方式 A(预判 URL):打包后自动拼接 URL → 适用于 OSS 路径可预测时
|
// 方式 A(预判 URL):打包后自动拼接 URL → 适用于 OSS 路径可预测时
|
||||||
// node scripts/generate-update-info.mjs win32 personal
|
// node scripts/generate-update-info.mjs win32
|
||||||
//
|
//
|
||||||
// 方式 B(指定 URL):上传后用实际 URL 重新生成 → 适用于上传后才知道 URL 时
|
// 方式 B(指定 URL):上传后用实际 URL 重新生成 → 适用于上传后才知道 URL 时
|
||||||
// node scripts/generate-update-info.mjs win32 personal 0.1.0 --url "https://oss.../xxx.exe"
|
// node scripts/generate-update-info.mjs win32 0.1.0 --url "https://oss.../xxx.exe"
|
||||||
//
|
//
|
||||||
// 环境变量(可选):
|
// 环境变量(可选):
|
||||||
// UPDATE_DOWNLOAD_BASE — 方式 A 的 URL 基地址,默认 https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases
|
// UPDATE_DOWNLOAD_BASE — 方式 A 的 URL 基地址,默认 https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases
|
||||||
@@ -45,17 +45,16 @@ for (let i = 0; i < args.length; i++) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const platform = positional[0];
|
const platform = positional[0];
|
||||||
const edition = positional[1];
|
|
||||||
|
|
||||||
if (!platform || !edition) {
|
if (!platform) {
|
||||||
console.error('用法: node scripts/generate-update-info.mjs <platform> <edition> [version] [--url <url>]');
|
console.error('用法: node scripts/generate-update-info.mjs <platform> [version] [--url <url>]');
|
||||||
console.error('示例: node scripts/generate-update-info.mjs win32 personal');
|
console.error('示例: node scripts/generate-update-info.mjs win32');
|
||||||
console.error(' node scripts/generate-update-info.mjs win32 personal 0.1.0 --url "https://..."');
|
console.error(' node scripts/generate-update-info.mjs win32 0.1.0 --url "https://..."');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
|
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
|
||||||
const version = positional[2] || pkg.version;
|
const version = positional[1] || pkg.version;
|
||||||
const productName = pkg.build?.productName || '船长·HeiXiu';
|
const productName = pkg.build?.productName || '船长·HeiXiu';
|
||||||
|
|
||||||
// ---------- 查找安装包 ----------
|
// ---------- 查找安装包 ----------
|
||||||
@@ -190,7 +189,6 @@ const updateInfo = {
|
|||||||
// --- 附加:方便后端录入 ---
|
// --- 附加:方便后端录入 ---
|
||||||
_meta: {
|
_meta: {
|
||||||
platform,
|
platform,
|
||||||
edition,
|
|
||||||
fileName,
|
fileName,
|
||||||
blockmap: blockMapUrl ? { url: blockMapUrl, size: blockMapSize } : null,
|
blockmap: blockMapUrl ? { url: blockMapUrl, size: blockMapSize } : null,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
@@ -199,7 +197,7 @@ const updateInfo = {
|
|||||||
|
|
||||||
const outputPath = join(
|
const outputPath = join(
|
||||||
releaseDir,
|
releaseDir,
|
||||||
`${productName}-${platform}-${edition}-v${version}-update-info.json`,
|
`${productName}-${platform}-v${version}-update-info.json`,
|
||||||
);
|
);
|
||||||
|
|
||||||
writeFileSync(outputPath, JSON.stringify(updateInfo, null, 2), 'utf-8');
|
writeFileSync(outputPath, JSON.stringify(updateInfo, null, 2), 'utf-8');
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// 发布版本信息到后端 API
|
// 发布版本信息到后端 API
|
||||||
//
|
//
|
||||||
// 用法:
|
// 用法:
|
||||||
// node scripts/publish-release.mjs <platform> <edition> [version] [--url <url>]
|
// node scripts/publish-release.mjs <platform> [version] [--url <url>]
|
||||||
//
|
//
|
||||||
// 环境变量:
|
// 环境变量:
|
||||||
// UPDATE_API_URL — API 基地址(默认 https://www.heixiu.com)
|
// UPDATE_API_URL — API 基地址(默认 https://www.heixiu.com)
|
||||||
@@ -35,16 +35,15 @@ for (let i = 0; i < args.length; i++) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const platform = positional[0];
|
const platform = positional[0];
|
||||||
const edition = positional[1];
|
|
||||||
|
|
||||||
if (!platform || !edition) {
|
if (!platform) {
|
||||||
console.error('用法: node scripts/publish-release.mjs <platform> <edition> [version] [--url <url>]');
|
console.error('用法: node scripts/publish-release.mjs <platform> [version] [--url <url>]');
|
||||||
console.error('示例: node scripts/publish-release.mjs win32 personal 0.1.0');
|
console.error('示例: node scripts/publish-release.mjs win32 0.1.0');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
|
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
|
||||||
const version = positional[2] || pkg.version;
|
const version = positional[1] || pkg.version;
|
||||||
|
|
||||||
// ---------- 读取 update-info.json ----------
|
// ---------- 读取 update-info.json ----------
|
||||||
|
|
||||||
@@ -63,9 +62,9 @@ if (jsonOverride) {
|
|||||||
try {
|
try {
|
||||||
const all = readdirSync(releaseDir);
|
const all = readdirSync(releaseDir);
|
||||||
infoFile = all.find((f) =>
|
infoFile = all.find((f) =>
|
||||||
f.includes(platform) && f.includes(edition) && f.endsWith('-update-info.json'),
|
f.includes(platform) && f.endsWith('-update-info.json'),
|
||||||
);
|
);
|
||||||
if (!infoFile) console.error(`未找到 ${platform}+${edition} 的 update-info.json`);
|
if (!infoFile) console.error(`未找到 ${platform} 的 update-info.json`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`错误: ${releaseDir} — ${err.message}`);
|
console.error(`错误: ${releaseDir} — ${err.message}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -81,7 +80,6 @@ const apiKey = process.env.UPDATE_API_KEY || '';
|
|||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
platform,
|
platform,
|
||||||
edition,
|
|
||||||
latestVersion: updateInfo.latestVersion || version,
|
latestVersion: updateInfo.latestVersion || version,
|
||||||
downloadUrl: updateInfo.downloadUrl,
|
downloadUrl: updateInfo.downloadUrl,
|
||||||
fileSize: updateInfo.fileSize,
|
fileSize: updateInfo.fileSize,
|
||||||
@@ -98,7 +96,7 @@ console.log('━━━━━━━━━━━━━━━━━━━━━━
|
|||||||
console.log('📡 发布版本到 API');
|
console.log('📡 发布版本到 API');
|
||||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||||
console.log(`POST ${endpoint}`);
|
console.log(`POST ${endpoint}`);
|
||||||
console.log(`版本: ${version} (${platform}/${edition})`);
|
console.log(`版本: ${version} (${platform})`);
|
||||||
console.log(`URL: ${payload.downloadUrl}\n`);
|
console.log(`URL: ${payload.downloadUrl}\n`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// 上传安装包 + blockmap 文件到阿里云 OSS
|
// 上传安装包 + blockmap 文件到阿里云 OSS
|
||||||
//
|
//
|
||||||
// 用法:
|
// 用法:
|
||||||
// node scripts/upload-oss.mjs <platform> <edition> [version]
|
// node scripts/upload-oss.mjs <platform> [version]
|
||||||
//
|
//
|
||||||
// 环境变量(使用 Node.js 直传时需要):
|
// 环境变量(使用 Node.js 直传时需要):
|
||||||
// OSS_ENDPOINT — OSS endpoint(如 oss-cn-hangzhou.aliyuncs.com)
|
// OSS_ENDPOINT — OSS endpoint(如 oss-cn-hangzhou.aliyuncs.com)
|
||||||
@@ -15,12 +15,12 @@
|
|||||||
//
|
//
|
||||||
// 示例:
|
// 示例:
|
||||||
// # 仅打印上传命令
|
// # 仅打印上传命令
|
||||||
// node scripts/upload-oss.mjs win32 personal 0.1.0
|
// node scripts/upload-oss.mjs win32 0.1.0
|
||||||
//
|
//
|
||||||
// # 直接上传
|
// # 直接上传
|
||||||
// OSS_BUCKET=heixiu OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com \
|
// OSS_BUCKET=heixiu OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com \
|
||||||
// OSS_ACCESS_KEY_ID=xxx OSS_ACCESS_KEY_SECRET=xxx \
|
// OSS_ACCESS_KEY_ID=xxx OSS_ACCESS_KEY_SECRET=xxx \
|
||||||
// node scripts/upload-oss.mjs win32 personal 0.1.0
|
// node scripts/upload-oss.mjs win32 0.1.0
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import {readFileSync, readdirSync} from 'node:fs';
|
import {readFileSync, readdirSync} from 'node:fs';
|
||||||
@@ -34,16 +34,15 @@ const ROOT = join(__dirname, '..');
|
|||||||
// ---------- 参数 ----------
|
// ---------- 参数 ----------
|
||||||
|
|
||||||
const platform = process.argv[2];
|
const platform = process.argv[2];
|
||||||
const edition = process.argv[3];
|
|
||||||
|
|
||||||
if (!platform || !edition) {
|
if (!platform) {
|
||||||
console.error('用法: node scripts/upload-oss.mjs <platform> <edition> [version]');
|
console.error('用法: node scripts/upload-oss.mjs <platform> [version]');
|
||||||
console.error('示例: node scripts/upload-oss.mjs win32 personal 0.1.0');
|
console.error('示例: node scripts/upload-oss.mjs win32 0.1.0');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
|
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
|
||||||
const version = process.argv[4] || pkg.version;
|
const version = process.argv[3] || pkg.version;
|
||||||
|
|
||||||
// ---------- 查找安装包 ----------
|
// ---------- 查找安装包 ----------
|
||||||
|
|
||||||
@@ -132,15 +131,15 @@ if (!OSS_ENDPOINT || !OSS_BUCKET || !OSS_ACCESS_KEY_ID) {
|
|||||||
|
|
||||||
console.log('上传后执行以下命令,用实际 URL 重新生成 info:');
|
console.log('上传后执行以下命令,用实际 URL 重新生成 info:');
|
||||||
if (blockmapUrl) {
|
if (blockmapUrl) {
|
||||||
console.log(` node scripts/generate-update-info.mjs ${platform} ${edition} ${version} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`);
|
console.log(` node scripts/generate-update-info.mjs ${platform} ${version} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`);
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('或直接发布到后端 API:');
|
console.log('或直接发布到后端 API:');
|
||||||
console.log(` node scripts/publish-release.mjs ${platform} ${edition} ${version} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`);
|
console.log(` node scripts/publish-release.mjs ${platform} ${version} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`);
|
||||||
} else {
|
} else {
|
||||||
console.log(` node scripts/generate-update-info.mjs ${platform} ${edition} ${version} --url "${downloadUrl}"`);
|
console.log(` node scripts/generate-update-info.mjs ${platform} ${version} --url "${downloadUrl}"`);
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('或直接发布到后端 API:');
|
console.log('或直接发布到后端 API:');
|
||||||
console.log(` node scripts/publish-release.mjs ${platform} ${edition} ${version} --url "${downloadUrl}"`);
|
console.log(` node scripts/publish-release.mjs ${platform} ${version} --url "${downloadUrl}"`);
|
||||||
}
|
}
|
||||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -238,11 +237,11 @@ if (blockmapPath) {
|
|||||||
// 后续步骤提示
|
// 后续步骤提示
|
||||||
console.log('\n接下来 — 重新生成 update-info.json:');
|
console.log('\n接下来 — 重新生成 update-info.json:');
|
||||||
if (blockmapUrl) {
|
if (blockmapUrl) {
|
||||||
console.log(` node scripts/generate-update-info.mjs ${platform} ${edition} ${version} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`);
|
console.log(` node scripts/generate-update-info.mjs ${platform} ${version} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`);
|
||||||
console.log('\n或直接发布到后端 API:');
|
console.log('\n或直接发布到后端 API:');
|
||||||
console.log(` node scripts/publish-release.mjs ${platform} ${edition} ${version} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`);
|
console.log(` node scripts/publish-release.mjs ${platform} ${version} --url "${downloadUrl}" --blockmap-url "${blockmapUrl}"`);
|
||||||
} else {
|
} else {
|
||||||
console.log(` node scripts/generate-update-info.mjs ${platform} ${edition} ${version} --url "${downloadUrl}"`);
|
console.log(` node scripts/generate-update-info.mjs ${platform} ${version} --url "${downloadUrl}"`);
|
||||||
console.log('\n或直接发布到后端 API:');
|
console.log('\n或直接发布到后端 API:');
|
||||||
console.log(` node scripts/publish-release.mjs ${platform} ${edition} ${version} --url "${downloadUrl}"`);
|
console.log(` node scripts/publish-release.mjs ${platform} ${version} --url "${downloadUrl}"`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,25 +6,18 @@
|
|||||||
import type { Edition, Platform } from '../types';
|
import type { Edition, Platform } from '../types';
|
||||||
import { APP_VERSION } from './version';
|
import { APP_VERSION } from './version';
|
||||||
|
|
||||||
/** 应用显示名称 */
|
/** 应用显示名称(文件/标题通用前缀) */
|
||||||
export const APP_NAME = '船长·HeiXiu';
|
export const APP_NAME = '船长-HeiXiu';
|
||||||
|
|
||||||
// APP_VERSION 从 ./version.ts 导入(读取 package.json),不再硬编码
|
// APP_VERSION 从 ./version.ts 导入(读取 package.json),不再硬编码
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 版本类型(企业版 / 个人版)
|
// 版本类型(企业版 / 个人版)
|
||||||
|
//
|
||||||
|
// 注意:edition 由后端 account_type 运行时决定,不由构建变量控制。
|
||||||
|
// 不再需要 parseEdition() —— 构建时 EDITION 环境变量已移除。
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析版本类型字符串
|
|
||||||
* @param raw - 环境变量值,如 "enterprise" / undefined
|
|
||||||
* @returns 版本类型,默认 personal
|
|
||||||
*/
|
|
||||||
export function parseEdition(raw: string | undefined): Edition {
|
|
||||||
if (raw === 'enterprise') return 'enterprise';
|
|
||||||
return 'personal';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 版本中文名称 */
|
/** 版本中文名称 */
|
||||||
export function getEditionName(edition: Edition): string {
|
export function getEditionName(edition: Edition): string {
|
||||||
return edition === 'enterprise' ? '企业版' : '个人版';
|
return edition === 'enterprise' ? '企业版' : '个人版';
|
||||||
@@ -42,18 +35,24 @@ function platformShortName(platform: Platform): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建完整窗口标题
|
* 构建窗口标题
|
||||||
* 格式:船长·HeiXiu V{版本号} {平台}{版本}
|
*
|
||||||
* 示例:"船长·HeiXiu V0.0.1 MAC企业版"
|
* 格式(无版控):船长-HeiXiu-{平台}-{版本号}
|
||||||
* "船长·HeiXiu V0.0.1 Windows个人版"
|
* 示例:船长-HeiXiu-Windows-0.0.23
|
||||||
|
*
|
||||||
|
* 格式(含版控):船长-HeiXiu-{平台}-{版本号}·{版控}
|
||||||
|
* 示例:船长-HeiXiu-Windows-0.0.23·企业版
|
||||||
*
|
*
|
||||||
* @param platform - 当前操作系统平台
|
* @param platform - 当前操作系统平台
|
||||||
* @param edition - 版本类型
|
|
||||||
* @param version - 可选覆盖版本号(默认 APP_VERSION)
|
* @param version - 可选覆盖版本号(默认 APP_VERSION)
|
||||||
|
* @param edition - 可选版本类型(登录后追加,未登录不显示)
|
||||||
*/
|
*/
|
||||||
export function buildWindowTitle(platform: Platform, edition: Edition, version?: string): string {
|
export function buildWindowTitle(platform: Platform, version?: string, edition?: Edition): string {
|
||||||
const ver = version ?? APP_VERSION;
|
const ver = version ?? APP_VERSION;
|
||||||
const os = platformShortName(platform);
|
const os = platformShortName(platform);
|
||||||
const edName = getEditionName(edition);
|
const base = `${APP_NAME}-${os}-${ver}`;
|
||||||
return `${APP_NAME} V${ver} ${os}${edName}`;
|
if (edition) {
|
||||||
|
return `${base}·${getEditionName(edition)}`;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { useState, useEffect, useCallback } from 'react';
|
|||||||
import { useAppContext } from '@/components/providers/AppProvider';
|
import { useAppContext } from '@/components/providers/AppProvider';
|
||||||
import { LoginPage } from './pages/auth';
|
import { LoginPage } from './pages/auth';
|
||||||
import { SettingsPage } from './pages/settings';
|
import { SettingsPage } from './pages/settings';
|
||||||
import { on, off, EVENTS } from './utils/event-bus';
|
import { on, off, EVENTS } from './utils/bus';
|
||||||
import { Layout } from '@/components/shell/Layout';
|
import { Layout } from '@/components/frame/Layout';
|
||||||
import { HomePage } from '@/pages/home/HomePage';
|
import { HomePage } from '@/pages/home/HomePage';
|
||||||
import { PageDispatcher } from '@/components/shell/PageDispatcher';
|
import { PageDispatcher } from '@/components/frame/PageDispatcher';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根组件 — 主窗口
|
* 根组件 — 主窗口
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
import {type ComponentType, useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
import {type ComponentType, useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||||
import {Drawer, Modal} from 'antd';
|
import {Drawer, Modal} from 'antd';
|
||||||
import {EVENTS, off, on} from '@/utils/event-bus';
|
import {EVENTS, off, on} from '@/utils/bus';
|
||||||
import {FloatingPanel} from '@/components/ui/FloatingPanel';
|
import {FloatingPanel} from '@/components/ui/FloatingPanel';
|
||||||
|
|
||||||
import {WechatWorkPage} from '@/pages/panels/WechatWorkPage';
|
import {WechatWorkPage} from '@/pages/panels/WechatWorkPage';
|
||||||
@@ -12,7 +12,7 @@ import {App} from 'antd';
|
|||||||
|
|
||||||
import {useAppContext} from '@/components/providers/AppProvider';
|
import {useAppContext} from '@/components/providers/AppProvider';
|
||||||
import {useTheme} from '@/components/providers/ThemeProvider';
|
import {useTheme} from '@/components/providers/ThemeProvider';
|
||||||
import {emit, EVENTS} from '@/utils/event-bus.ts';
|
import {emit, EVENTS} from '@/utils/bus';
|
||||||
import {NavItem} from './NavItem';
|
import {NavItem} from './NavItem';
|
||||||
import {AnnouncementDrawer} from './AnnouncementDrawer';
|
import {AnnouncementDrawer} from './AnnouncementDrawer';
|
||||||
import {personalLeft, personalRight, enterpriseLeft, enterpriseRight} from './nav-config';
|
import {personalLeft, personalRight, enterpriseLeft, enterpriseRight} from './nav-config';
|
||||||
@@ -10,12 +10,12 @@
|
|||||||
import { createContext, useContext, useState, useMemo, useCallback, useEffect, type ReactNode } from 'react';
|
import { createContext, useContext, useState, useMemo, useCallback, useEffect, type ReactNode } from 'react';
|
||||||
import type { Platform, Edition } from '@shared/types';
|
import type { Platform, Edition } from '@shared/types';
|
||||||
|
|
||||||
import { getPlatform, isDev } from '@/utils/platform';
|
import { getPlatform, isDev } from '@/utils/env';
|
||||||
import { safeIpcOn, safeIpcOff } from '@/utils/ipc';
|
import { safeIpcOn, safeIpcOff } from '@/utils/bus';
|
||||||
import { AuthAPI, type LoginResponseBody } from '@/services/modules/auth';
|
import { AuthAPI, type LoginResponseBody } from '@/services/modules/auth';
|
||||||
import { setToken, clearToken, initTokenStore } from '@/services/request';
|
import { setToken, clearToken, initTokenStore } from '@/services/request';
|
||||||
import { emit, EVENTS } from '@/utils/event-bus';
|
import { emit, EVENTS } from '@/utils/bus';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/bus';
|
||||||
import { getAutoLoginFlag, clearAutoLoginFlag } from '@/services/auth-persistence';
|
import { getAutoLoginFlag, clearAutoLoginFlag } from '@/services/auth-persistence';
|
||||||
import {
|
import {
|
||||||
initAuthRefresh,
|
initAuthRefresh,
|
||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
clearProactiveRefresh,
|
clearProactiveRefresh,
|
||||||
} from '@/services/auth-token';
|
} from '@/services/auth-token';
|
||||||
import {initModelCache} from '@/services/cache/model-cache';
|
import {initModelCache} from '@/services/cache/model-cache';
|
||||||
import {initStorage, clearUserStorage} from '@/infrastructure/storage';
|
import {initStorage} from '@/infrastructure/storage';
|
||||||
|
|
||||||
// ---------- Context 类型 ----------
|
// ---------- Context 类型 ----------
|
||||||
|
|
||||||
@@ -87,6 +87,11 @@ export function AppProvider({ children }: AppProviderProps) {
|
|||||||
return user.account_type === 'individual' ? 'personal' : 'enterprise';
|
return user.account_type === 'individual' ? 'personal' : 'enterprise';
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
// 登录/登出后同步窗口标题版控后缀到主进程
|
||||||
|
useEffect(() => {
|
||||||
|
window.appRuntime?.setWindowEdition(isLoggedIn ? edition : null);
|
||||||
|
}, [edition, isLoggedIn]);
|
||||||
|
|
||||||
// ---------- 登录 / 退出 ----------
|
// ---------- 登录 / 退出 ----------
|
||||||
|
|
||||||
const login = useCallback(async (auth: LoginResponseBody) => {
|
const login = useCallback(async (auth: LoginResponseBody) => {
|
||||||
@@ -111,8 +116,9 @@ export function AppProvider({ children }: AppProviderProps) {
|
|||||||
setUser(null);
|
setUser(null);
|
||||||
setIsLoggedIn(false);
|
setIsLoggedIn(false);
|
||||||
emit(EVENTS.LOGOUT);
|
emit(EVENTS.LOGOUT);
|
||||||
// 异步清理本地存储(不阻塞 UI)
|
// 注意:退出登录不自动清理本地缓存
|
||||||
void clearUserStorage();
|
// —— 数据已按 user_id 隔离,同一用户重新登录后缓存仍可命中
|
||||||
|
// 用户主动清理请使用设置页面的「清理缓存」按钮
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ---------- 启动时初始化 token 存储 + 自动登录 ----------
|
// ---------- 启动时初始化 token 存储 + 自动登录 ----------
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// FormField — 单个表单字段(数据驱动渲染)
|
// FormField — 通用表单字段渲染器(数据驱动)
|
||||||
// 根据 FieldConfig 自动选择 Input / Input.Password 及附加操作按钮
|
//
|
||||||
|
// 根据 FieldConfig 自动选择 Input / Input.Password / 带短信按钮的复合控件。
|
||||||
|
// 原在 src/pages/auth/components/FormField.tsx,
|
||||||
|
// 提升到 src/components/ui/ 供跨页面复用。
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { Input, Button, Form } from 'antd';
|
import { Input, Button, Form } from 'antd';
|
||||||
import type { FieldConfig } from '../types';
|
import type { FieldConfig } from './types';
|
||||||
|
|
||||||
interface FormFieldProps {
|
interface FormFieldProps {
|
||||||
config: FieldConfig;
|
config: FieldConfig;
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (name: string, value: string) => void;
|
onChange: (name: string, value: string) => void;
|
||||||
errors: string[];
|
errors: string[];
|
||||||
/** 发送短信验证码回调 */
|
|
||||||
onSendSms?: () => void;
|
onSendSms?: () => void;
|
||||||
/** 验证码按钮冷却中 */
|
|
||||||
smsCooldown?: boolean;
|
smsCooldown?: boolean;
|
||||||
/** 冷却剩余秒数 */
|
|
||||||
smsSecondsLeft?: number;
|
smsSecondsLeft?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
26
src/components/ui/types.ts
Normal file
26
src/components/ui/types.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ui/types — 通用 UI 组件共享类型
|
||||||
|
// FieldConfig / FieldRule / InputType 原在 src/pages/auth/types.ts
|
||||||
|
// 提升到 ui/ 后供 FormField 及未来消费者共用
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { RequestError, RequestErrorType } from '@/services/types';
|
import { RequestError, RequestErrorType } from '@/services/types';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/bus';
|
||||||
|
|
||||||
// ---------- 错误信息映射 ----------
|
// ---------- 错误信息映射 ----------
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
|
||||||
import { safeIpcInvoke, safeIpcOn, safeIpcSend } from '@/utils/ipc';
|
import { safeIpcInvoke, safeIpcOn, safeIpcSend } from '@/utils/bus';
|
||||||
|
|
||||||
// ---------- 类型 ----------
|
// ---------- 类型 ----------
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
import initSqlJs, { type Database, type SqlJsStatic, type SqlValue } from 'sql.js';
|
import initSqlJs, { type Database, type SqlJsStatic, type SqlValue } from 'sql.js';
|
||||||
import { DDL_STATEMENTS, INIT_VERSION_SQL } from './schema';
|
import { DDL_STATEMENTS, INIT_VERSION_SQL } from './schema';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/bus';
|
||||||
|
|
||||||
// ---------- 内部状态 ----------
|
// ---------- 内部状态 ----------
|
||||||
|
|
||||||
@@ -11,9 +11,9 @@
|
|||||||
// 加载:IPC readFile → base64 → safeStorage.decrypt() → Uint8Array → DB
|
// 加载:IPC readFile → base64 → safeStorage.decrypt() → Uint8Array → DB
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { hasIpc } from '@/utils/ipc';
|
import { hasIpc } from '@/utils/bus';
|
||||||
import { exportDatabase } from './db-core';
|
import { exportDatabase } from './db-core';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/bus';
|
||||||
|
|
||||||
/** 数据库文件相对路径(相对于 heixiu-data/) */
|
/** 数据库文件相对路径(相对于 heixiu-data/) */
|
||||||
const DB_RELATIVE_PATH = 'heixiu.db';
|
const DB_RELATIVE_PATH = 'heixiu.db';
|
||||||
@@ -15,35 +15,35 @@
|
|||||||
// import { initStorage, getTasks, syncTasks, saveParamSnapshot } from '@/infrastructure/storage';
|
// import { initStorage, getTasks, syncTasks, saveParamSnapshot } from '@/infrastructure/storage';
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { openDatabase, closeDatabase, isOpen } from './db-core';
|
import { openDatabase, isOpen } from './core/db-core';
|
||||||
import { loadDatabase, saveDatabase } from './db-encrypt';
|
import { loadDatabase, saveDatabase } from './core/db-encrypt';
|
||||||
import { clearUserIdCache } from './user-scope';
|
import { clearUserIdCache } from './services/user-scope';
|
||||||
import { clearUserTasks, getTaskCount as _getTaskCount } from './task-store';
|
import { clearUserTasks, getTaskCount as _getTaskCount } from './stores/task-store';
|
||||||
import { clearUserOrders, getOrderCount as _getOrderCount } from './order-store';
|
import { clearUserOrders, getOrderCount as _getOrderCount } from './stores/order-store';
|
||||||
import { clearUserParams } from './param-store';
|
import { clearUserParams } from './stores/param-store';
|
||||||
import { clearUserMedia, getMediaCount as _getMediaCount, getMediaTotalSize as _getMediaTotalSize } from './media-store';
|
import { clearUserMedia, getMediaCount as _getMediaCount, getMediaTotalSize as _getMediaTotalSize } from './stores/media-store';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/bus';
|
||||||
|
|
||||||
// ---------- 重新导出 ----------
|
// ---------- 重新导出 ----------
|
||||||
|
|
||||||
export { openDatabase, closeDatabase, isOpen, exportDatabase, execute, queryAll, queryOne } from './db-core';
|
export { openDatabase, closeDatabase, isOpen, exportDatabase, execute, queryAll, queryOne } from './core/db-core';
|
||||||
export { loadDatabase, saveDatabase } from './db-encrypt';
|
export { loadDatabase, saveDatabase } from './core/db-encrypt';
|
||||||
export { getCurrentUserId, clearUserIdCache, getTodayKey, hashURL, formatBytes } from './user-scope';
|
export { getCurrentUserId, clearUserIdCache, getTodayKey, hashURL, formatBytes } from './services/user-scope';
|
||||||
export { upsertTask, syncTasks, getTasks, getTaskById, deleteTask, getTaskCount, clearUserTasks, rowToTask } from './task-store';
|
export { upsertTask, syncTasks, getTasks, getTaskById, deleteTask, getTaskCount, clearUserTasks, rowToTask } from './stores/task-store';
|
||||||
export { upsertOrder, syncRechargeOrders, syncVipOrders, getOrders, getOrderById, deleteOrder, getOrderCount, clearUserOrders } from './order-store';
|
export { upsertOrder, syncRechargeOrders, syncVipOrders, getOrders, getOrderById, deleteOrder, getOrderCount, clearUserOrders } from './stores/order-store';
|
||||||
export { saveParamSnapshot, fillOutputData, getParamByTaskId, getParamHistory, parseParams, deleteParamHistory, clearUserParams } from './param-store';
|
export { saveParamSnapshot, fillOutputData, getParamByTaskId, getParamHistory, parseParams, deleteParamHistory, clearUserParams } from './stores/param-store';
|
||||||
export { upsertMediaCache, getMediaByUrl, hasMedia, getMediaCount, getMediaTotalSize, deleteMediaByUrl, clearExpiredMedia, clearUserMedia } from './media-store';
|
export { upsertMediaCache, getMediaByUrl, hasMedia, getMediaCount, getMediaTotalSize, deleteMediaByUrl, clearExpiredMedia, clearUserMedia } from './stores/media-store';
|
||||||
export { setSetting, getSetting, getAllSettings, deleteSetting, getOutputPath, setOutputPath, getTeamRepoPath, setTeamRepoPath } from './settings-store';
|
export { setSetting, getSetting, getAllSettings, deleteSetting, getOutputPath, setOutputPath, getTeamRepoPath, setTeamRepoPath } from './stores/settings-store';
|
||||||
export { getCursor, updateCursor, hasMore, nextPage, syncedCount, totalCount, getPageSize } from './sync-manager';
|
export { getCursor, updateCursor, hasMore, nextPage, syncedCount, totalCount, getPageSize } from './services/sync-manager';
|
||||||
export { writeFile, readFile, deleteFile, makeDir, showItemInFolder, fileExists, saveBlobToFile, readFileAsBlobUrl, getDataDir } from './ipc-file';
|
export { writeFile, readFile, deleteFile, makeDir, showItemInFolder, fileExists, saveBlobToFile, readFileAsBlobUrl, getDataDir } from './ipc-file';
|
||||||
export { loadMedia, preloadMedia, isVideoMime, isImageMime, isAudioMime, revokeMediaUrl, isMediaSuccess, isMediaFailure } from './media-loader';
|
export { loadMedia, preloadMedia, isVideoMime, isImageMime, isAudioMime, revokeMediaUrl, isMediaSuccess, isMediaFailure } from './services/media-loader';
|
||||||
export type { MediaLoadResult, MediaLoadSuccess, MediaLoadFailure } from './media-loader';
|
export type { MediaLoadResult, MediaLoadSuccess, MediaLoadFailure } from './services/media-loader';
|
||||||
export type {
|
export type {
|
||||||
TaskRecordEntry, TaskQueryFilter,
|
TaskRecordEntry, TaskQueryFilter,
|
||||||
OrderRecordEntry, OrderType, OrderQueryFilter,
|
OrderRecordEntry, OrderType, OrderQueryFilter,
|
||||||
ParamHistoryEntry, MediaCacheEntry,
|
ParamHistoryEntry, MediaCacheEntry,
|
||||||
SyncCursor, SyncDataType, AppSetting,
|
SyncCursor, SyncDataType, AppSetting,
|
||||||
} from './types';
|
} from './core/types';
|
||||||
|
|
||||||
// ---------- 初始化 ----------
|
// ---------- 初始化 ----------
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ export async function initStorage(): Promise<boolean> {
|
|||||||
|
|
||||||
// 3. 清理过期媒体缓存
|
// 3. 清理过期媒体缓存
|
||||||
try {
|
try {
|
||||||
const { clearExpiredMedia } = await import('./media-store');
|
const { clearExpiredMedia } = await import('./stores/media-store');
|
||||||
clearExpiredMedia();
|
clearExpiredMedia();
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
@@ -93,8 +93,14 @@ export async function initStorage(): Promise<boolean> {
|
|||||||
|
|
||||||
// ---------- 清理 ----------
|
// ---------- 清理 ----------
|
||||||
|
|
||||||
/** 清除当前用户的所有本地数据 + 关闭数据库 */
|
/** 清除当前用户的所有本地数据并持久化空库到磁盘 */
|
||||||
export async function clearUserStorage(): Promise<void> {
|
export async function clearUserStorage(): Promise<void> {
|
||||||
|
// 守卫:数据库未初始化或已被清理,无需重复操作
|
||||||
|
if (!isOpen()) {
|
||||||
|
logger.debug('storage', '数据库未打开,跳过 clearUserStorage');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 清空所有表数据
|
// 1. 清空所有表数据
|
||||||
try {
|
try {
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
@@ -105,11 +111,8 @@ export async function clearUserStorage(): Promise<void> {
|
|||||||
]);
|
]);
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
// 2. 保存空库到磁盘(覆盖旧数据)
|
// 2. 保存空库到磁盘(覆盖旧数据,但不关闭——以便重新登录后直接使用)
|
||||||
await saveDatabase();
|
await saveDatabase();
|
||||||
|
|
||||||
// 3. 关闭数据库
|
|
||||||
closeDatabase();
|
|
||||||
clearUserIdCache();
|
clearUserIdCache();
|
||||||
logger.info('storage', '用户存储数据已清理');
|
logger.info('storage', '用户存储数据已清理');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
// media-store / db-encrypt 等均通过本模块操作文件。
|
// media-store / db-encrypt 等均通过本模块操作文件。
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { hasIpc } from '@/utils/ipc';
|
import { hasIpc } from '@/utils/bus';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/bus';
|
||||||
|
|
||||||
// ---------- 文件 API ----------
|
// ---------- 文件 API ----------
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
import { TaskAPI } from '@/services/modules/task';
|
import { TaskAPI } from '@/services/modules/task';
|
||||||
import { PaymentAPI } from '@/services/modules/payments';
|
import { PaymentAPI } from '@/services/modules/payments';
|
||||||
import { VipAPI } from '@/services/modules/vip-api';
|
import { VipAPI } from '@/services/modules/vip-api';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/bus';
|
||||||
|
|
||||||
const PAGE_SIZE = 100;
|
const PAGE_SIZE = 100;
|
||||||
|
|
||||||
@@ -16,11 +16,11 @@
|
|||||||
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { axiosInstance } from '@/services/request';
|
import { axiosInstance } from '@/services/request';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/bus';
|
||||||
import { hasIpc } from '@/utils/ipc';
|
import { hasIpc } from '@/utils/bus';
|
||||||
import { getCurrentUserId, hashURL, getTodayKey } from './user-scope';
|
import { getCurrentUserId, hashURL, getTodayKey } from './user-scope';
|
||||||
import { getMediaByUrl, upsertMediaCache, deleteMediaByUrl } from './media-store';
|
import { getMediaByUrl, upsertMediaCache, deleteMediaByUrl } from '../stores/media-store';
|
||||||
import { saveBlobToFile, readFile, fileExists } from './ipc-file';
|
import { saveBlobToFile, readFile, fileExists } from '../ipc-file';
|
||||||
|
|
||||||
// ---------- 类型 ----------
|
// ---------- 类型 ----------
|
||||||
|
|
||||||
@@ -12,8 +12,8 @@
|
|||||||
// - 不自动拉取,完全由用户手动触发
|
// - 不自动拉取,完全由用户手动触发
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { execute, queryOne } from './db-core';
|
import { execute, queryOne } from '../core/db-core';
|
||||||
import type { SyncCursor, SyncDataType } from './types';
|
import type { SyncCursor, SyncDataType } from '../core/types';
|
||||||
|
|
||||||
const PAGE_SIZE = 100;
|
const PAGE_SIZE = 100;
|
||||||
|
|
||||||
@@ -7,10 +7,10 @@
|
|||||||
// - 读取时更新访问时间(LRU 排序依据)
|
// - 读取时更新访问时间(LRU 排序依据)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { execute, queryAll, queryOne } from './db-core';
|
import { execute, queryAll, queryOne } from '../core/db-core';
|
||||||
import { getCurrentUserId, getTodayKey, hashURL } from './user-scope';
|
import { getCurrentUserId, getTodayKey, hashURL } from '../services/user-scope';
|
||||||
import type { MediaCacheEntry } from './types';
|
import type { MediaCacheEntry } from '../core/types';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/bus';
|
||||||
|
|
||||||
// ---------- API ----------
|
// ---------- API ----------
|
||||||
|
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
// infrastructure/storage/order-store.ts — 订单记录本地存储
|
// infrastructure/storage/order-store.ts — 订单记录本地存储
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { execute, queryAll, queryOne } from './db-core';
|
import { execute, queryAll, queryOne } from '../core/db-core';
|
||||||
import { getCurrentUserId } from './user-scope';
|
import { getCurrentUserId } from '../services/user-scope';
|
||||||
import type { OrderRecordEntry, OrderType, OrderQueryFilter } from './types';
|
import type { OrderRecordEntry, OrderType, OrderQueryFilter } from '../core/types';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/bus';
|
||||||
|
|
||||||
// ---------- 通用接口 ----------
|
// ---------- 通用接口 ----------
|
||||||
|
|
||||||
@@ -7,9 +7,9 @@
|
|||||||
// - 按模型查询历史参数列表(回填菜单数据源)
|
// - 按模型查询历史参数列表(回填菜单数据源)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { execute, queryAll, queryOne } from './db-core';
|
import { execute, queryAll, queryOne } from '../core/db-core';
|
||||||
import { getCurrentUserId } from './user-scope';
|
import { getCurrentUserId } from '../services/user-scope';
|
||||||
import type { ParamHistoryEntry } from './types';
|
import type { ParamHistoryEntry } from '../core/types';
|
||||||
|
|
||||||
const MAX_PER_MODEL = 20;
|
const MAX_PER_MODEL = 20;
|
||||||
|
|
||||||
@@ -7,9 +7,9 @@
|
|||||||
// - 兼容现有的 localStorage 设置逻辑
|
// - 兼容现有的 localStorage 设置逻辑
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { execute, queryAll, queryOne } from './db-core';
|
import { execute, queryAll, queryOne } from '../core/db-core';
|
||||||
import type { AppSetting } from './types';
|
import type { AppSetting } from '../core/types';
|
||||||
import { SETTING_KEYS } from './types';
|
import { SETTING_KEYS } from '../core/types';
|
||||||
|
|
||||||
// ---------- API ----------
|
// ---------- API ----------
|
||||||
|
|
||||||
@@ -7,11 +7,11 @@
|
|||||||
// - 按用户 + 状态/模型筛选
|
// - 按用户 + 状态/模型筛选
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { execute, queryAll, queryOne } from './db-core';
|
import { execute, queryAll, queryOne } from '../core/db-core';
|
||||||
import { getCurrentUserId } from './user-scope';
|
import { getCurrentUserId } from '../services/user-scope';
|
||||||
import type { TaskRecordEntry, TaskQueryFilter } from './types';
|
import type { TaskRecordEntry, TaskQueryFilter } from '../core/types';
|
||||||
import type { TaskInfoItemResponseBody } from '@/services/modules/task';
|
import type { TaskInfoItemResponseBody } from '@/services/modules/task';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/bus';
|
||||||
|
|
||||||
// ---------- 序列化 ----------
|
// ---------- 序列化 ----------
|
||||||
|
|
||||||
@@ -28,13 +28,13 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 仅在 Electron 环境中监听主进程消息(浏览器预览时跳过)
|
// 仅在 Electron 环境中监听主进程消息(浏览器预览时跳过)
|
||||||
import { safeIpcOn } from './utils/ipc';
|
import { safeIpcOn } from './utils/bus';
|
||||||
safeIpcOn('main-process-message', (_event: unknown, message: unknown) => {
|
safeIpcOn('main-process-message', (_event: unknown, message: unknown) => {
|
||||||
console.log('[Main Process Message]:', message);
|
console.log('[Main Process Message]:', message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 渲染进程全局未捕获错误(兜底记录到日志文件)
|
// 渲染进程全局未捕获错误(兜底记录到日志文件)
|
||||||
import {logger} from './utils/logger';
|
import {logger} from './utils/bus';
|
||||||
|
|
||||||
window.addEventListener('error', (event) => {
|
window.addEventListener('error', (event) => {
|
||||||
logger.error('ui', 'Uncaught error (renderer)', event.error, {
|
logger.error('ui', 'Uncaught error (renderer)', event.error, {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { RegisterForm } from './components/RegisterForm';
|
|||||||
import { ForgotPasswordModal } from './components/ForgotPasswordModal';
|
import { ForgotPasswordModal } from './components/ForgotPasswordModal';
|
||||||
import { useAuthState } from './hooks/use-auth-state';
|
import { useAuthState } from './hooks/use-auth-state';
|
||||||
import { AuthAPI, type LoginResponseBody } from '@/services/modules/auth';
|
import { AuthAPI, type LoginResponseBody } from '@/services/modules/auth';
|
||||||
import { getDeviceId } from '@/utils/device';
|
import { getDeviceId } from '@/utils/env';
|
||||||
import type { LoginFormValues, RegisterFormValues } from './types';
|
import type { LoginFormValues, RegisterFormValues } from './types';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|||||||
@@ -25,28 +25,5 @@ export interface RegisterFormValues {
|
|||||||
referral_code: string;
|
referral_code: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 字段配置(数据驱动 UI 渲染) ----------
|
// 从 ui/ 重新导出通用字段类型(保持向后兼容)
|
||||||
|
export type { FieldRule, InputType, FieldConfig } from '@/components/ui/types';
|
||||||
export interface FieldRule {
|
|
||||||
required?: boolean;
|
|
||||||
minLen?: number;
|
|
||||||
maxLen?: number;
|
|
||||||
pattern?: RegExp;
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type InputType = 'text' | 'password' | 'tel';
|
|
||||||
|
|
||||||
export interface FieldConfig {
|
|
||||||
name: string;
|
|
||||||
label: string;
|
|
||||||
placeholder: string;
|
|
||||||
inputType: InputType;
|
|
||||||
rules: FieldRule[];
|
|
||||||
/** 仅注册表单的选填字段 */
|
|
||||||
optional?: boolean;
|
|
||||||
/** 附带操作按钮标识 */
|
|
||||||
extraAction?: 'send-sms';
|
|
||||||
/** 冷却秒数(发送验证码按钮用) */
|
|
||||||
cooldown?: number;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ export function HomeContent() {
|
|||||||
const leftWidthRef = useRef(leftWidth);
|
const leftWidthRef = useRef(leftWidth);
|
||||||
const rightWidthRef = useRef(rightWidth);
|
const rightWidthRef = useRef(rightWidth);
|
||||||
const draggingRef = useRef(dragging);
|
const draggingRef = useRef(dragging);
|
||||||
|
// 上一次重算时的容器宽度(用于三栏等比缩放时反推中列宽度)
|
||||||
|
const prevWidthRef = useRef(0);
|
||||||
// 标记拖拽期间被跳过的重算,拖拽结束后补算
|
// 标记拖拽期间被跳过的重算,拖拽结束后补算
|
||||||
const needsRecalcRef = useRef(false);
|
const needsRecalcRef = useRef(false);
|
||||||
|
|
||||||
@@ -164,94 +166,97 @@ export function HomeContent() {
|
|||||||
|
|
||||||
// ======== 视口缩放时按比例自动调整面板宽度 ========
|
// ======== 视口缩放时按比例自动调整面板宽度 ========
|
||||||
|
|
||||||
useEffect(() => {
|
/**
|
||||||
const el = containerRef.current;
|
* 三栏等比缩放核心(ResizeObserver + window.resize 共用)。
|
||||||
if (!el) return;
|
*
|
||||||
|
* 旧算法只保持 left:right 比例,中列被动吸收剩余空间——窗口从半屏拉到全屏时
|
||||||
const observer = new ResizeObserver(() => {
|
* 新增宽度全部给了左右栏,中列反而被挤到 CENTER_MIN。
|
||||||
// 拖拽中由 mousemove handler 处理约束,避免冲突;
|
*
|
||||||
// 若跳过则标记 needsRecalcRef,拖拽结束后补算
|
* 新算法通过 prevWidthRef 反推中列上次实际宽度,三栏按各自占比分配空间变化。
|
||||||
|
*/
|
||||||
|
const recalcLayout = useCallback(() => {
|
||||||
|
if (draggingRef.current) {
|
||||||
|
needsRecalcRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
if (draggingRef.current) {
|
if (draggingRef.current) {
|
||||||
needsRecalcRef.current = true;
|
needsRecalcRef.current = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 每次回调从 ref 读取当前 DOM 元素(避免闭包捕获 mount 时的 el)
|
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const containerWidth = el.clientWidth;
|
const containerWidth = el.clientWidth;
|
||||||
|
const available = containerWidth - LAYOUT_OVERHEAD; // 三栏可用总宽
|
||||||
|
|
||||||
const currentLeft = leftWidthRef.current;
|
const currentLeft = leftWidthRef.current;
|
||||||
const currentRight = rightWidthRef.current;
|
const currentRight = rightWidthRef.current;
|
||||||
|
|
||||||
// 左右面板可用的总空间(中列至少保留 CENTER_MIN)
|
// ---- 反推中列上次实际宽度(基于上一次记录的容器宽度) ----
|
||||||
const sideAvailable = containerWidth - LAYOUT_OVERHEAD - CENTER_MIN;
|
const prevWidth = prevWidthRef.current || containerWidth; // 首次调用用当前值
|
||||||
const sideTotal = currentLeft + currentRight;
|
const currentCenter = Math.max(
|
||||||
|
CENTER_MIN,
|
||||||
|
prevWidth - LAYOUT_OVERHEAD - currentLeft - currentRight,
|
||||||
|
);
|
||||||
|
const total = currentLeft + currentCenter + currentRight;
|
||||||
|
if (total <= 0) return;
|
||||||
|
|
||||||
if (sideAvailable <= 0 || sideTotal <= 0) return;
|
// ---- 三栏按比例分配可用空间 ----
|
||||||
|
let newLeft = Math.round(available * (currentLeft / total));
|
||||||
|
let newCenter = Math.round(available * (currentCenter / total));
|
||||||
|
let newRight = available - newLeft - newCenter;
|
||||||
|
|
||||||
// ---- 按当前比例分配空间 ----
|
// ---- 最小值约束(左/右优先保底,中列弹性收缩) ----
|
||||||
const ratio = currentLeft / sideTotal;
|
|
||||||
let newLeft = Math.round(sideAvailable * ratio);
|
|
||||||
let newRight = sideAvailable - newLeft;
|
|
||||||
|
|
||||||
// ---- 最小值约束(保持比例的前提下不跌破下限) ----
|
|
||||||
if (newLeft < LEFT_MIN) {
|
if (newLeft < LEFT_MIN) {
|
||||||
newLeft = LEFT_MIN;
|
newLeft = LEFT_MIN;
|
||||||
newRight = sideAvailable - newLeft;
|
// 差额从右侧扣(先扣中列,再扣右列)
|
||||||
|
const deficit = available - newLeft - newCenter - newRight;
|
||||||
|
newCenter = Math.max(CENTER_MIN, newCenter + deficit);
|
||||||
}
|
}
|
||||||
if (newRight < RIGHT_MIN) {
|
if (newRight < RIGHT_MIN) {
|
||||||
newRight = RIGHT_MIN;
|
newRight = RIGHT_MIN;
|
||||||
newLeft = sideAvailable - newRight;
|
const deficit = available - newLeft - newCenter - newRight;
|
||||||
|
newCenter = Math.max(CENTER_MIN, newCenter + deficit);
|
||||||
}
|
}
|
||||||
// 两侧同时触底 → 保持最小值,中列由 CSS min-width 兜底
|
|
||||||
if (newLeft < LEFT_MIN) newLeft = LEFT_MIN;
|
|
||||||
if (newRight < RIGHT_MIN) newRight = RIGHT_MIN;
|
|
||||||
|
|
||||||
// 即时更新 ref,避免 ResizeObserver 连续触发时读到过期值
|
// 更新 ref(在 setState 之前,确保并发回调读到最新值)
|
||||||
leftWidthRef.current = newLeft;
|
leftWidthRef.current = newLeft;
|
||||||
rightWidthRef.current = newRight;
|
rightWidthRef.current = newRight;
|
||||||
|
prevWidthRef.current = containerWidth;
|
||||||
|
|
||||||
if (newLeft !== currentLeft) setLeftWidth(newLeft);
|
if (newLeft !== currentLeft) setLeftWidth(newLeft);
|
||||||
if (newRight !== currentRight) setRightWidth(newRight);
|
if (newRight !== currentRight) setRightWidth(newRight);
|
||||||
});
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 主触发器:ResizeObserver(响应容器尺寸变化)
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => recalcLayout());
|
||||||
observer.observe(el);
|
observer.observe(el);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, [recalcLayout]);
|
||||||
|
|
||||||
|
// 兜底触发器:window.resize(捕获 ResizeObserver 可能遗漏的快照/最大化/分屏事件)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => recalcLayout();
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
}, [recalcLayout]);
|
||||||
|
|
||||||
// 拖拽结束后,若 ResizeObserver 曾因拖拽跳过重算,立即补算
|
// 拖拽结束后,若 ResizeObserver 曾因拖拽跳过重算,立即补算
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dragging !== null) return; // 仍在拖拽中
|
if (dragging !== null) return;
|
||||||
if (!needsRecalcRef.current) return;
|
if (!needsRecalcRef.current) return;
|
||||||
|
|
||||||
needsRecalcRef.current = false;
|
needsRecalcRef.current = false;
|
||||||
|
// 使用 requestAnimationFrame 确保 React 已完成 commit 再读取 DOM
|
||||||
const el = containerRef.current;
|
requestAnimationFrame(() => recalcLayout());
|
||||||
if (!el) return;
|
}, [dragging, recalcLayout]);
|
||||||
const containerWidth = el.clientWidth;
|
|
||||||
const currentLeft = leftWidthRef.current;
|
|
||||||
const currentRight = rightWidthRef.current;
|
|
||||||
|
|
||||||
const sideAvailable = containerWidth - LAYOUT_OVERHEAD - CENTER_MIN;
|
|
||||||
const sideTotal = currentLeft + currentRight;
|
|
||||||
if (sideAvailable <= 0 || sideTotal <= 0) return;
|
|
||||||
|
|
||||||
const ratio = currentLeft / sideTotal;
|
|
||||||
let newLeft = Math.round(sideAvailable * ratio);
|
|
||||||
let newRight = sideAvailable - newLeft;
|
|
||||||
|
|
||||||
if (newLeft < LEFT_MIN) { newLeft = LEFT_MIN; newRight = sideAvailable - newLeft; }
|
|
||||||
if (newRight < RIGHT_MIN) { newRight = RIGHT_MIN; newLeft = sideAvailable - newRight; }
|
|
||||||
if (newLeft < LEFT_MIN) newLeft = LEFT_MIN;
|
|
||||||
if (newRight < RIGHT_MIN) newRight = RIGHT_MIN;
|
|
||||||
|
|
||||||
leftWidthRef.current = newLeft;
|
|
||||||
rightWidthRef.current = newRight;
|
|
||||||
|
|
||||||
if (newLeft !== currentLeft) setLeftWidth(newLeft);
|
|
||||||
if (newRight !== currentRight) setRightWidth(newRight);
|
|
||||||
}, [dragging]);
|
|
||||||
|
|
||||||
// ======== 分隔条样式工厂 ========
|
// ======== 分隔条样式工厂 ========
|
||||||
|
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// AudioListInput — audioList → Dragger(音频文件拖拽上传)
|
|
||||||
// 用于声纹克隆的音频样本上传场景
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { Upload } from 'antd';
|
|
||||||
import { InboxOutlined } from '@ant-design/icons';
|
|
||||||
import type { UploadFile } from 'antd/es/upload';
|
|
||||||
import type { WidgetProps } from './types';
|
|
||||||
import { useFileUpload } from '@/hooks/use-api';
|
|
||||||
|
|
||||||
const { Dragger } = Upload;
|
|
||||||
|
|
||||||
export function AudioListInput({ value, onChange, disabled, config }: WidgetProps) {
|
|
||||||
const fileList = (value as UploadFile[]) || [];
|
|
||||||
const maxSizeMb = (config.maxFileSizeMb as number) || 10;
|
|
||||||
const maxCount = (config.maxFiles as number) || 10;
|
|
||||||
const filters = config.fileFilter as string[] | undefined;
|
|
||||||
|
|
||||||
const { customRequest } = useFileUpload();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dragger
|
|
||||||
multiple
|
|
||||||
accept={
|
|
||||||
filters
|
|
||||||
? filters.map((ext) => `.${ext}`).join(',')
|
|
||||||
: '.wav,.mp3,.m4a,.flac'
|
|
||||||
}
|
|
||||||
fileList={fileList}
|
|
||||||
customRequest={customRequest}
|
|
||||||
onChange={({ fileList: newList }) => onChange?.(newList)}
|
|
||||||
disabled={disabled}
|
|
||||||
beforeUpload={() => true}
|
|
||||||
maxCount={maxCount}
|
|
||||||
>
|
|
||||||
<p className="ant-upload-drag-icon">
|
|
||||||
<InboxOutlined />
|
|
||||||
</p>
|
|
||||||
<p className="ant-upload-text">点击或拖拽音频文件到此区域上传</p>
|
|
||||||
<p className="ant-upload-hint">
|
|
||||||
支持 {filters?.join(' / ').toUpperCase() || 'WAV / MP3 / M4A / FLAC'}
|
|
||||||
,单文件不超过 {maxSizeMb}MB
|
|
||||||
</p>
|
|
||||||
</Dragger>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// Checkbox — checkbox → Switch
|
|
||||||
// 用于布尔开关:原始模式、平铺、Base64 输出等
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { Switch } from 'antd';
|
|
||||||
import type { WidgetProps } from './types';
|
|
||||||
|
|
||||||
export function Checkbox({ value, onChange, disabled }: WidgetProps) {
|
|
||||||
return (
|
|
||||||
<Switch
|
|
||||||
checked={value as boolean}
|
|
||||||
onChange={(v) => onChange?.(v)}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// ComboBox — comboBox → Select
|
|
||||||
// 用于枚举选择:宽高比、音色、分辨率、模型等
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { Select } from 'antd';
|
|
||||||
import type { WidgetProps } from './types';
|
|
||||||
|
|
||||||
export function ComboBox({ value, onChange, disabled, config }: WidgetProps) {
|
|
||||||
const enumValues = config.enumValues as string[] | undefined;
|
|
||||||
const options = enumValues?.map((v) => ({ label: v, value: v })) || [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
value={value as string}
|
|
||||||
onChange={(v) => onChange?.(v)}
|
|
||||||
disabled={disabled}
|
|
||||||
placeholder={(config.placeholder as string) || '请选择'}
|
|
||||||
options={options}
|
|
||||||
allowClear={!config.required}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// DoubleSpinBox — doubleSpinBox → InputNumber (step=config.step)
|
|
||||||
// 用于小数输入:语速、音量、相似度等
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { InputNumber } from 'antd';
|
|
||||||
import type { WidgetProps } from './types';
|
|
||||||
|
|
||||||
export function DoubleSpinBox({ value, onChange, disabled, config }: WidgetProps) {
|
|
||||||
return (
|
|
||||||
<InputNumber
|
|
||||||
value={value as number}
|
|
||||||
onChange={(v) => onChange?.(v != null ? v : undefined)}
|
|
||||||
disabled={disabled}
|
|
||||||
min={config.minValue as number | undefined}
|
|
||||||
max={config.maxValue as number | undefined}
|
|
||||||
step={config.step as number || 0.1}
|
|
||||||
style={{ width: '100%', minWidth: 140 }}
|
|
||||||
placeholder={(config.placeholder as string) || undefined}
|
|
||||||
controls
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// ImageInput — imageInput → Upload(单张图片)
|
|
||||||
// 用于垫图、参考图等单图上传场景
|
|
||||||
//
|
|
||||||
// enable_switch 支持:
|
|
||||||
// 当 API ui.enable_switch === true 时,默认禁用(灰色),
|
|
||||||
// 需点击 Switch 开关后才能上传——匹配原 Qt 客户端行为。
|
|
||||||
// 开关关闭时清除已上传文件。
|
|
||||||
//
|
|
||||||
// 表单值语义(配合 constraints_config.requires 依赖禁用):
|
|
||||||
// undefined → 开关关闭 / 未启用 → isValueEmpty = true → 依赖字段禁用
|
|
||||||
// [] → 开关开启但未上传文件 → isValueEmpty = false → 依赖字段启用
|
|
||||||
// [{...}] → 已上传文件 → isValueEmpty = false → 依赖字段启用
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
|
||||||
import { Upload, Switch, Space, Typography } from 'antd';
|
|
||||||
import { UploadOutlined } from '@ant-design/icons';
|
|
||||||
import type { UploadFile } from 'antd/es/upload';
|
|
||||||
import type { WidgetProps } from './types';
|
|
||||||
import { useFileUpload } from '@/hooks/use-api';
|
|
||||||
import { buildAccept, createMediaBeforeUpload } from './media-upload-utils';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
/** 标记"已启用但空"的空数组,避免 react 渲染时引用变化 */
|
|
||||||
const ENABLED_EMPTY: UploadFile[] = [];
|
|
||||||
|
|
||||||
export function ImageInput({ value, onChange, disabled, config }: WidgetProps) {
|
|
||||||
const fileList = (value as UploadFile[]) || [];
|
|
||||||
const maxSizeMb = (config.maxFileSizeMb as number) || 10;
|
|
||||||
const filters = config.fileFilter as string[] | undefined;
|
|
||||||
const hasEnableSwitch = (config.enableSwitch as boolean) || false;
|
|
||||||
|
|
||||||
// 文件上传 Hook(提供 customRequest → antd Upload 集成)
|
|
||||||
const { customRequest } = useFileUpload();
|
|
||||||
|
|
||||||
// 有 enable_switch 时默认禁用,需手动开启
|
|
||||||
const [switchOn, setSwitchOn] = useState(!hasEnableSwitch);
|
|
||||||
const isUploadDisabled = disabled || !switchOn;
|
|
||||||
|
|
||||||
const handleSwitchChange = useCallback(
|
|
||||||
(on: boolean) => {
|
|
||||||
setSwitchOn(on);
|
|
||||||
if (!on) {
|
|
||||||
// 关闭开关 → 表单值置为 undefined(isValueEmpty = true,依赖字段禁用)
|
|
||||||
onChange?.(undefined);
|
|
||||||
} else {
|
|
||||||
// 开启开关 → 空数组 = "已启用但未上传"(isValueEmpty = false,依赖字段启用)
|
|
||||||
onChange?.(ENABLED_EMPTY as unknown as UploadFile[]);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Space orientation="vertical" style={{ width: '100%' }} size={8}>
|
|
||||||
{hasEnableSwitch && (
|
|
||||||
<Space size={8}>
|
|
||||||
<Switch
|
|
||||||
size="small"
|
|
||||||
checked={switchOn}
|
|
||||||
onChange={handleSwitchChange}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
<Text type={switchOn ? 'success' : 'secondary'} style={{ fontSize: 12 }}>
|
|
||||||
{switchOn ? '已启用' : '点击开关启用上传'}
|
|
||||||
</Text>
|
|
||||||
</Space>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Upload
|
|
||||||
listType="picture-card"
|
|
||||||
maxCount={1}
|
|
||||||
accept={buildAccept(filters)}
|
|
||||||
fileList={fileList}
|
|
||||||
customRequest={customRequest}
|
|
||||||
onChange={({ fileList: newList }) => {
|
|
||||||
// 仅当开关开启时上报文件变化
|
|
||||||
if (switchOn || !hasEnableSwitch) {
|
|
||||||
onChange?.(newList as unknown as UploadFile[]);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isUploadDisabled}
|
|
||||||
beforeUpload={createMediaBeforeUpload(maxSizeMb, filters)}
|
|
||||||
>
|
|
||||||
{fileList.length < 1 && (
|
|
||||||
<div style={{ opacity: isUploadDisabled ? 0.4 : 1 }}>
|
|
||||||
<UploadOutlined />
|
|
||||||
<div style={{ marginTop: 4, fontSize: 12 }}>上传</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Upload>
|
|
||||||
</Space>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// ImageListInput — imageList → Dragger(多张图片/视频拖拽上传)
|
|
||||||
// 用于图生图/图生视频模型的多图输入场景
|
|
||||||
// 当 file_filter 包含视频扩展名时,自动支持视频文件上传
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { Upload } from 'antd';
|
|
||||||
import { InboxOutlined } from '@ant-design/icons';
|
|
||||||
import type { UploadFile } from 'antd/es/upload';
|
|
||||||
import type { WidgetProps } from './types';
|
|
||||||
import { useFileUpload } from '@/hooks/use-api';
|
|
||||||
import { buildAccept, createMediaBeforeUpload, VIDEO_EXTENSIONS } from './media-upload-utils';
|
|
||||||
|
|
||||||
const { Dragger } = Upload;
|
|
||||||
|
|
||||||
export function ImageListInput({ value, onChange, disabled, config }: WidgetProps) {
|
|
||||||
const fileList = (value as UploadFile[]) || [];
|
|
||||||
const maxSizeMb = (config.maxFileSizeMb as number) || 10;
|
|
||||||
const maxCount = (config.maxFiles as number) || 10;
|
|
||||||
const filters = config.fileFilter as string[] | undefined;
|
|
||||||
const hasVideo = filters?.some(f => VIDEO_EXTENSIONS.has(f.toLowerCase()));
|
|
||||||
|
|
||||||
const { customRequest } = useFileUpload();
|
|
||||||
|
|
||||||
const mediaLabel = hasVideo ? '图片/视频' : '图片';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dragger
|
|
||||||
multiple
|
|
||||||
listType="picture"
|
|
||||||
accept={buildAccept(filters)}
|
|
||||||
fileList={fileList}
|
|
||||||
customRequest={customRequest}
|
|
||||||
onChange={({ fileList: newList }) => onChange?.(newList)}
|
|
||||||
disabled={disabled}
|
|
||||||
beforeUpload={createMediaBeforeUpload(maxSizeMb, filters)}
|
|
||||||
maxCount={maxCount}
|
|
||||||
>
|
|
||||||
<p className="ant-upload-drag-icon">
|
|
||||||
<InboxOutlined />
|
|
||||||
</p>
|
|
||||||
<p className="ant-upload-text">点击或拖拽{mediaLabel}到此区域上传</p>
|
|
||||||
<p className="ant-upload-hint">
|
|
||||||
支持 {filters?.join(' / ').toUpperCase() || 'PNG / JPG / WebP'}
|
|
||||||
,单文件不超过 {maxSizeMb}MB
|
|
||||||
</p>
|
|
||||||
</Dragger>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// LineInput — lineEdit → Input
|
|
||||||
// 用于单行文本输入(如 custom_voice_id)
|
|
||||||
// 同时作为未知 widget 类型的兜底控件
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { Input } from 'antd';
|
|
||||||
import type { WidgetProps } from './types';
|
|
||||||
|
|
||||||
export function LineInput({ value, onChange, disabled, config }: WidgetProps) {
|
|
||||||
return (
|
|
||||||
<Input
|
|
||||||
value={value as string}
|
|
||||||
onChange={(e) => onChange?.(e.target.value)}
|
|
||||||
disabled={disabled}
|
|
||||||
placeholder={(config.placeholder as string) || '请输入'}
|
|
||||||
maxLength={config.maxLength as number | undefined}
|
|
||||||
allowClear
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// SpinBox — spinBox → InputNumber (step=1)
|
|
||||||
// 用于整数输入:混沌、风格化、怪异、时长、音调等
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { InputNumber } from 'antd';
|
|
||||||
import type { WidgetProps } from './types';
|
|
||||||
|
|
||||||
export function SpinBox({ value, onChange, disabled, config }: WidgetProps) {
|
|
||||||
return (
|
|
||||||
<InputNumber
|
|
||||||
value={value as number}
|
|
||||||
onChange={(v) => onChange?.(v != null ? v : undefined)}
|
|
||||||
disabled={disabled}
|
|
||||||
min={config.minValue as number | undefined}
|
|
||||||
max={config.maxValue as number | undefined}
|
|
||||||
step={1}
|
|
||||||
style={{ width: '100%', minWidth: 140 }}
|
|
||||||
placeholder={(config.placeholder as string) || undefined}
|
|
||||||
controls
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// TextInput — textEdit → Input.TextArea
|
|
||||||
// 用于提示词、文本合成等长文本输入场景
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { Input } from 'antd';
|
|
||||||
import type { WidgetProps } from './types';
|
|
||||||
|
|
||||||
const { TextArea } = Input;
|
|
||||||
|
|
||||||
export function TextInput({ value, onChange, disabled, config }: WidgetProps) {
|
|
||||||
return (
|
|
||||||
<TextArea
|
|
||||||
rows={4}
|
|
||||||
value={value as string}
|
|
||||||
onChange={(e) => onChange?.(e.target.value)}
|
|
||||||
disabled={disabled}
|
|
||||||
placeholder={(config.placeholder as string) || '请输入'}
|
|
||||||
maxLength={config.maxLength as number | undefined}
|
|
||||||
showCount={config.maxLength !== undefined}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// VideoInput — videoInput → Upload(单个视频)
|
|
||||||
// 用于视频参考等单视频上传场景
|
|
||||||
//
|
|
||||||
// enable_switch 支持:
|
|
||||||
// 当 API ui.enable_switch === true 时,默认禁用(灰色),
|
|
||||||
// 需点击 Switch 开关后才能上传——匹配原 Qt 客户端行为。
|
|
||||||
// 开关关闭时清除已上传文件。
|
|
||||||
//
|
|
||||||
// 表单值语义(配合 constraints_config.requires 依赖禁用):
|
|
||||||
// undefined → 开关关闭 / 未启用 → isValueEmpty = true → 依赖字段禁用
|
|
||||||
// [] → 开关开启但未上传文件 → isValueEmpty = false → 依赖字段启用
|
|
||||||
// [{...}] → 已上传文件 → isValueEmpty = false → 依赖字段启用
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
|
||||||
import { Upload, Switch, Space, Typography, message } from 'antd';
|
|
||||||
import { VideoCameraOutlined } from '@ant-design/icons';
|
|
||||||
import type { UploadFile, RcFile } from 'antd/es/upload';
|
|
||||||
import type { WidgetProps } from './types';
|
|
||||||
import { useFileUpload } from '@/hooks/use-api';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建视频上传前的类型 & 大小校验函数。
|
|
||||||
* @param maxSizeMb 最大文件大小(MB),默认 50
|
|
||||||
*/
|
|
||||||
export function createVideoBeforeUpload(maxSizeMb: number = 50) {
|
|
||||||
return (file: RcFile) => {
|
|
||||||
const isVideo = file.type.startsWith('video/');
|
|
||||||
if (!isVideo) {
|
|
||||||
message.error('只能上传视频文件');
|
|
||||||
return Upload.LIST_IGNORE;
|
|
||||||
}
|
|
||||||
if (file.size / 1024 / 1024 >= maxSizeMb) {
|
|
||||||
message.error(`视频大小不能超过 ${maxSizeMb}MB`);
|
|
||||||
return Upload.LIST_IGNORE;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 构建视频 accept 字符串 */
|
|
||||||
function buildVideoAccept(filters?: string[]): string {
|
|
||||||
if (!filters || filters.length === 0) return 'video/mp4,video/webm,video/quicktime,video/x-msvideo';
|
|
||||||
return filters.map((ext) => `video/${ext}`).join(',');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 标记"已启用但空"的空数组,避免 react 渲染时引用变化 */
|
|
||||||
const ENABLED_EMPTY: UploadFile[] = [];
|
|
||||||
|
|
||||||
export function VideoInput({ value, onChange, disabled, config }: WidgetProps) {
|
|
||||||
const fileList = (value as UploadFile[]) || [];
|
|
||||||
const maxSizeMb = (config.maxFileSizeMb as number) || 50;
|
|
||||||
const hasEnableSwitch = (config.enableSwitch as boolean) || false;
|
|
||||||
|
|
||||||
// 文件上传 Hook(提供 customRequest → antd Upload 集成)
|
|
||||||
const { customRequest } = useFileUpload();
|
|
||||||
|
|
||||||
// 有 enable_switch 时默认禁用,需手动开启
|
|
||||||
const [switchOn, setSwitchOn] = useState(!hasEnableSwitch);
|
|
||||||
const isUploadDisabled = disabled || !switchOn;
|
|
||||||
|
|
||||||
const handleSwitchChange = useCallback(
|
|
||||||
(on: boolean) => {
|
|
||||||
setSwitchOn(on);
|
|
||||||
if (!on) {
|
|
||||||
// 关闭开关 → 表单值置为 undefined(isValueEmpty = true,依赖字段禁用)
|
|
||||||
onChange?.(undefined);
|
|
||||||
} else {
|
|
||||||
// 开启开关 → 空数组 = "已启用但未上传"(isValueEmpty = false,依赖字段启用)
|
|
||||||
onChange?.(ENABLED_EMPTY as unknown as UploadFile[]);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Space orientation="vertical" style={{ width: '100%' }} size={8}>
|
|
||||||
{hasEnableSwitch && (
|
|
||||||
<Space size={8}>
|
|
||||||
<Switch
|
|
||||||
size="small"
|
|
||||||
checked={switchOn}
|
|
||||||
onChange={handleSwitchChange}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
<Text type={switchOn ? 'success' : 'secondary'} style={{ fontSize: 12 }}>
|
|
||||||
{switchOn ? '已启用' : '点击开关启用上传'}
|
|
||||||
</Text>
|
|
||||||
</Space>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Upload
|
|
||||||
listType="picture-card"
|
|
||||||
maxCount={1}
|
|
||||||
accept={buildVideoAccept(config.fileFilter as string[] | undefined)}
|
|
||||||
fileList={fileList}
|
|
||||||
customRequest={customRequest}
|
|
||||||
onChange={({ fileList: newList }) => {
|
|
||||||
// 仅当开关开启时上报文件变化
|
|
||||||
if (switchOn || !hasEnableSwitch) {
|
|
||||||
onChange?.(newList as unknown as UploadFile[]);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isUploadDisabled}
|
|
||||||
beforeUpload={createVideoBeforeUpload(maxSizeMb)}
|
|
||||||
>
|
|
||||||
{fileList.length < 1 && (
|
|
||||||
<div style={{ opacity: isUploadDisabled ? 0.4 : 1 }}>
|
|
||||||
<VideoCameraOutlined />
|
|
||||||
<div style={{ marginTop: 4, fontSize: 12 }}>上传视频</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Upload>
|
|
||||||
</Space>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -14,16 +14,9 @@
|
|||||||
import type { ComponentType } from 'react';
|
import type { ComponentType } from 'react';
|
||||||
import type { WidgetProps } from './types';
|
import type { WidgetProps } from './types';
|
||||||
|
|
||||||
import { TextInput } from './TextInput';
|
import { TextInput, LineInput, ComboBox, Checkbox, SpinBox, DoubleSpinBox } from './simple-widgets';
|
||||||
import { LineInput } from './LineInput';
|
import { ImageInput, VideoInput } from './upload-widgets';
|
||||||
import { ComboBox } from './ComboBox';
|
import { ImageListInput, AudioListInput } from './list-upload-widgets';
|
||||||
import { Checkbox } from './Checkbox';
|
|
||||||
import { SpinBox } from './SpinBox';
|
|
||||||
import { DoubleSpinBox } from './DoubleSpinBox';
|
|
||||||
import { ImageInput } from './ImageInput';
|
|
||||||
import { ImageListInput } from './ImageListInput';
|
|
||||||
import { AudioListInput } from './AudioListInput';
|
|
||||||
import { VideoInput } from './VideoInput';
|
|
||||||
import { SeedanceContent } from './SeedanceContent';
|
import { SeedanceContent } from './SeedanceContent';
|
||||||
|
|
||||||
export type { WidgetProps } from './types';
|
export type { WidgetProps } from './types';
|
||||||
|
|||||||
117
src/pages/home/controls/list-upload-widgets.tsx
Normal file
117
src/pages/home/controls/list-upload-widgets.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
// ============================================================
|
||||||
|
// list-upload-widgets — 多文件拖拽上传控件
|
||||||
|
// 合并自 ImageListInput.tsx + AudioListInput.tsx
|
||||||
|
//
|
||||||
|
// 两者几乎完全相同(Upload.Dragger 包装),
|
||||||
|
// 仅默认扩展名、accept 后缀、文案不同。
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { Upload } from 'antd';
|
||||||
|
import { InboxOutlined } from '@ant-design/icons';
|
||||||
|
import type { UploadFile } from 'antd/es/upload';
|
||||||
|
import type { WidgetProps } from './types';
|
||||||
|
import { useFileUpload } from '@/hooks/use-api';
|
||||||
|
import { buildAccept, createMediaBeforeUpload, VIDEO_EXTENSIONS } from '@/utils/media-upload';
|
||||||
|
|
||||||
|
const { Dragger } = Upload;
|
||||||
|
|
||||||
|
// ════ DraggerUpload 内部组件 ════
|
||||||
|
|
||||||
|
interface DraggerUploadProps {
|
||||||
|
value: unknown;
|
||||||
|
onChange: WidgetProps['onChange'];
|
||||||
|
disabled: boolean;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
/** 'image' | 'audio' */
|
||||||
|
mediaType: 'image' | 'audio';
|
||||||
|
defaultAccept: string;
|
||||||
|
defaultFiltersLabel: string;
|
||||||
|
defaultMaxSizeMb: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DraggerUpload({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
config,
|
||||||
|
mediaType,
|
||||||
|
defaultAccept,
|
||||||
|
defaultFiltersLabel,
|
||||||
|
defaultMaxSizeMb,
|
||||||
|
label,
|
||||||
|
}: DraggerUploadProps) {
|
||||||
|
const fileList = (value as UploadFile[]) || [];
|
||||||
|
const maxSizeMb = (config.maxFileSizeMb as number) || defaultMaxSizeMb;
|
||||||
|
const maxCount = (config.maxFiles as number) || 10;
|
||||||
|
const filters = config.fileFilter as string[] | undefined;
|
||||||
|
const hasVideo = mediaType === 'image' && filters?.some(f => VIDEO_EXTENSIONS.has(f.toLowerCase()));
|
||||||
|
|
||||||
|
const { customRequest } = useFileUpload();
|
||||||
|
const mediaLabel = hasVideo ? '图片/视频' : label;
|
||||||
|
|
||||||
|
const accept = mediaType === 'audio'
|
||||||
|
? (filters ? filters.map((ext) => `.${ext}`).join(',') : defaultAccept)
|
||||||
|
: buildAccept(filters);
|
||||||
|
|
||||||
|
const beforeUpload = mediaType === 'audio'
|
||||||
|
? () => true
|
||||||
|
: createMediaBeforeUpload(maxSizeMb, filters);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dragger
|
||||||
|
multiple
|
||||||
|
listType="picture"
|
||||||
|
accept={accept}
|
||||||
|
fileList={fileList}
|
||||||
|
customRequest={customRequest}
|
||||||
|
onChange={({ fileList: newList }) => onChange?.(newList)}
|
||||||
|
disabled={disabled}
|
||||||
|
beforeUpload={beforeUpload}
|
||||||
|
maxCount={maxCount}
|
||||||
|
>
|
||||||
|
<p className="ant-upload-drag-icon"><InboxOutlined /></p>
|
||||||
|
<p className="ant-upload-text">点击或拖拽{mediaLabel}到此区域上传</p>
|
||||||
|
<p className="ant-upload-hint">
|
||||||
|
支持 {filters?.join(' / ').toUpperCase() || defaultFiltersLabel}
|
||||||
|
,单文件不超过 {maxSizeMb}MB
|
||||||
|
</p>
|
||||||
|
</Dragger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════ 公开导出(保持原组件名兼容 WIDGET_MAP) ════
|
||||||
|
|
||||||
|
export function ImageListInput(props: WidgetProps) {
|
||||||
|
const { value, onChange, disabled, config } = props;
|
||||||
|
return (
|
||||||
|
<DraggerUpload
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={disabled ?? false}
|
||||||
|
config={config}
|
||||||
|
mediaType="image"
|
||||||
|
defaultAccept="image/png,image/jpg,image/jpeg,image/webp"
|
||||||
|
defaultFiltersLabel="PNG / JPG / WebP"
|
||||||
|
defaultMaxSizeMb={10}
|
||||||
|
label="图片"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioListInput(props: WidgetProps) {
|
||||||
|
const { value, onChange, disabled, config } = props;
|
||||||
|
return (
|
||||||
|
<DraggerUpload
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={disabled ?? false}
|
||||||
|
config={config}
|
||||||
|
mediaType="audio"
|
||||||
|
defaultAccept=".wav,.mp3,.m4a,.flac"
|
||||||
|
defaultFiltersLabel="WAV / MP3 / M4A / FLAC"
|
||||||
|
defaultMaxSizeMb={10}
|
||||||
|
label="音频"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// media-upload-utils — ImageInput / ImageListInput / VideoInput 共享工具
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { message, Upload } from 'antd';
|
|
||||||
import type { RcFile } from 'antd/es/upload';
|
|
||||||
|
|
||||||
/** 常见视频扩展名,用于 buildAccept / createMediaBeforeUpload 区分图片/视频 */
|
|
||||||
export const VIDEO_EXTENSIONS = new Set(['mp4', 'webm', 'mov', 'avi', 'mkv', 'flv', 'wmv']);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 构建媒体文件 accept 字符串,自动区分 image/ video/ 前缀。
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* buildAccept(['png', 'jpg']) → "image/png,image/jpg"
|
|
||||||
* buildAccept(['mp4', 'webm']) → "video/mp4,video/webm"
|
|
||||||
* buildAccept([]) → "image/png,image/jpg,image/jpeg,image/webp"
|
|
||||||
*/
|
|
||||||
export function buildAccept(filters?: string[]): string {
|
|
||||||
if (!filters || filters.length === 0) return 'image/png,image/jpg,image/jpeg,image/webp';
|
|
||||||
return filters.map((ext) => {
|
|
||||||
if (VIDEO_EXTENSIONS.has(ext.toLowerCase())) return `video/${ext}`;
|
|
||||||
return `image/${ext}`;
|
|
||||||
}).join(',');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建上传前的类型 & 大小校验函数(ImageInput / ImageListInput 共用)。
|
|
||||||
*
|
|
||||||
* 当 file_filter 包含视频扩展名时,自动放行 video/* MIME 文件。
|
|
||||||
* 不传入 filters 时仅接受 image/*(向后兼容)。
|
|
||||||
*
|
|
||||||
* @param maxSizeMb 最大文件大小(MB),默认 10
|
|
||||||
* @param filters API spec.file_filter 数组
|
|
||||||
*/
|
|
||||||
export function createMediaBeforeUpload(maxSizeMb: number = 10, filters?: string[]) {
|
|
||||||
const hasVideoFilters = filters?.some(f => VIDEO_EXTENSIONS.has(f.toLowerCase()));
|
|
||||||
|
|
||||||
return (file: RcFile) => {
|
|
||||||
const isImage = file.type.startsWith('image/');
|
|
||||||
const isVideo = file.type.startsWith('video/');
|
|
||||||
|
|
||||||
if (isVideo && hasVideoFilters) {
|
|
||||||
if (file.size / 1024 / 1024 >= maxSizeMb) {
|
|
||||||
message.error(`视频大小不能超过 ${maxSizeMb}MB`);
|
|
||||||
return Upload.LIST_IGNORE;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isImage) {
|
|
||||||
message.error(hasVideoFilters ? '只能上传图片或视频文件' : '只能上传图片文件');
|
|
||||||
return Upload.LIST_IGNORE;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.size / 1024 / 1024 >= maxSizeMb) {
|
|
||||||
message.error(`图片大小不能超过 ${maxSizeMb}MB`);
|
|
||||||
return Upload.LIST_IGNORE;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
111
src/pages/home/controls/simple-widgets.tsx
Normal file
111
src/pages/home/controls/simple-widgets.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
// ============================================================
|
||||||
|
// simple-widgets — 简单输入控件集合
|
||||||
|
// 合并自 Checkbox.tsx + LineInput.tsx + TextInput.tsx +
|
||||||
|
// ComboBox.tsx + SpinBox.tsx + DoubleSpinBox.tsx
|
||||||
|
//
|
||||||
|
// 这些控件都是对应 antd 组件的薄包装(10-24 行),
|
||||||
|
// 统一 WidgetProps 接口以注册到 WIDGET_MAP。
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { Input, InputNumber, Select, Switch } from 'antd';
|
||||||
|
import type { WidgetProps } from './types';
|
||||||
|
|
||||||
|
// ════ Checkbox (checkbox → Switch) ════
|
||||||
|
|
||||||
|
export function Checkbox({ value, onChange, disabled }: WidgetProps) {
|
||||||
|
return (
|
||||||
|
<Switch
|
||||||
|
checked={value as boolean}
|
||||||
|
onChange={(v) => onChange?.(v)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════ LineInput (lineEdit → Input) ════
|
||||||
|
|
||||||
|
export function LineInput({ value, onChange, disabled, config }: WidgetProps) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
value={value as string}
|
||||||
|
onChange={(e) => onChange?.(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={(config.placeholder as string) || '请输入'}
|
||||||
|
maxLength={config.maxLength as number | undefined}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════ TextInput (textEdit → Input.TextArea) ════
|
||||||
|
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
export function TextInput({ value, onChange, disabled, config }: WidgetProps) {
|
||||||
|
return (
|
||||||
|
<TextArea
|
||||||
|
rows={4}
|
||||||
|
value={value as string}
|
||||||
|
onChange={(e) => onChange?.(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={(config.placeholder as string) || '请输入'}
|
||||||
|
maxLength={config.maxLength as number | undefined}
|
||||||
|
showCount={config.maxLength !== undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════ ComboBox (comboBox → Select) ════
|
||||||
|
|
||||||
|
export function ComboBox({ value, onChange, disabled, config }: WidgetProps) {
|
||||||
|
const enumValues = config.enumValues as string[] | undefined;
|
||||||
|
const options = enumValues?.map((v) => ({ label: v, value: v })) || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={value as string}
|
||||||
|
onChange={(v) => onChange?.(v)}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={(config.placeholder as string) || '请选择'}
|
||||||
|
options={options}
|
||||||
|
allowClear={!config.required}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════ SpinBox (spinBox → InputNumber, step=1) ════
|
||||||
|
|
||||||
|
export function SpinBox({ value, onChange, disabled, config }: WidgetProps) {
|
||||||
|
return (
|
||||||
|
<InputNumber
|
||||||
|
value={value as number}
|
||||||
|
onChange={(v) => onChange?.(v != null ? v : undefined)}
|
||||||
|
disabled={disabled}
|
||||||
|
min={config.minValue as number | undefined}
|
||||||
|
max={config.maxValue as number | undefined}
|
||||||
|
step={1}
|
||||||
|
style={{ width: '100%', minWidth: 140 }}
|
||||||
|
placeholder={(config.placeholder as string) || undefined}
|
||||||
|
controls
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════ DoubleSpinBox (doubleSpinBox → InputNumber, step=config.step) ════
|
||||||
|
|
||||||
|
export function DoubleSpinBox({ value, onChange, disabled, config }: WidgetProps) {
|
||||||
|
return (
|
||||||
|
<InputNumber
|
||||||
|
value={value as number}
|
||||||
|
onChange={(v) => onChange?.(v != null ? v : undefined)}
|
||||||
|
disabled={disabled}
|
||||||
|
min={config.minValue as number | undefined}
|
||||||
|
max={config.maxValue as number | undefined}
|
||||||
|
step={config.step as number || 0.1}
|
||||||
|
style={{ width: '100%', minWidth: 140 }}
|
||||||
|
placeholder={(config.placeholder as string) || undefined}
|
||||||
|
controls
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
src/pages/home/controls/upload-widgets.tsx
Normal file
158
src/pages/home/controls/upload-widgets.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
// ============================================================
|
||||||
|
// upload-widgets — 单文件上传控件
|
||||||
|
// 合并自 ImageInput.tsx + VideoInput.tsx
|
||||||
|
//
|
||||||
|
// 两者共享 ~90% 的 enable_switch + Upload 模式,
|
||||||
|
// 差异仅在于:accept 类型、beforeUpload 校验、图标、默认 maxSize。
|
||||||
|
// 提取 useEnableSwitch() + SingleUpload 去重。
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { Upload, Switch, Space, Typography } from 'antd';
|
||||||
|
import { UploadOutlined, VideoCameraOutlined } from '@ant-design/icons';
|
||||||
|
import type { UploadFile } from 'antd/es/upload';
|
||||||
|
import type { WidgetProps } from './types';
|
||||||
|
import { useFileUpload } from '@/hooks/use-api';
|
||||||
|
import {
|
||||||
|
buildAccept,
|
||||||
|
buildVideoAccept,
|
||||||
|
createMediaBeforeUpload,
|
||||||
|
createVideoBeforeUpload,
|
||||||
|
} from '@/utils/media-upload';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
// ════ enable_switch 共享 Hook ════
|
||||||
|
|
||||||
|
const ENABLED_EMPTY: UploadFile[] = [];
|
||||||
|
|
||||||
|
function useEnableSwitch(
|
||||||
|
hasEnableSwitch: boolean,
|
||||||
|
disabled: boolean,
|
||||||
|
onChange: WidgetProps['onChange'],
|
||||||
|
) {
|
||||||
|
const [switchOn, setSwitchOn] = useState(!hasEnableSwitch);
|
||||||
|
const isUploadDisabled = disabled || !switchOn;
|
||||||
|
|
||||||
|
const handleSwitchChange = useCallback(
|
||||||
|
(on: boolean) => {
|
||||||
|
setSwitchOn(on);
|
||||||
|
if (!on) {
|
||||||
|
onChange?.(undefined);
|
||||||
|
} else {
|
||||||
|
onChange?.(ENABLED_EMPTY as unknown as UploadFile[]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { switchOn, isUploadDisabled, handleSwitchChange };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════ SingleUpload 内部组件 ════
|
||||||
|
|
||||||
|
interface SingleUploadProps {
|
||||||
|
value: unknown;
|
||||||
|
onChange: WidgetProps['onChange'];
|
||||||
|
disabled: boolean;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
/** 'image' | 'video' */
|
||||||
|
mediaType: 'image' | 'video';
|
||||||
|
icon: React.ReactNode;
|
||||||
|
uploadLabel: string;
|
||||||
|
defaultMaxSizeMb: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SingleUpload({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
config,
|
||||||
|
mediaType,
|
||||||
|
icon,
|
||||||
|
uploadLabel,
|
||||||
|
defaultMaxSizeMb,
|
||||||
|
}: SingleUploadProps) {
|
||||||
|
const fileList = (value as UploadFile[]) || [];
|
||||||
|
const maxSizeMb = (config.maxFileSizeMb as number) || defaultMaxSizeMb;
|
||||||
|
const filters = config.fileFilter as string[] | undefined;
|
||||||
|
const hasEnableSwitch = (config.enableSwitch as boolean) || false;
|
||||||
|
|
||||||
|
const { customRequest } = useFileUpload();
|
||||||
|
const { switchOn, isUploadDisabled, handleSwitchChange } = useEnableSwitch(hasEnableSwitch, disabled, onChange);
|
||||||
|
|
||||||
|
const accept = mediaType === 'video'
|
||||||
|
? buildVideoAccept(filters)
|
||||||
|
: buildAccept(filters);
|
||||||
|
|
||||||
|
const beforeUpload = mediaType === 'video'
|
||||||
|
? createVideoBeforeUpload(maxSizeMb)
|
||||||
|
: createMediaBeforeUpload(maxSizeMb, filters);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space orientation="vertical" style={{ width: '100%' }} size={8}>
|
||||||
|
{hasEnableSwitch && (
|
||||||
|
<Space size={8}>
|
||||||
|
<Switch size="small" checked={switchOn} onChange={handleSwitchChange} disabled={disabled} />
|
||||||
|
<Text type={switchOn ? 'success' : 'secondary'} style={{ fontSize: 12 }}>
|
||||||
|
{switchOn ? '已启用' : '点击开关启用上传'}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Upload
|
||||||
|
listType="picture-card"
|
||||||
|
maxCount={1}
|
||||||
|
accept={accept}
|
||||||
|
fileList={fileList}
|
||||||
|
customRequest={customRequest}
|
||||||
|
onChange={({ fileList: newList }) => {
|
||||||
|
if (switchOn || !hasEnableSwitch) onChange?.(newList as unknown as UploadFile[]);
|
||||||
|
}}
|
||||||
|
disabled={isUploadDisabled}
|
||||||
|
beforeUpload={beforeUpload}
|
||||||
|
>
|
||||||
|
{fileList.length < 1 && (
|
||||||
|
<div style={{ opacity: isUploadDisabled ? 0.4 : 1 }}>
|
||||||
|
{icon}
|
||||||
|
<div style={{ marginTop: 4, fontSize: 12 }}>{uploadLabel}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Upload>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════ 公开导出(保持原组件名兼容 WIDGET_MAP) ════
|
||||||
|
|
||||||
|
export function ImageInput(props: WidgetProps) {
|
||||||
|
const { value, onChange, disabled, config } = props;
|
||||||
|
return (
|
||||||
|
<SingleUpload
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={disabled ?? false}
|
||||||
|
config={config}
|
||||||
|
mediaType="image"
|
||||||
|
icon={<UploadOutlined />}
|
||||||
|
uploadLabel="上传"
|
||||||
|
defaultMaxSizeMb={10}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VideoInput(props: WidgetProps) {
|
||||||
|
const { value, onChange, disabled, config } = props;
|
||||||
|
return (
|
||||||
|
<SingleUpload
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={disabled ?? false}
|
||||||
|
config={config}
|
||||||
|
mediaType="video"
|
||||||
|
icon={<VideoCameraOutlined />}
|
||||||
|
uploadLabel="上传视频"
|
||||||
|
defaultMaxSizeMb={50}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ import type { UploadFile } from 'antd/es/upload';
|
|||||||
import { useHomeContext } from '@/pages/home/HomeContext';
|
import { useHomeContext } from '@/pages/home/HomeContext';
|
||||||
import { useAppContext } from '@/components/providers/AppProvider';
|
import { useAppContext } from '@/components/providers/AppProvider';
|
||||||
import { useSubmitTask } from '@/hooks/use-api';
|
import { useSubmitTask } from '@/hooks/use-api';
|
||||||
import { emit, EVENTS } from '@/utils/event-bus';
|
import { emit, EVENTS } from '@/utils/bus';
|
||||||
import { OutputTypeMap } from '@/services/modules/task';
|
import { OutputTypeMap } from '@/services/modules/task';
|
||||||
import type { OutputTypeString, TaskInfoItemResponseBody } from '@/services/modules/task';
|
import type { OutputTypeString, TaskInfoItemResponseBody } from '@/services/modules/task';
|
||||||
import { useModelUI, isValueEmpty, type UIFieldConfig } from '../hooks/useModelUI';
|
import { useModelUI, isValueEmpty, type UIFieldConfig } from '../hooks/useModelUI';
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import type { ModelsListResponseBody } from '@/services/modules/models';
|
|||||||
import { useAppContext } from '@/components/providers/AppProvider';
|
import { useAppContext } from '@/components/providers/AppProvider';
|
||||||
import { useHomeContext } from '@/pages/home/HomeContext';
|
import { useHomeContext } from '@/pages/home/HomeContext';
|
||||||
import { useTheme } from '@/components/providers/ThemeProvider';
|
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||||
import { on, off, EVENTS } from '@/utils/event-bus';
|
import { on, off, EVENTS } from '@/utils/bus';
|
||||||
import type {
|
import type {
|
||||||
TaskInfoItemResponseBody,
|
TaskInfoItemResponseBody,
|
||||||
TaskListRequestParams,
|
TaskListRequestParams,
|
||||||
|
|||||||
@@ -530,7 +530,7 @@ export function OrdersPage() {
|
|||||||
onChange: (page: number, pageSize: number) => {
|
onChange: (page: number, pageSize: number) => {
|
||||||
setPagination({ page, page_size: pageSize });
|
setPagination({ page, page_size: pageSize });
|
||||||
},
|
},
|
||||||
position: ['bottomCenter'],
|
placement: ['bottomCenter'],
|
||||||
}}
|
}}
|
||||||
locale={{ emptyText: '暂无订单记录' }}
|
locale={{ emptyText: '暂无订单记录' }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import {
|
|||||||
getPaymentStatusText,
|
getPaymentStatusText,
|
||||||
type PaymentStatus,
|
type PaymentStatus,
|
||||||
} from '@/services/modules/payments';
|
} from '@/services/modules/payments';
|
||||||
import { emit, EVENTS } from '@/utils/event-bus';
|
import { emit, EVENTS } from '@/utils/bus';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import {
|
|||||||
} from '@/services/modules/vip-api';
|
} from '@/services/modules/vip-api';
|
||||||
import {centToYuan} from '@/utils/display';
|
import {centToYuan} from '@/utils/display';
|
||||||
import {EnumResolver} from '@/utils/enum-resolver';
|
import {EnumResolver} from '@/utils/enum-resolver';
|
||||||
import {emit, EVENTS} from '@/utils/event-bus';
|
import {emit, EVENTS} from '@/utils/bus';
|
||||||
import {RequestError, RequestErrorType} from '@/services/request';
|
import {RequestError, RequestErrorType} from '@/services/request';
|
||||||
|
|
||||||
const {Text} = Typography;
|
const {Text} = Typography;
|
||||||
@@ -275,7 +275,7 @@ export function VipPage() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{display: 'flex', justifyContent: 'center', padding: '48px 0'}}>
|
<div style={{display: 'flex', justifyContent: 'center', padding: '48px 0'}}>
|
||||||
<Spin size="default">
|
<Spin size="medium">
|
||||||
<div style={{padding: 24, textAlign: 'center'}}>
|
<div style={{padding: 24, textAlign: 'center'}}>
|
||||||
<Text type="secondary" style={{fontSize: 13}}>加载套餐列表…</Text>
|
<Text type="secondary" style={{fontSize: 13}}>加载套餐列表…</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { useCallback } from 'react';
|
|||||||
import { Modal } from 'antd';
|
import { Modal } from 'antd';
|
||||||
import { SettingOutlined, CloseOutlined } from '@ant-design/icons';
|
import { SettingOutlined, CloseOutlined } from '@ant-design/icons';
|
||||||
import { useAppContext } from '@/components/providers/AppProvider';
|
import { useAppContext } from '@/components/providers/AppProvider';
|
||||||
import { isMacOS } from '@/utils/platform';
|
import { isMacOS } from '@/utils/env';
|
||||||
import { ThemeSetting } from './sections/ThemeSetting';
|
import { ThemeSetting } from './sections/ThemeSetting';
|
||||||
import { StoragePathSetting } from './sections/StoragePathSetting';
|
import { StoragePathSetting } from './sections/StoragePathSetting';
|
||||||
import { SystemInfoSetting } from './sections/SystemInfoSetting';
|
import { SystemInfoSetting } from './sections/SystemInfoSetting';
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { CloudDownloadOutlined, DeleteOutlined, ReloadOutlined } from '@ant-desi
|
|||||||
import {
|
import {
|
||||||
initStorage, getStorageStats, clearUserStorage, saveDatabase,
|
initStorage, getStorageStats, clearUserStorage, saveDatabase,
|
||||||
} from '@/infrastructure/storage';
|
} from '@/infrastructure/storage';
|
||||||
import { fetchAndSyncTasks, fetchAndSyncRechargeOrders, fetchAndSyncVipOrders } from './data-sync';
|
import { fetchAndSyncTasks, fetchAndSyncRechargeOrders, fetchAndSyncVipOrders } from '@/infrastructure/storage/services/data-sync';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { Card, Button, Input, Typography, Space, App } from 'antd';
|
|||||||
import { FolderOpenOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
import { FolderOpenOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
||||||
import type { Edition } from '@shared/types';
|
import type { Edition } from '@shared/types';
|
||||||
import { useSettings } from '@/components/providers/SettingsProvider';
|
import { useSettings } from '@/components/providers/SettingsProvider';
|
||||||
import { hasIpc, safeIpcInvoke } from '@/utils/ipc';
|
import { hasIpc, safeIpcInvoke } from '@/utils/bus';
|
||||||
import { BIDIRECTIONAL } from '@shared/constants/ipc-channels';
|
import { BIDIRECTIONAL } from '@shared/constants/ipc-channels';
|
||||||
|
|
||||||
const { Text, Paragraph } = Typography;
|
const { Text, Paragraph } = Typography;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
EnvironmentOutlined,
|
EnvironmentOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useAppContext } from '@/components/providers/AppProvider';
|
import { useAppContext } from '@/components/providers/AppProvider';
|
||||||
import { getPlatformName } from '@/utils/platform';
|
import { getPlatformName } from '@/utils/env';
|
||||||
import { APP_VERSION } from '@shared/constants/version';
|
import { APP_VERSION } from '@shared/constants/version';
|
||||||
import { getEditionName } from '@shared/constants/app';
|
import { getEditionName } from '@shared/constants/app';
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useUpdater } from '@/hooks/use-updater';
|
import { useUpdater } from '@/hooks/use-updater';
|
||||||
import { isDev } from '@/utils/platform';
|
import { isDev } from '@/utils/env';
|
||||||
|
|
||||||
const { Text, Title } = Typography;
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {type Nullable} from "@/utils/type";
|
import {type Nullable} from "@/utils/display";
|
||||||
import {get} from "@/services/request.ts";
|
import {get} from "@/services/request.ts";
|
||||||
|
|
||||||
const BillingUrlObj = {
|
const BillingUrlObj = {
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import {get, post} from '../request';
|
import {get, post} from '../request';
|
||||||
import {getDeviceId} from '@/utils/device.ts';
|
import {getDeviceId} from '@/utils/env';
|
||||||
import {Nullable} from "@/utils/type.ts";
|
import {Nullable} from "@/utils/display";
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 基础枚举 / 字面量类型
|
// 基础枚举 / 字面量类型
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {post, get} from '../request';
|
import {post, get} from '../request';
|
||||||
import {type UserInfoBody} from "@/services/modules/auth";
|
import {type UserInfoBody} from "@/services/modules/auth";
|
||||||
import {type Nullable} from "@/utils/type";
|
import {type Nullable} from "@/utils/display";
|
||||||
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import axios, {
|
|||||||
} from 'axios';
|
} from 'axios';
|
||||||
import type { ApiResponse } from './types';
|
import type { ApiResponse } from './types';
|
||||||
import { RequestError, RequestErrorType, RefreshError, isRefreshError } from './types';
|
import { RequestError, RequestErrorType, RefreshError, isRefreshError } from './types';
|
||||||
import { emit, EVENTS } from '../utils/event-bus';
|
import { emit, EVENTS } from '../utils/bus';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/bus';
|
||||||
import { getSafeStore, setSafeStore, clearSafeStore, initSafeStore } from '../utils/safe-storage';
|
import { getSafeStore, setSafeStore, clearSafeStore, initSafeStore } from '../utils/safe-storage';
|
||||||
|
|
||||||
// ---------- 配置 ----------
|
// ---------- 配置 ----------
|
||||||
|
|||||||
167
src/utils/bus.ts
Normal file
167
src/utils/bus.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
// ============================================================
|
||||||
|
// bus — 通信层:事件总线 + IPC 守卫 + 渲染进程日志
|
||||||
|
// 合并自 event-bus.ts + ipc.ts + logger.ts
|
||||||
|
//
|
||||||
|
// 各模块职责:
|
||||||
|
// 事件总线 — 跨组件/跨模块事件通信(进程内)
|
||||||
|
// IPC 守卫 — Electron/Browser 环境安全适配(进程间)
|
||||||
|
// 日志 — 通过 IPC 发送到主进程写文件(含事件总线审计钩子)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { RENDERER_TO_MAIN } from '@shared/constants/ipc-channels';
|
||||||
|
import { sanitizeLogEntry } from '@shared/utils/sanitize';
|
||||||
|
import type { LogEntry, LogLevel, LogCategory, LogErrorSnapshot } from '@shared/types/logging';
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// 一、IPC 安全守卫(原 ipc.ts)
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
export function hasIpc(): boolean {
|
||||||
|
return typeof window !== 'undefined' && window.ipcRenderer != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeIpcOn(
|
||||||
|
channel: string,
|
||||||
|
listener: (event: unknown, ...args: unknown[]) => void,
|
||||||
|
): void {
|
||||||
|
if (hasIpc()) window.ipcRenderer.on(channel, listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeIpcOff(channel: string, listener: (...args: unknown[]) => void): void {
|
||||||
|
if (hasIpc()) window.ipcRenderer.off(channel, listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeIpcSend(channel: string, ...args: unknown[]): void {
|
||||||
|
if (hasIpc()) window.ipcRenderer.send(channel, ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function safeIpcInvoke(channel: string, ...args: unknown[]): Promise<unknown> {
|
||||||
|
if (!hasIpc()) {
|
||||||
|
console.warn(`[IPC] 非 Electron 环境,invoke "${channel}" 被忽略`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return window.ipcRenderer.invoke(channel, ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// 二、事件总线(原 event-bus.ts)
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
type Listener = (...args: unknown[]) => void;
|
||||||
|
const listeners = new Map<string, Set<Listener>>();
|
||||||
|
|
||||||
|
type EventLogListener = (event: string, ...args: unknown[]) => void;
|
||||||
|
let eventLogListener: EventLogListener | null = null;
|
||||||
|
|
||||||
|
/** 注册事件日志监听器(由下方 logger 模块自动调用,用于审计追踪) */
|
||||||
|
export function setEventLogListener(fn: EventLogListener): void {
|
||||||
|
eventLogListener = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function on(event: string, fn: Listener): void {
|
||||||
|
if (!listeners.has(event)) listeners.set(event, new Set());
|
||||||
|
listeners.get(event)!.add(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function off(event: string, fn: Listener): void {
|
||||||
|
listeners.get(event)?.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emit(event: string, ...args: unknown[]): void {
|
||||||
|
if (eventLogListener) {
|
||||||
|
try { eventLogListener(event, ...args); } catch { /* 日志钩子异常不影响投递 */ }
|
||||||
|
}
|
||||||
|
listeners.get(event)?.forEach((fn) => {
|
||||||
|
try { fn(...args); } catch (e) { console.error(`[EventBus] ${event} 回调出错:`, e); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EVENTS = {
|
||||||
|
AUTH_REQUIRED: 'auth:required',
|
||||||
|
SHOW_LOGIN: 'auth:show-login',
|
||||||
|
LOGIN_SUCCESS: 'auth:login-success',
|
||||||
|
LOGOUT: 'auth:logout',
|
||||||
|
TASK_SUBMITTED: 'task:submitted',
|
||||||
|
OPEN_SETTINGS: 'settings:open',
|
||||||
|
OPEN_PAGE: 'page:open',
|
||||||
|
CLOSE_PAGE: 'page:close',
|
||||||
|
PAGE_LOCK: 'page:lock',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// 三、渲染进程日志(原 logger.ts)
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
function errorToSnapshot(err: Error): LogErrorSnapshot {
|
||||||
|
return {
|
||||||
|
name: err.name,
|
||||||
|
message: err.message,
|
||||||
|
stack: err.stack,
|
||||||
|
cause: (err as { cause?: unknown }).cause
|
||||||
|
? String((err as { cause?: unknown }).cause)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEntry(
|
||||||
|
level: LogLevel,
|
||||||
|
category: LogCategory,
|
||||||
|
message: string,
|
||||||
|
error?: Error,
|
||||||
|
context?: Record<string, unknown>,
|
||||||
|
): LogEntry {
|
||||||
|
const entry: LogEntry = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
level,
|
||||||
|
category,
|
||||||
|
message,
|
||||||
|
processType: 'renderer',
|
||||||
|
};
|
||||||
|
if (context && Object.keys(context).length > 0) entry.context = context;
|
||||||
|
if (error) entry.error = errorToSnapshot(error);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(rawEntry: LogEntry): void {
|
||||||
|
const entry = sanitizeLogEntry(rawEntry);
|
||||||
|
if (hasIpc()) safeIpcSend(RENDERER_TO_MAIN.LOG_MESSAGE, entry);
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
const prefix = `[${entry.level}][${entry.category}]`;
|
||||||
|
const ctx = entry.context ? ` ${JSON.stringify(entry.context)}` : '';
|
||||||
|
const errMsg = entry.error ? ` — ${entry.error.name}: ${entry.error.message}` : '';
|
||||||
|
|
||||||
|
switch (entry.level) {
|
||||||
|
case 'DEBUG': console.debug(prefix, entry.message, ctx); break;
|
||||||
|
case 'INFO': console.info(prefix, entry.message, ctx); break;
|
||||||
|
case 'WARN': console.warn(prefix, entry.message + errMsg, ctx); break;
|
||||||
|
case 'ERROR': console.error(prefix, entry.message + errMsg, ctx, entry.error?.stack || ''); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logger = {
|
||||||
|
debug(category: LogCategory, message: string, context?: Record<string, unknown>): void {
|
||||||
|
send(createEntry('DEBUG', category, message, undefined, context));
|
||||||
|
},
|
||||||
|
info(category: LogCategory, message: string, context?: Record<string, unknown>): void {
|
||||||
|
send(createEntry('INFO', category, message, undefined, context));
|
||||||
|
},
|
||||||
|
warn(category: LogCategory, message: string, error?: Error, context?: Record<string, unknown>): void {
|
||||||
|
send(createEntry('WARN', category, message, error, context));
|
||||||
|
},
|
||||||
|
error(category: LogCategory, message: string, error?: Error, context?: Record<string, unknown>): void {
|
||||||
|
send(createEntry('ERROR', category, message, error, context));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 事件总线审计钩子(模块加载时自动注册)
|
||||||
|
let eventHookRegistered = false;
|
||||||
|
export function registerEventLogHook(): void {
|
||||||
|
if (eventHookRegistered) return;
|
||||||
|
eventHookRegistered = true;
|
||||||
|
setEventLogListener((event: string, ...args: unknown[]) => {
|
||||||
|
logger.debug('event', 'Event emitted', { event, args });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
registerEventLogHook();
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// 设备标识 — 生成/读取持久化设备 ID
|
|
||||||
// 用于登录/注册时标识当前设备(MAC 地址通常不可行,用 UUID 替代)
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
const DEVICE_ID_KEY = 'ele-heixiu-device-id';
|
|
||||||
|
|
||||||
/** 生成 UUID v4(兼容所有浏览器) */
|
|
||||||
function generateUUID(): string {
|
|
||||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
||||||
const r = (Math.random() * 16) | 0;
|
|
||||||
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取设备唯一标识(若无则自动生成并持久化到 localStorage) */
|
|
||||||
export function getDeviceId(): string {
|
|
||||||
try {
|
|
||||||
let id = localStorage.getItem(DEVICE_ID_KEY);
|
|
||||||
if (!id) {
|
|
||||||
id = generateUUID();
|
|
||||||
localStorage.setItem(DEVICE_ID_KEY, id);
|
|
||||||
}
|
|
||||||
return id;
|
|
||||||
} catch {
|
|
||||||
// localStorage 不可用时返回一次性 UUID
|
|
||||||
return generateUUID();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,41 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// display — 显示格式化工具(金额、日期等)
|
// display — 展示/格式化工具(金额、日期、Logo 资产、类型别名)
|
||||||
// 各页面共用的纯函数,避免重复定义
|
// 合并自 display.ts + logo.ts + type.ts
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
/** 将分转为元,保留两位小数 */
|
import { isMacOS, isWindows } from './env';
|
||||||
|
|
||||||
|
// ========== 金额 & 日期格式化(原 display.ts)==========
|
||||||
|
|
||||||
export function centToYuan(cent: number): string {
|
export function centToYuan(cent: number): string {
|
||||||
return (cent / 100).toFixed(2);
|
return (cent / 100).toFixed(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 格式化日期为 zh-CN 本地字符串 */
|
|
||||||
export function formatDate(date: Date | string | undefined): string {
|
export function formatDate(date: Date | string | undefined): string {
|
||||||
if (!date) return '—';
|
if (!date) return '—';
|
||||||
const d = typeof date === 'string' ? new Date(date) : date;
|
const d = typeof date === 'string' ? new Date(date) : date;
|
||||||
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' });
|
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== Logo 资产(原 logo.ts)==========
|
||||||
|
|
||||||
|
import hxPng from '../assets/logo/HX.png';
|
||||||
|
import hx2Png from '../assets/logo/HX2.png';
|
||||||
|
|
||||||
|
export function getMainLogoUrl(): string { return hxPng; }
|
||||||
|
export function getAltLogoUrl(): string { return hx2Png; }
|
||||||
|
|
||||||
|
export function getFaviconPath(): string {
|
||||||
|
if (isWindows()) return '/src/assets/logo/heixiu.ico';
|
||||||
|
return hxPng;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlatformIconFormatInfo(): string {
|
||||||
|
if (isMacOS()) return '.icns(当前使用 .png 回退)';
|
||||||
|
if (isWindows()) return '.ico';
|
||||||
|
return '.png';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 类型别名(原 type.ts)==========
|
||||||
|
|
||||||
|
export type Nullable<T> = T | null;
|
||||||
|
|||||||
71
src/utils/env.ts
Normal file
71
src/utils/env.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
// ============================================================
|
||||||
|
// env — 运行时环境信息(平台检测 + 设备标识)
|
||||||
|
// 合并自 platform.ts + device.ts
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import type { Platform } from '@shared/types';
|
||||||
|
|
||||||
|
// ========== 平台检测 ==========
|
||||||
|
|
||||||
|
export function getPlatform(): Platform {
|
||||||
|
// 如果 preload 暴露了 platform 信息,优先使用
|
||||||
|
if (typeof window !== 'undefined' && window.ipcRenderer) {
|
||||||
|
const ua = navigator.userAgent.toLowerCase();
|
||||||
|
if (ua.includes('mac os') || ua.includes('macintosh')) return 'darwin';
|
||||||
|
if (ua.includes('windows') || ua.includes('win32') || ua.includes('win64')) return 'win32';
|
||||||
|
if (ua.includes('linux')) return 'linux';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ua = navigator.userAgent.toLowerCase();
|
||||||
|
if (ua.includes('mac')) return 'darwin';
|
||||||
|
if (ua.includes('win')) return 'win32';
|
||||||
|
if (ua.includes('linux')) return 'linux';
|
||||||
|
|
||||||
|
return 'win32';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMacOS(): boolean { return getPlatform() === 'darwin'; }
|
||||||
|
export function isWindows(): boolean { return getPlatform() === 'win32'; }
|
||||||
|
export function isLinux(): boolean { return getPlatform() === 'linux'; }
|
||||||
|
|
||||||
|
export function getPlatformName(): string {
|
||||||
|
if (isMacOS()) return 'macOS';
|
||||||
|
if (isWindows()) return 'Windows';
|
||||||
|
if (isLinux()) return 'Linux';
|
||||||
|
return '未知平台';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShortcutDisplay(key: string): string {
|
||||||
|
return isMacOS() ? `⌘+${key}` : `Ctrl+${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getContextMenuHint(): string {
|
||||||
|
return isMacOS() ? '右键点击 或 Ctrl+点击' : '右键点击';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDev(): boolean { return import.meta.env.DEV; }
|
||||||
|
export function isProd(): boolean { return import.meta.env.PROD; }
|
||||||
|
|
||||||
|
// ========== 设备标识 ==========
|
||||||
|
|
||||||
|
const DEVICE_ID_KEY = 'ele-heixiu-device-id';
|
||||||
|
|
||||||
|
function generateUUID(): string {
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||||
|
const r = (Math.random() * 16) | 0;
|
||||||
|
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDeviceId(): string {
|
||||||
|
try {
|
||||||
|
let id = localStorage.getItem(DEVICE_ID_KEY);
|
||||||
|
if (!id) {
|
||||||
|
id = generateUUID();
|
||||||
|
localStorage.setItem(DEVICE_ID_KEY, id);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
} catch {
|
||||||
|
return generateUUID();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// 事件总线 — 跨组件/跨模块事件通信
|
|
||||||
// 用于 axios 拦截器 → App 触发登录弹窗等解耦场景
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
type Listener = (...args: unknown[]) => void;
|
|
||||||
|
|
||||||
const listeners = new Map<string, Set<Listener>>();
|
|
||||||
|
|
||||||
// ---------- 日志钩子(由 logger 模块注册,避免循环依赖)----------
|
|
||||||
|
|
||||||
type EventLogListener = (event: string, ...args: unknown[]) => void;
|
|
||||||
let eventLogListener: EventLogListener | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 注册事件日志监听器(由 logger 模块调用)
|
|
||||||
* 每次 emit() 时回调,用于审计追踪
|
|
||||||
*/
|
|
||||||
export function setEventLogListener(fn: EventLogListener): void {
|
|
||||||
eventLogListener = fn;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- API ----------
|
|
||||||
|
|
||||||
/** 监听事件 */
|
|
||||||
export function on(event: string, fn: Listener): void {
|
|
||||||
if (!listeners.has(event)) {
|
|
||||||
listeners.set(event, new Set());
|
|
||||||
}
|
|
||||||
listeners.get(event)!.add(fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 取消监听 */
|
|
||||||
export function off(event: string, fn: Listener): void {
|
|
||||||
listeners.get(event)?.delete(fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 触发事件 */
|
|
||||||
export function emit(event: string, ...args: unknown[]): void {
|
|
||||||
// ① 通知日志监听器(在触发业务监听器之前记录)
|
|
||||||
if (eventLogListener) {
|
|
||||||
try {
|
|
||||||
eventLogListener(event, ...args);
|
|
||||||
} catch {
|
|
||||||
/* 日志钩子异常不影响事件投递 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ② 通知业务监听器
|
|
||||||
listeners.get(event)?.forEach((fn) => {
|
|
||||||
try {
|
|
||||||
fn(...args);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`[EventBus] ${event} 回调出错:`, e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 预定义事件名
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export const EVENTS = {
|
|
||||||
/** 需要登录(axios 返回 401 时触发) */
|
|
||||||
AUTH_REQUIRED: 'auth:required',
|
|
||||||
/** 显示登录弹窗(用户点击登录按钮触发) */
|
|
||||||
SHOW_LOGIN: 'auth:show-login',
|
|
||||||
/** 登录成功 */
|
|
||||||
LOGIN_SUCCESS: 'auth:login-success',
|
|
||||||
/** 退出登录 */
|
|
||||||
LOGOUT: 'auth:logout',
|
|
||||||
/** 任务提交成功(→ 刷新任务列表) */
|
|
||||||
TASK_SUBMITTED: 'task:submitted',
|
|
||||||
/** 打开设置 Drawer(导航栏"设置"按钮点击) */
|
|
||||||
OPEN_SETTINGS: 'settings:open',
|
|
||||||
/** 打开页面 Modal(导航栏点击页面导航项触发,携带 target 路径标识符) */
|
|
||||||
OPEN_PAGE: 'page:open',
|
|
||||||
/** 关闭当前页面(由页面组件内部按钮触发,PageDispatcher 响应) */
|
|
||||||
CLOSE_PAGE: 'page:close',
|
|
||||||
/** 锁定/解锁页面关闭(页面组件内部操作进行中时禁止关闭容器,PageDispatcher 响应) */
|
|
||||||
PAGE_LOCK: 'page:lock',
|
|
||||||
} as const;
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// IPC 安全访问守卫
|
|
||||||
// window.ipcRenderer 仅在 Electron 环境中存在(由 preload 注入),
|
|
||||||
// 浏览器环境下为 undefined,所有调用必须先行判断。
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
/** 检查是否在 Electron 环境中(window.ipcRenderer 可用) */
|
|
||||||
export function hasIpc(): boolean {
|
|
||||||
return typeof window !== 'undefined' && window.ipcRenderer != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 安全地监听 IPC 消息 */
|
|
||||||
export function safeIpcOn(
|
|
||||||
channel: string,
|
|
||||||
listener: (event: unknown, ...args: unknown[]) => void,
|
|
||||||
): void {
|
|
||||||
if (hasIpc()) {
|
|
||||||
window.ipcRenderer.on(channel, listener);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 安全地取消监听 IPC 消息 */
|
|
||||||
export function safeIpcOff(channel: string, listener: (...args: unknown[]) => void): void {
|
|
||||||
if (hasIpc()) {
|
|
||||||
window.ipcRenderer.off(channel, listener);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 安全地发送 IPC 消息(不等待响应) */
|
|
||||||
export function safeIpcSend(channel: string, ...args: unknown[]): void {
|
|
||||||
if (hasIpc()) {
|
|
||||||
window.ipcRenderer.send(channel, ...args);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 安全地调用 IPC(等待响应) */
|
|
||||||
export async function safeIpcInvoke(channel: string, ...args: unknown[]): Promise<unknown> {
|
|
||||||
if (!hasIpc()) {
|
|
||||||
console.warn(`[IPC] 非 Electron 环境,invoke "${channel}" 被忽略`);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return window.ipcRenderer.invoke(channel, ...args);
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// 渲染进程日志器 — 通过 IPC 转发到主进程写入文件
|
|
||||||
//
|
|
||||||
// 用法:
|
|
||||||
// import { logger } from '@/utils/logger';
|
|
||||||
// logger.info('auth', 'User logged in', { userId: 42 });
|
|
||||||
// logger.warn('request', 'Request timeout', new Error('...'), { url: '...' });
|
|
||||||
//
|
|
||||||
// 行为:
|
|
||||||
// Electron 环境 → safeIpcSend('log-message', entry) 发往主进程
|
|
||||||
// 浏览器 DEV 环境 → console[level] 输出(不回退文件)
|
|
||||||
//
|
|
||||||
// 事件总线联动:
|
|
||||||
// 调用 registerEventLogHook() 后,所有 emit() 自动记录 DEBUG event 日志
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { safeIpcSend, hasIpc } from './ipc';
|
|
||||||
import { RENDERER_TO_MAIN } from '@shared/constants/ipc-channels';
|
|
||||||
import { setEventLogListener } from './event-bus';
|
|
||||||
import { sanitizeLogEntry } from '@shared/utils/sanitize';
|
|
||||||
import type { LogEntry, LogLevel, LogCategory, LogErrorSnapshot } from '@shared/types/logging';
|
|
||||||
|
|
||||||
// ---------- 辅助 ----------
|
|
||||||
|
|
||||||
/** 错误对象 → 可序列化快照 */
|
|
||||||
function errorToSnapshot(err: Error): LogErrorSnapshot {
|
|
||||||
return {
|
|
||||||
name: err.name,
|
|
||||||
message: err.message,
|
|
||||||
stack: err.stack,
|
|
||||||
cause: (err as { cause?: unknown }).cause
|
|
||||||
? String((err as { cause?: unknown }).cause)
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 构建日志条目 */
|
|
||||||
function createEntry(
|
|
||||||
level: LogLevel,
|
|
||||||
category: LogCategory,
|
|
||||||
message: string,
|
|
||||||
error?: Error,
|
|
||||||
context?: Record<string, unknown>,
|
|
||||||
): LogEntry {
|
|
||||||
const entry: LogEntry = {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
level,
|
|
||||||
category,
|
|
||||||
message,
|
|
||||||
processType: 'renderer',
|
|
||||||
};
|
|
||||||
if (context && Object.keys(context).length > 0) {
|
|
||||||
entry.context = context;
|
|
||||||
}
|
|
||||||
if (error) {
|
|
||||||
entry.error = errorToSnapshot(error);
|
|
||||||
}
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 发送日志条目 */
|
|
||||||
function send(rawEntry: LogEntry): void {
|
|
||||||
const entry = sanitizeLogEntry(rawEntry);
|
|
||||||
|
|
||||||
// Electron 环境 → IPC 发往主进程写入文件
|
|
||||||
if (hasIpc()) {
|
|
||||||
safeIpcSend(RENDERER_TO_MAIN.LOG_MESSAGE, entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
// DEV 模式 → 同时输出到浏览器控制台(开发者可见)
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
const prefix = `[${entry.level}][${entry.category}]`;
|
|
||||||
const ctx = entry.context ? ` ${JSON.stringify(entry.context)}` : '';
|
|
||||||
const errMsg = entry.error ? ` — ${entry.error.name}: ${entry.error.message}` : '';
|
|
||||||
|
|
||||||
switch (entry.level) {
|
|
||||||
case 'DEBUG':
|
|
||||||
console.debug(prefix, entry.message, ctx);
|
|
||||||
break;
|
|
||||||
case 'INFO':
|
|
||||||
console.info(prefix, entry.message, ctx);
|
|
||||||
break;
|
|
||||||
case 'WARN':
|
|
||||||
console.warn(prefix, entry.message + errMsg, ctx);
|
|
||||||
break;
|
|
||||||
case 'ERROR':
|
|
||||||
console.error(prefix, entry.message + errMsg, ctx, entry.error?.stack || '');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 对外 API ----------
|
|
||||||
|
|
||||||
export const logger = {
|
|
||||||
debug(category: LogCategory, message: string, context?: Record<string, unknown>): void {
|
|
||||||
send(createEntry('DEBUG', category, message, undefined, context));
|
|
||||||
},
|
|
||||||
info(category: LogCategory, message: string, context?: Record<string, unknown>): void {
|
|
||||||
send(createEntry('INFO', category, message, undefined, context));
|
|
||||||
},
|
|
||||||
warn(
|
|
||||||
category: LogCategory,
|
|
||||||
message: string,
|
|
||||||
error?: Error,
|
|
||||||
context?: Record<string, unknown>,
|
|
||||||
): void {
|
|
||||||
send(createEntry('WARN', category, message, error, context));
|
|
||||||
},
|
|
||||||
error(
|
|
||||||
category: LogCategory,
|
|
||||||
message: string,
|
|
||||||
error?: Error,
|
|
||||||
context?: Record<string, unknown>,
|
|
||||||
): void {
|
|
||||||
send(createEntry('ERROR', category, message, error, context));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------- 事件总线联动 ----------
|
|
||||||
|
|
||||||
let eventHookRegistered = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 注册事件总线日志钩子(单次调用,重复调用无副作用)
|
|
||||||
* 注册后所有 emit() 调用自动记录为 DEBUG event 日志
|
|
||||||
*/
|
|
||||||
export function registerEventLogHook(): void {
|
|
||||||
if (eventHookRegistered) return;
|
|
||||||
eventHookRegistered = true;
|
|
||||||
|
|
||||||
setEventLogListener((event: string, ...args: unknown[]) => {
|
|
||||||
// 过滤高频事件,避免日志文件膨胀
|
|
||||||
// 仅记录非 LOGIN_SUCCESS / LOGOUT 的开销极低的事件
|
|
||||||
logger.debug('event', 'Event emitted', { event, args });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 模块加载时自动注册事件总线钩子
|
|
||||||
registerEventLogHook();
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// 渲染进程 Logo 自适应工具
|
|
||||||
// 提供按平台返回正确 Logo 资源路径的函数,
|
|
||||||
// 用于 <img src> 等场景。
|
|
||||||
//
|
|
||||||
// 资源存放在 src/assets/logo/
|
|
||||||
// heixiu.ico — Windows 多尺寸图标
|
|
||||||
// HX.png — 通用 PNG Logo
|
|
||||||
// HX2.png — 备用 PNG Logo(方形变体)
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { isMacOS, isWindows } from './platform';
|
|
||||||
|
|
||||||
// Vite 中直接 import 图片资源得到编译后的 URL
|
|
||||||
import hxPng from '../assets/logo/HX.png';
|
|
||||||
import hx2Png from '../assets/logo/HX2.png';
|
|
||||||
// .ico 文件通常不太适合 <img> 标签,但在 Electron 窗口图标中使用
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取主 Logo 图片 URL(用于界面展示、关于对话框等)
|
|
||||||
* 所有平台统一使用 PNG
|
|
||||||
*/
|
|
||||||
export function getMainLogoUrl(): string {
|
|
||||||
return hxPng;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取备用/方形 Logo 图片 URL
|
|
||||||
*/
|
|
||||||
export function getAltLogoUrl(): string {
|
|
||||||
return hx2Png;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取浏览器 favicon 路径
|
|
||||||
* Windows 可用 .ico,其他平台用 .png
|
|
||||||
*/
|
|
||||||
export function getFaviconPath(): string {
|
|
||||||
if (isWindows()) {
|
|
||||||
return '/src/assets/logo/heixiu.ico';
|
|
||||||
}
|
|
||||||
return hxPng;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取平台推荐的应用图标格式说明
|
|
||||||
* 用于调试或信息展示
|
|
||||||
*/
|
|
||||||
export function getPlatformIconFormatInfo(): string {
|
|
||||||
if (isMacOS()) return '.icns(当前使用 .png 回退)';
|
|
||||||
if (isWindows()) return '.ico';
|
|
||||||
return '.png';
|
|
||||||
}
|
|
||||||
89
src/utils/media-upload.ts
Normal file
89
src/utils/media-upload.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
// ============================================================
|
||||||
|
// media-upload — 媒体上传共享工具
|
||||||
|
//
|
||||||
|
// 合并自:
|
||||||
|
// 1. src/pages/home/controls/media-upload-utils.ts
|
||||||
|
// 2. VideoInput.tsx 中的 createVideoBeforeUpload / buildVideoAccept
|
||||||
|
//
|
||||||
|
// 使用者:ImageInput / ImageListInput / VideoInput / AudioListInput
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { message, Upload } from 'antd';
|
||||||
|
import type { RcFile } from 'antd/es/upload';
|
||||||
|
|
||||||
|
/** 常见视频扩展名 */
|
||||||
|
export const VIDEO_EXTENSIONS = new Set(['mp4', 'webm', 'mov', 'avi', 'mkv', 'flv', 'wmv']);
|
||||||
|
|
||||||
|
// ========== accept 字符串构建 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建媒体文件 accept 字符串,自动区分 image/ video/ 前缀。
|
||||||
|
* @example
|
||||||
|
* buildAccept(['png', 'jpg']) → "image/png,image/jpg"
|
||||||
|
* buildAccept(['mp4', 'webm']) → "video/mp4,video/webm"
|
||||||
|
* buildAccept([]) → "image/png,image/jpg,image/jpeg,image/webp"
|
||||||
|
*/
|
||||||
|
export function buildAccept(filters?: string[]): string {
|
||||||
|
if (!filters || filters.length === 0) return 'image/png,image/jpg,image/jpeg,image/webp';
|
||||||
|
return filters.map((ext) => {
|
||||||
|
if (VIDEO_EXTENSIONS.has(ext.toLowerCase())) return `video/${ext}`;
|
||||||
|
return `image/${ext}`;
|
||||||
|
}).join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建视频 accept 字符串 */
|
||||||
|
export function buildVideoAccept(filters?: string[]): string {
|
||||||
|
if (!filters || filters.length === 0) return 'video/mp4,video/webm,video/quicktime,video/x-msvideo';
|
||||||
|
return filters.map((ext) => `video/${ext}`).join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== beforeUpload 校验工厂 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建上传前的类型 & 大小校验函数(ImageInput / ImageListInput 共用)。
|
||||||
|
* 当 file_filter 包含视频扩展名时,自动放行 video/* MIME。
|
||||||
|
*/
|
||||||
|
export function createMediaBeforeUpload(maxSizeMb: number = 10, filters?: string[]) {
|
||||||
|
const hasVideoFilters = filters?.some(f => VIDEO_EXTENSIONS.has(f.toLowerCase()));
|
||||||
|
|
||||||
|
return (file: RcFile) => {
|
||||||
|
const isImage = file.type.startsWith('image/');
|
||||||
|
const isVideo = file.type.startsWith('video/');
|
||||||
|
|
||||||
|
if (isVideo && hasVideoFilters) {
|
||||||
|
if (file.size / 1024 / 1024 >= maxSizeMb) {
|
||||||
|
message.error(`视频大小不能超过 ${maxSizeMb}MB`);
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isImage) {
|
||||||
|
message.error(hasVideoFilters ? '只能上传图片或视频文件' : '只能上传图片文件');
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size / 1024 / 1024 >= maxSizeMb) {
|
||||||
|
message.error(`图片大小不能超过 ${maxSizeMb}MB`);
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建视频上传前的类型 & 大小校验函数(VideoInput 用)。
|
||||||
|
*/
|
||||||
|
export function createVideoBeforeUpload(maxSizeMb: number = 50) {
|
||||||
|
return (file: RcFile) => {
|
||||||
|
if (!file.type.startsWith('video/')) {
|
||||||
|
message.error('只能上传视频文件');
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}
|
||||||
|
if (file.size / 1024 / 1024 >= maxSizeMb) {
|
||||||
|
message.error(`视频大小不能超过 ${maxSizeMb}MB`);
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// 渲染进程平台判定工具
|
|
||||||
// 通过 preload 暴露的 process.platform 或 navigator.userAgent 判定
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import type { Platform } from '@shared/types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取当前平台标识
|
|
||||||
* 优先使用 preload 暴露的 Electron process.platform,
|
|
||||||
* 降级使用 navigator.userAgent 推断
|
|
||||||
*/
|
|
||||||
export function getPlatform(): Platform {
|
|
||||||
// 如果 preload 暴露了 platform 信息,优先使用
|
|
||||||
if (typeof window !== 'undefined' && window.ipcRenderer) {
|
|
||||||
// 通过 IPC 或 User-Agent 判断
|
|
||||||
const ua = navigator.userAgent.toLowerCase();
|
|
||||||
if (ua.includes('mac os') || ua.includes('macintosh')) return 'darwin';
|
|
||||||
if (ua.includes('windows') || ua.includes('win32') || ua.includes('win64')) return 'win32';
|
|
||||||
if (ua.includes('linux')) return 'linux';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 降级方案:基于 userAgent 判断
|
|
||||||
const ua = navigator.userAgent.toLowerCase();
|
|
||||||
if (ua.includes('mac')) return 'darwin';
|
|
||||||
if (ua.includes('win')) return 'win32';
|
|
||||||
if (ua.includes('linux')) return 'linux';
|
|
||||||
|
|
||||||
// 默认为 Windows
|
|
||||||
return 'win32';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 是否为 macOS */
|
|
||||||
export function isMacOS(): boolean {
|
|
||||||
return getPlatform() === 'darwin';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 是否为 Windows */
|
|
||||||
export function isWindows(): boolean {
|
|
||||||
return getPlatform() === 'win32';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 是否为 Linux */
|
|
||||||
export function isLinux(): boolean {
|
|
||||||
return getPlatform() === 'linux';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取平台名称(中文) */
|
|
||||||
export function getPlatformName(): string {
|
|
||||||
if (isMacOS()) return 'macOS';
|
|
||||||
if (isWindows()) return 'Windows';
|
|
||||||
if (isLinux()) return 'Linux';
|
|
||||||
return '未知平台';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取快捷键修饰符展示文本
|
|
||||||
* macOS 用户习惯 ⌘ 符号,Windows/Linux 显示 Ctrl
|
|
||||||
*/
|
|
||||||
export function getShortcutDisplay(key: string): string {
|
|
||||||
if (isMacOS()) {
|
|
||||||
return `⌘+${key}`;
|
|
||||||
}
|
|
||||||
return `Ctrl+${key}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取适合当前平台的菜单行为提示
|
|
||||||
*/
|
|
||||||
export function getContextMenuHint(): string {
|
|
||||||
if (isMacOS()) {
|
|
||||||
return '右键点击 或 Ctrl+点击';
|
|
||||||
}
|
|
||||||
return '右键点击';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断是否为开发环境
|
|
||||||
*/
|
|
||||||
export function isDev(): boolean {
|
|
||||||
return import.meta.env.DEV;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断是否为生产环境
|
|
||||||
*/
|
|
||||||
export function isProd(): boolean {
|
|
||||||
return import.meta.env.PROD;
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
// safeStore.clear('my-key');
|
// safeStore.clear('my-key');
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { hasIpc } from './ipc';
|
import { hasIpc } from './bus';
|
||||||
|
|
||||||
// ---------- 内部状态 ----------
|
// ---------- 内部状态 ----------
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export type Nullable<T> =T | null;
|
|
||||||
4
src/vite-env.d.ts
vendored
4
src/vite-env.d.ts
vendored
@@ -24,6 +24,10 @@ declare global {
|
|||||||
fileSize(relativePath: string): Promise<number | null>;
|
fileSize(relativePath: string): Promise<number | null>;
|
||||||
fileExists(relativePath: string): Promise<boolean>;
|
fileExists(relativePath: string): Promise<boolean>;
|
||||||
};
|
};
|
||||||
|
/** 应用运行时 API(由 preload 注入) */
|
||||||
|
appRuntime: {
|
||||||
|
setWindowEdition(edition: string | null): void;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ export default defineConfig(({mode}) => {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
define: {
|
define: {
|
||||||
'process.env.UPDATE_API_URL': JSON.stringify(env.UPDATE_API_URL || 'https://www.heixiu.com'),
|
'process.env.UPDATE_API_URL': JSON.stringify(env.UPDATE_API_URL || 'https://www.heixiu.com'),
|
||||||
'process.env.EDITION': JSON.stringify(env.EDITION || 'personal'),
|
|
||||||
},
|
},
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 开发服务器 & HMR 配置
|
// 开发服务器 & HMR 配置
|
||||||
|
|||||||
Reference in New Issue
Block a user