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:
2026-06-22 13:36:42 +08:00
parent e4d66a2ac0
commit 8b697e775d
95 changed files with 1158 additions and 1295 deletions

View File

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

View File

@@ -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

View File

@@ -1,6 +1,57 @@
# 更新日志 # 更新日志
> 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。 > 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。
---
## 0.0.242026-06-22
**去除构建时版本变量 + 窗口标题动态版控 + 退出登录保留缓存**
### 版控体系重构
- **去除构建时 `EDITION` 环境变量**`--personal`/`--enterprise` 参数从 `build.mjs` 移除,`vite.config.ts` 不再注入 `process.env.EDITION``.env.*` 清理相应行
- **统一的安装包文件名**`船长-HeiXiu-{os}-{版本号}-Setup.{ext}`(移除 `${env.EDITION}`
- **`package.json` 简化**:移除 3 条 `:enterprise` 构建脚本
- **`scripts/*.mjs` 统一**`upload-oss`/`generate-update-info`/`publish-release` 不再接受 `edition` 参数
### 窗口标题动态版控
- **新格式**`船长-HeiXiu-{os}-{版本号}`(未登录)/ `船长-HeiXiu-{os}-{版本号}·{版控}`(登录后)
- **运行时链路**`AppProvider` 根据 `user.account_type` 派生 `edition``useEffect``window.appRuntime.setWindowEdition()` → IPC `set-window-title` → 主进程 `buildWindowTitle``mainWindow.setTitle`
- **Edtion 类型守卫**`src/vite-env.d.ts` + `electron/electron-env.d.ts` 双端类型声明
- **更新检查 API** 移除 `edition` 查询参数
### 本地存储生命周期修正
- **退出登录不再自动清缓存**`AppProvider.logout()` 移除 `clearUserStorage()` 调用。数据已按 `user_id` 隔离,同一用户重新登录缓存直接命中
- **`clearUserStorage` 竞态修复**:添加 `isOpen()` 前置守卫,防止数据库未初始化时执行 SQL移除尾部 `closeDatabase()`,确保退出→重新登录周期内数据库持续可用
- **手动清理保留**`DataManageSetting` 的「清理缓存」按钮仍可主动触发
---
## 0.0.232026-06-22
**三栏等比缩放修复 + 代码重组**
### 三栏布局修复
- **约束逻辑修正**:双遍最小值约束改为 clamp + 超限兜底,修复窗口缩小至 1036px 以下时中列被挤出视口
- **双触发器**`ResizeObserver` + `window.resize` 事件双重驱动,防止 React 批量更新吞掉重绘
- **三栏等比缩放**:旧算法只保 left:right 比例,中列被动吸收剩余;新算法通过 `prevWidthRef` 反推中列上次宽度,三栏按各自占比同步缩放——窗口从半屏拉到全屏保持 ≈1:1:1
### 代码重组(第二轮)
- **src/utils/**11 文件精简为 7 文件 —— `env.ts`(platform+device)、`bus.ts`(event-bus+ipc+logger)、`display.ts`(display+logo+type)、`media-upload.ts`
- **controls/**14 文件精简为 6 文件 —— `simple-widgets.tsx`(6合1)、`upload-widgets.tsx`(ImageInput+VideoInput 去重)、`list-upload-widgets.tsx`(ImageListInput+AudioListInput)
- **FormField 提升**:从 `auth/components/` 提升到 `src/components/ui/``FieldConfig` 类型同步移到 `ui/types.ts`
- **data-sync 归位**:从 `settings/sections/` 移到 `infrastructure/storage/`
- **electron/main/ 分组**3 个 IPC handlerlog-ipc/safe-storage-ipc/storage-ipc收拢到 `ipc/` 子目录
- **shell/ → frame/**`src/components/shell/` 重命名为 `frame/`,更直观表达"应用框架"
- **storage/ 分层**15 文件平铺 → `core/`4+ `stores/`5+ `services/`4+ 入口
### 文档
- 37 个文件夹全部生成 README.md不进 Git
- PROJECT.md 目录树同步更新
--- ---
## 0.0.222026-06-18 ## 0.0.222026-06-18

View File

@@ -7,8 +7,9 @@
- 进行任务时都需要考虑安全、性能、主题切换、业务自定义错误、事件总线驱动方式以及日志记录; - 进行任务时都需要考虑安全、性能、主题切换、业务自定义错误、事件总线驱动方式以及日志记录;
- 进行系统级别的操作时需要考虑系统差异并处理兼容性。如WINDOWS、MAC OS - 进行系统级别的操作时需要考虑系统差异并处理兼容性。如WINDOWS、MAC OS
- 处理业务时,需要同时考虑兜底机制和业务自定义错误捕获; - 处理业务时,需要同时考虑兜底机制和业务自定义错误捕获,比如后端在修改了某些数据的枚举值但没有通知时,需要能够正常转化对用户友好的提示信息
- 所有设计思路、代码实现均需要考虑以上三个条件。 - 新增文件时,需要注意各个文件夹当前平铺的文件数量,如果过多需要考虑合并文件或者合并为子文件夹;
- 所有设计思路、代码实现均需要考虑以上四个条件。
# 相关文档说明 # 相关文档说明

View File

@@ -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 handlerheixiu-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 handlerheixiu-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 HooksBanner/模型/任务/账户/财务) │ │ ├── use-api.ts # 业务 API HooksBanner/模型/任务/账户/财务)
│ │ └── 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 Store5 文件)
│ │ │ ├── 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/ # useModelUIparam_schema → UIFieldConfig │ │ │ └── hooks/ # useModelUIparam_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
View File

@@ -1,6 +1,14 @@
# TODO — 船长·HeiXiu 待办与优化清单 # TODO — 船长·HeiXiu 待办与优化清单
> 最后更新2026-06-18 > 最后更新2026-06-22
---
## ✅ 近期完成
- **构建时 EDITION 变量已移除**:版控全部收敛到后端 `account_type` 运行时判断,不再区分个人版/企业版构建产物
- **窗口标题动态版控**:登录后通过 IPC 追加 `·个人版`/`·企业版` 后缀
- **退出登录不再自动清空本地缓存**`user_id` 隔离 + 手动清理按钮
--- ---

View File

@@ -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')}`);

View File

@@ -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;
};
} }

View File

@@ -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();

View File

@@ -3,9 +3,9 @@
// ============================================================ // ============================================================
import { ipcMain } from 'electron'; import { ipcMain } from 'electron';
import { RENDERER_TO_MAIN } from '../../shared/constants/ipc-channels'; import { RENDERER_TO_MAIN } from '../../../shared/constants/ipc-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

View File

@@ -16,8 +16,8 @@
// ============================================================ // ============================================================
import { ipcMain, safeStorage } from 'electron'; import { ipcMain, safeStorage } from 'electron';
import { BIDIRECTIONAL } from '../../shared/constants/ipc-channels'; import { BIDIRECTIONAL } from '../../../shared/constants/ipc-channels';
import { logger } from './logger'; import { logger } from '../logger';
/** 检查 safeStorage 是否可用,不可用时记录警告 */ /** 检查 safeStorage 是否可用,不可用时记录警告 */
function checkAvailability(): boolean { function checkAvailability(): boolean {

View File

@@ -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';
// ---------- 内部工具 ---------- // ---------- 内部工具 ----------

View File

@@ -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',
}, },
}, },
); );

View File

@@ -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' | nullnull 清除后缀)
*/
setWindowEdition(edition: string | null): void {
ipcRenderer.send(RENDERER_TO_MAIN.SET_WINDOW_TITLE, edition);
},
});

View File

@@ -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,

View File

@@ -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');

View File

@@ -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 {

View File

@@ -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}"`);
} }

View File

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

View File

@@ -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';
/** /**
* 根组件 — 主窗口 * 根组件 — 主窗口

View File

@@ -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';

View File

@@ -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';

View File

@@ -10,12 +10,12 @@
import { createContext, useContext, useState, useMemo, useCallback, useEffect, type ReactNode } from 'react'; import { createContext, useContext, useState, useMemo, useCallback, useEffect, type ReactNode } from 'react';
import type { Platform, Edition } from '@shared/types'; import type { Platform, Edition } from '@shared/types';
import { getPlatform, isDev } from '@/utils/platform'; import { getPlatform, isDev } from '@/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 存储 + 自动登录 ----------

View File

@@ -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;
} }

View 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;
}

View File

@@ -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';
// ---------- 错误信息映射 ---------- // ---------- 错误信息映射 ----------

View File

@@ -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';
// ---------- 类型 ---------- // ---------- 类型 ----------

View File

@@ -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';
// ---------- 内部状态 ---------- // ---------- 内部状态 ----------

View File

@@ -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';

View File

@@ -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', '用户存储数据已清理');
} }

View File

@@ -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 ----------

View File

@@ -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;

View File

@@ -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';
// ---------- 类型 ---------- // ---------- 类型 ----------

View 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;

View File

@@ -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 ----------

View File

@@ -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';
// ---------- 通用接口 ---------- // ---------- 通用接口 ----------

View File

@@ -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;

View File

@@ -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 ----------

View File

@@ -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';
// ---------- 序列化 ---------- // ---------- 序列化 ----------

View File

@@ -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, {

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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 比例,中列被动吸收剩余空间——窗口从半屏拉到全屏时
* 新增宽度全部给了左右栏,中列反而被挤到 CENTER_MIN。
*
* 新算法通过 prevWidthRef 反推中列上次实际宽度,三栏按各自占比分配空间变化。
*/
const recalcLayout = useCallback(() => {
if (draggingRef.current) {
needsRecalcRef.current = true;
return;
}
const observer = new ResizeObserver(() => { requestAnimationFrame(() => {
// 拖拽中由 mousemove handler 处理约束,避免冲突;
// 若跳过则标记 needsRecalcRef拖拽结束后补算
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]);
// ======== 分隔条样式工厂 ======== // ======== 分隔条样式工厂 ========

View File

@@ -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>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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%' }}
/>
);
}

View File

@@ -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
/>
);
}

View File

@@ -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) {
// 关闭开关 → 表单值置为 undefinedisValueEmpty = 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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
/>
);
}

View File

@@ -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
/>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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) {
// 关闭开关 → 表单值置为 undefinedisValueEmpty = 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>
);
}

View File

@@ -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';

View 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="音频"
/>
);
}

View File

@@ -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;
};
}

View 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
/>
);
}

View 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}
/>
);
}

View File

@@ -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';

View File

@@ -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,

View File

@@ -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: '暂无订单记录' }}
/> />

View File

@@ -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;

View File

@@ -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>

View File

@@ -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';

View File

@@ -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;

View File

@@ -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;

View File

@@ -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';

View File

@@ -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;

View File

@@ -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 = {

View File

@@ -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";
// ============================================================ // ============================================================
// 基础枚举 / 字面量类型 // 基础枚举 / 字面量类型

View File

@@ -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";
// ============================================================ // ============================================================

View File

@@ -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
View 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();

View File

@@ -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();
}
}

View File

@@ -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
View 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();
}
}

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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();

View File

@@ -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
View 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;
};
}

View File

@@ -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;
}

View File

@@ -21,7 +21,7 @@
// safeStore.clear('my-key'); // safeStore.clear('my-key');
// ============================================================ // ============================================================
import { hasIpc } from './ipc'; import { hasIpc } from './bus';
// ---------- 内部状态 ---------- // ---------- 内部状态 ----------

View File

@@ -1 +0,0 @@
export type Nullable<T> =T | null;

4
src/vite-env.d.ts vendored
View File

@@ -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;
};
} }
} }

View File

@@ -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 配置