feat: 初始化船长·HeiXiu 桌面应用项目(错误是svg的问题)

Electron + React 19 + TypeScript + Ant Design 6 + Tailwind CSS 4

  【核心架构】
  - Electron 主进程(窗口管理、平台检测、图标适配)
  - Vite 构建链(双产物 dist/ + dist-electron/)
  - 共享层 shared/(类型、常量、工具函数)
  - IPC 通信框架 + 安全守卫

  【主题系统】
  - 蓝紫渐变主题(亮色/暗色双模式)
  - Ant Design ConfigProvider 适配(lightAlgorithm / darkAlgorithm)
  - Tailwind CSS 4 @theme 指令 + CSS 变量
  - 设计令牌三处同步(tokens.ts / globals.css / antd-theme.ts)

  【导航栏】
  - 自定义 H5 导航栏替代系统菜单
  - 个人版/企业版双布局
  - 未登录拦截 + 事件总线驱动登录弹窗

  【登录注册】
  - Modal 弹层(Portal)+ Tabs 切换
  - 登录/注册表单动态字段渲染
  - 记住密码 / 自动登录 / 用户协议

  【更新系统】
  - 自定义 API 版本检查(替代 electron-updater generic provider)
  - 手动下载 + SHA512 校验 + spawn NSIS 安装
  - 渲染进程更新状态机 Hook(useUpdater)
  - 检查更新按钮 + 下载进度展示

  【构建工具链】
  - build.mjs 构建总管(--win/--mac/--linux --personal/--enterprise --build/--clean/--sign/--upload/--publish)
  - scripts/ 自动化脚本(clean / generate-update-info / upload-oss / publish-release)
  - electron-builder NSIS 安装器(可选路径、多版本打包)
  - update-info.json 自动生成(fileSize/sha512/changelog/releaseDate)

  【代码质量】
  - ESLint + TypeScript strict + Prettier
  - husky + lint-staged(提交前自动检查)
  - commitlint(规范提交信息)
  - Playwright E2E + Vitest 单元测试框架
  - rollup-plugin-visualizer 打包体积分析

  【文档】
  - README.md(快速开始 + 目录结构 + 命令速查)
  - UpdateA.md(发布手册:API/签名/发版流程/数据库)
  - CHANGELOG.md(更新日志)
  - .env.example(构建环境变量模板)
This commit is contained in:
2026-06-03 15:00:51 +08:00
commit 767bb8b297
95 changed files with 20814 additions and 0 deletions

16
.env.development Normal file
View File

@@ -0,0 +1,16 @@
# ============================================================
# 开发环境 — 连接测试服务器
# 环境变量模板 — 复制为 .env.development / .env.production 后填入实际值
# Vite 仅暴露 VITE_ 前缀的变量给客户端代码import.meta.env.VITE_xxx
# ============================================================
# API 基础地址(测试服务器)
VITE_API_BASE_URL=http://8.160.179.64:8000
# 版本类型personal个人版| enterprise企业版
VITE_EDITION=personal
# VITE_EDITION=enterprise
# 应用标题后缀(可选,覆盖默认构建标题)
VITE_APP_TITLE=船长·HeiXiu

49
.env.example Normal file
View File

@@ -0,0 +1,49 @@
# ============================================================
# HeiXiu 构建时环境变量
#
# 用法:复制此文件为 .env注意.env 已加入 .gitignore不会提交
# cp .env.example .env
#
# 优先级:终端 export > CI/CD Secret > .env 文件 > 默认值
# ============================================================
# -------- 更新 API --------
# 版本检查 API 基地址
UPDATE_API_URL=https://www.heixiu.com
# 发布 API 鉴权 Keypublish-release.mjs 使用)
# UPDATE_API_KEY=your-api-key-here
# -------- OSS 上传 --------
# 阿里云 OSS endpoint
# OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
# 阿里云 OSS bucket 名称
# OSS_BUCKET=heixiu
# 阿里云 AccessKey
# OSS_ACCESS_KEY_ID=your-access-key-id
# OSS_ACCESS_KEY_SECRET=your-access-key-secret
# OSS 内基路径(默认 releases
# OSS_BASE_PATH=releases
# 预判 URL 模式时的下载基地址generate-update-info.mjs 使用)
# UPDATE_DOWNLOAD_BASE=https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases
# -------- 代码签名 --------
# 证书文件路径(.pfx / .p12
# CSC_LINK=/path/to/certificate.pfx
# 证书密码
# CSC_KEY_PASSWORD=your-cert-password
# -------- 更新元数据 --------
# 强制更新标记
# UPDATE_FORCE=false
# 最低兼容版本(低于此版本必须强制升级)
UPDATE_MIN_VERSION=0.1.1
# 覆盖 CHANGELOG.md 中的日志CI/CD 场景)
# UPDATE_CHANGELOG=## 0.1.0\n- 新增 xxx\n- 修复 xxx

9
.env.production Normal file
View File

@@ -0,0 +1,9 @@
# ============================================================
# 生产环境
# ============================================================
# 生产服务器 API 地址
VITE_API_BASE_URL=https://www.heixiu.net
# 生产版本(构建时可通过命令行 EDITION=enterprise 覆盖)
VITE_EDITION=personal

19
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,19 @@
module.exports = {
root: true,
env: {browser: true, es2020: true},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{allowConstantExport: true},
],
"semi": ["error", "always"]
},
}

32
.gitattributes vendored Normal file
View File

@@ -0,0 +1,32 @@
# 自动检测文本文件,并规范化换行符为 LF提交到仓库时统一为 LF
* text=auto
# 明确指定以下文件使用 LF适用于大多数代码文本文件
*.js text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.json text eol=lf
*.css text eol=lf
*.scss text eol=lf
*.html text eol=lf
*.md text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.sh text eol=lf # Shell 脚本必须为 LF
*.py text eol=lf
# Windows 批处理文件必须使用 CRLF
*.bat text eol=crlf
*.cmd text eol=crlf
# 二进制文件(防止 Git 错误地修改它们)
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.zip binary
*.tar.gz binary
*.exe binary
*.dmg binary
*.AppImage binary

35
.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
# vite打包输出
dist
# electron打包输出
dist-electron
# electron-builder输出
release
e2e
test-results
dist-ssr
*.local
# 环境变量
.env # 构建时环境变量(从 .env.example 复制,含 OSS Key 等)
.env.production # Vite 生产环境变量(含 API 地址)
# Editor directories and files
.vscode
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
# electron_mirror=https://npmmirror.com/mirrors/electron/

19
.prettierrc Normal file
View File

@@ -0,0 +1,19 @@
{
"printWidth": 100,
"tabWidth": 3,
"useTabs": false,
"semi": true,
"singleQuote": true,
"quoteProps": "as-needed",
"jsxSingleQuote": false,
"trailingComma": "all",
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "always",
"endOfLine": "lf",
"embeddedLanguageFormatting": "auto",
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"proseWrap": "preserve",
"vueIndentScriptAndStyle": false
}

20
CHANGELOG.md Normal file
View File

@@ -0,0 +1,20 @@
# 更新日志
> 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。
---
## 0.0.12026-06-03
- 初始版本
- 蓝紫渐变主题(亮色 + 暗色)
- 自定义 H5 导航栏
- 登录 / 注册 Modal
- 公告系统
- 自动更新检查
---
## 格式说明
每个版本以 `## x.y.z日期` 开头,内容为无序列表。打包脚本会提取最新版本的日志填入 update-info.json。

3
CLAUDE.md Normal file
View File

@@ -0,0 +1,3 @@
# 回答语言
- 所有交互、解释、代码注释与生成内容均使用简体中文,专有技术名词可保留英文,但需附带中文说明

180
PROJECT.md Normal file
View File

@@ -0,0 +1,180 @@
# 项目概览
- 本项目使用 Vite + React 19 + TypeScript + Electron 重构现有 Python Qt
桌面应用打造跨平台Windows/macOS的多窗口桌面程序。
- 核心界面为单页应用SPA同时支持通过 Electron 打开独立窗口以承载子功能。
# 核心需求
- 完整还原原有 Qt 应用的业务逻辑与视觉体验,并对交互进行现代化改造。
- 必须支持 多窗口(主窗口 + 可动态创建的辅助窗口),窗口间需通过事件/消息进行低耦合通信。
- 适配 Windows 与 macOS处理好快捷键、托盘、菜单、窗口边框等平台差异。
- 采用 离线优先 架构,保障本地数据处理性能,合理使用本地存储(如 electron-store 或 SQLite
- 所有对系统底层能力的调用必须通过 Electron 主进程,严禁在渲染进程直接使用 Node.js API除非通过 preload 安全暴露)。
# 技术架构
## 整体分层
- 主进程Main ProcessElectron 主进程管理窗口生命周期、系统菜单、托盘、IPC 事件调度、本地文件/数据库操作。
- 预加载脚本Preload桥接渲染进程与主进程使用 contextBridge 安全暴露有限 API。
- 渲染进程Renderer基于 React 19 的 SPA主窗口与独立窗口的 React 入口,负责 UI 展示与用户交互。
- 共享层Shared进程间共享的 TypeScript 类型、常量、工具函数、事件名称枚举。
## 前端架构原则
- 组件化:以函数组件 + Hooks 为唯一组件形式UI 拆分为展示组件与容器组件(若无特殊需求,可使用 Hooks 直接连接 Redux
- 单向数据流:全局状态使用 Redux Toolkit严格遵循 Action → Reducer → Store → View 单向流动。组件内部状态使用
useState/useReducer不污染全局 Store。
- 事件驱动EDA
1. 主进程与渲染进程通信基于 Electron IPCipcMain/ipcRenderer定义明确的事件通道与载荷类型。
2. 渲染进程内跨组件通信优先使用 Redux需要解耦的场景可借助自定义事件总线如 mitt但严禁滥用。
3. 窗口间通信统一通过主进程转发(或使用 MessagePort 等 Electron 支持的机制),保持窗口独立性。
- 路由与导航:主窗口为 单页应用,使用 React Router v7+ 管理页面切换,支持嵌套路由。
- 辅助窗口(设置、预览、导入导出等)为独立 BrowserWindow加载独立的 HTML 入口(可根据路由参数或 windowId 决定内部路由),不与主窗口共享
DOM。
# 目录结构
```md
├── electron/ # Electron 主进程与预加载脚本
│ ├── main/ # 主进程代码
│ │ ├── index.ts # 入口,初始化应用
│ │ ├── windowManager.ts # 多窗口管理器(创建、销毁、聚焦)
│ │ ├── ipc/ # IPC 事件注册与处理
│ │ ├── menu/ # 系统菜单模板(按平台拆分)
│ │ └── utils/ # 平台判断、路径处理等
│ └── preload/ # 预加载脚本
│ ├── index.ts # 主窗口预加载
│ └── subWindow.ts # 辅助窗口预加载
├── src/ # 渲染进程React 应用)
│ ├── main/ # 主窗口 SPA 入口
│ │ ├── index.html # HTML 入口
│ │ ├── main.tsx # React 挂载点
│ │ └── App.tsx # 根组件(路由、主题)
│ ├── sub/ # 辅助窗口独立入口(一个窗口对应一个子目录)
│ │ ├── settings/ # 设置窗口示例
│ │ │ ├── index.html
│ │ │ └── main.tsx
│ │ └── preview/ # 预览窗口示例
│ ├── components/ # 通用 UI 组件
│ ├── containers/ # 业务容器组件(连接 Redux
│ ├── store/ # Redux Toolkit 配置
│ │ ├── index.ts # Store 创建
│ │ ├── slices/ # 按功能拆分 Slice
│ │ └── hooks.ts # 类型化 useAppSelector/useAppDispatch
│ ├── hooks/ # 自定义 Hooks
│ ├── theme/ # 主题适配器与定制
│ │ ├── adapters/ # Ant Design 主题配置、Tailwind 扩展
│ │ ├── tokens.ts # 设计令牌(颜色、圆角、阴影等)
│ │ └── globals.css # Tailwind 指令 + 全局样式
│ ├── router/ # 路由配置(主窗口)
│ ├── utils/ # 纯函数工具
│ │ └── platform.ts # 平台判定工具process.platform 封装)
│ └── types/ # 渲染进程专用类型
├── shared/ # 主进程 & 渲染进程共享
│ ├── types/ # IPC 通道、载荷类型、通用接口
│ ├── constants/ # 事件名称、配置常量
│ └── utils/ # 通用逻辑(如版本比较)
├── package.json
├── vite.config.ts # Vite 配置(多入口)
├── tailwind.config.ts # Tailwind 主题扩展
├── tsconfig.json
└── electron-builder.yml # 打包配置(跨平台适配)
```
# 多窗口与平台判定
- windowManager.ts 负责统一创建 BrowserWindow根据环境变量/参数加载不同入口(如 src/main/index.html 或
src/sub/settings/index.html
- src/utils/platform.ts 提供 isMacOS、isWindows 等函数(基于 navigator.userAgent 或 preload 暴露的 process.platform。在主进程的
electron/main/utils/platform.ts 中直接使用 Node.js API。
- 打包配置 (electron-builder.yml) 须针对平台设置不同的图标、签名策略、安装包格式。
# 代码风格
## TypeScript
- 所有语句必须以分号 ; 结尾,缺失分号将被视为错误。
- 启用 strict 模式,禁止隐式 any优先使用 interface 定义对象类型,复杂类型可适度使用 type。
- 文件名使用小写短横线连接kebab-case如 use-window-state.ts组件文件名与组件名保持一致PascalCase
ThemeAdapter.tsx。
- 导入顺序:第三方库 → 项目内共享模块 → 相对路径模块,中间用空行分隔。
## React
- 只允许函数组件与 Hooks禁止使用类组件。
- 每个组件必须有明确的 Props 类型,使用 React.FC<Props> 或直接标注函数参数类型(推荐直接标注)。
- 事件处理函数命名以 handle 开头(如 handleClick自定义 Hook 以 use 开头。
- 避免直接操作 DOM除非封装在自定义 Hook 中(如焦点管理)。
- 使用 React.memo、useMemo、useCallback 优化性能,但不要过早优化。
## Redux
- 使用 Redux Toolkit 的 createSlice 管理状态,异步逻辑使用 createAsyncThunk。
- Slice 按业务领域划分,每个 Slice 文件导出 actions 与 reducer。
- 组件中通过自定义 Hooks (useAppSelector, useAppDispatch) 访问 Store避免直接使用 useDispatch/useSelector。
# 样式与主题
- 优先使用 Tailwind 工具类 完成布局与常规样式,自定义设计令牌应映射为 Tailwind 扩展(在 tailwind.config.ts 中配置
theme.extend
- Ant Design 组件样式通过 ConfigProvider 的 theme 属性覆盖,确保与 Tailwind 主题令牌同步。在 src/theme/adapters/ 中创建
antd-theme.ts导出主题配置对象引用 tokens.ts 中的设计变量。
- 禁止在组件中硬编码颜色、字号等设计令牌,一律使用主题变量或 Tailwind 类名。
- 全局样式文件 globals.css 只引入 Tailwind 指令和必要的重置样式,不堆积组件样式。
# 事件与 IPC
- 所有 IPC 通道名称定义在 shared/constants/ipc-channels.ts 中,避免魔法字符串。
- 主进程事件处理函数需做好错误捕获与日志记录,渲染进程调用 IPC 时使用 try-catch 并处理超时。
- 窗口间通信走主进程转发:发送窗口 ipcRenderer.send主进程接收后定位目标窗口并 webContents.send。
# 开发与构建命令
- 开发模式npm run dev 或 yarn dev同时启动 Vite 与 Electron支持热重载
- 构建npm run build 编译渲染进程 → 打包 Electron 应用。
- 代码检查npm run lintESLint + Prettier规则已配置为强制分号、单引号等
- 类型检查npm run typechecktsc --noEmit
# 与其他 AI 协作的补充说明
- 所有生成的代码片段必须放在完整的文件上下文中,并注明所属文件路径。
- 若涉及跨平台逻辑,必须同时给出 Windows 与 macOS 的兼容实现或判断分支。
- 新增窗口时,需同步说明窗口入口、预加载脚本、主进程创建逻辑及 IPC 通道注册。
- 修改主题令牌时,需同步更新 tokens.ts、tailwind.config.ts 及 antd-theme.ts确保三处一致。

162
README.md Normal file
View File

@@ -0,0 +1,162 @@
# 船长·HeiXiu
> 桌面效率工作台 — Electron + React 19 + TypeScript + Ant Design + Tailwind CSS
---
## 技术栈
| 层 | 技术 |
| ---- | ------------------------------ |
| 框架 | Electron 42 |
| 前端 | React 19 + TypeScript + Vite 8 |
| UI | Ant Design 6 + Tailwind CSS 4 |
| 主题 | 蓝紫渐变(亮色 / 暗色) |
| 更新 | 自定义 API + 阿里云 OSS |
| 打包 | electron-builder + NSIS |
---
## 快速开始
```bash
# 安装依赖
npm install
# 开发模式Electron + HMR
npm run dev
# 打包Windows 个人版)
npm run build:win
```
---
## 构建命令
### 打包
```bash
npm run build:win # Windows 个人版
npm run build:win:enterprise # Windows 企业版
npm run build:mac # macOS 个人版
npm run build:linux # Linux 个人版
```
### 构建总管 `build.mjs`
所有打包命令最终调用 `build.mjs`,支持灵活组合操作:
```bash
# 查看帮助
node build.mjs --help
# 仅打包
node build.mjs --win --personal
# 打包 + 上传 OSS + 发布到 API
node build.mjs --win --personal --upload --publish
# 全流程(清理 → 构建 → 签名 → 上传 → 发布)
node build.mjs --win --enterprise --all
# 带签名打包
node build.mjs --win --personal --sign --cert ./cert.pfx --cert-password xxx
# 先清理再构建
node build.mjs --win --personal --clean --build
```
| 操作 | 说明 |
| ----------- | -------------------------------------------------------------- |
| `--build` | 构建默认tsc + vite + electron-builder + update-info.json |
| `--clean` | 构建前清理 release/ dist/ dist-electron/ |
| `--sign` | 代码签名(需配合 `--cert``CSC_LINK` 环境变量) |
| `--upload` | 上传安装包到 OSS |
| `--publish` | POST 版本信息到后端 API |
| `--all` | 全流程 |
### 工具命令
```bash
npm run build:clean # 清理所有构建产物
node scripts/upload-oss.mjs win32 personal 0.1.0 # 手动上传 OSS
node scripts/publish-release.mjs win32 personal 0.1.0 # 手动发布到 API
node scripts/generate-update-info.mjs win32 personal 0.1.0 # 重新生成 info
```
---
## 目录结构
```
ele-heixiu/
├ build.mjs # 构建总管
├ CHANGELOG.md # 更新日志
├ UpdateA.md # 发布手册(后端 API / 签名 / 发版流程)
├ package.json # 项目配置
├ electron-builder.json5 # 打包配置
├ electron/ # Electron 主进程
│ ├ main.ts # 入口
│ ├ preload.ts # 预加载脚本
│ └ main/
│ ├ updater.ts # 自动更新模块
│ ├ menu/ # 菜单
│ └ utils/ # 工具函数
├ src/ # 渲染进程
│ ├ App.tsx # 根组件
│ ├ main.tsx # 渲染入口
│ ├ components/ # 组件
│ │ ├ navbar/ # 自定义导航栏
│ │ └ LegalTextModal.tsx # 协议弹窗
│ ├ pages/
│ │ └ login/ # 登录 / 注册
│ ├ hooks/ # Hooks
│ │ ├ use-theme.tsx # 主题切换
│ │ ├ use-updater.ts # 更新状态
│ │ └ ...
│ ├ theme/ # 主题配置
│ │ ├ tokens.ts # 设计令牌
│ │ ├ globals.css # Tailwind + CSS 变量
│ │ └ adapters/ # Ant Design 适配
│ ├ services/ # API 服务axios
│ ├ utils/ # 工具函数
│ └ assets/ # 静态资源
├ shared/ # 主进程 & 渲染进程共享
│ ├ types/ # 类型定义
│ ├ constants/ # 常量
│ └ utils/ # 通用工具
├ scripts/ # 构建辅助脚本
│ ├ clean.mjs # 清理产物
│ ├ generate-update-info.mjs # 生成 API 信息 JSON
│ ├ upload-oss.mjs # OSS 上传
│ └ publish-release.mjs # 发布到 API
└ release/ # 构建产物
└ {version}/
├ xxx-Setup.exe
└ xxx-update-info.json
```
---
## 环境变量
| 变量 | 默认值 | 用途 |
| ----------------------- | ------------------------------------------------------ | --------------- |
| `UPDATE_API_URL` | `https://www.heixiu.com` | 版本检查 API |
| `UPDATE_API_KEY` | — | 发布 API 鉴权 |
| `UPDATE_DOWNLOAD_BASE` | `https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases` | OSS 下载基地址 |
| `OSS_ENDPOINT` | — | OSS endpoint |
| `OSS_BUCKET` | — | OSS bucket |
| `OSS_ACCESS_KEY_ID` | — | OSS AccessKey |
| `OSS_ACCESS_KEY_SECRET` | — | OSS Secret |
| `CSC_LINK` | — | 签名证书路径 |
| `CSC_KEY_PASSWORD` | — | 签名证书密码 |
| `VITE_API_BASE_URL` | `http://8.160.179.64:8000`dev | 业务 API 基地址 |
---
## 相关文档
- [发布手册](./UpdateA.md) — API 接口、发版流程、签名指南、数据库设计
- [项目架构](./PROJECT.md) — 详细的技术方案与设计决策

284
UpdateA.md Normal file
View File

@@ -0,0 +1,284 @@
# HeiXiu 构建 & 发布手册
> 面向前端发版 + 后端 API 对接
---
## 一、整体架构
```
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ 客户端 (Electron)│ │ ECS (API 服务) │ │ OSS (阿里云) │
│ │ GET │ │ │ │
│ 版本检查 ─────────┼────────▶│ /api/v1/update/ │ │ │
│ │ │ check │ │ │
│ │◀────────│ 返回 JSON │ │ │
│ │ │ │ │ │
│ 下载安装包 ───────┼─────────┼──────────────────┼────────▶│ 静态文件托管 │
│ │◀────────┼──────────────────┼─────────│ .exe / .dmg │
│ │ │ │ │ │
│ 安装 (spawn) │ │ │ │ │
│ → app.quit() │ │ │ │ │
└──────────────────┘ └──────────────────┘ └──────────────────┘
```
| 角色 | 技术 | 职责 |
| ------ | -------------------------- | ------------------------------------------ |
| 客户端 | Electron + `net.request()` | 调用 API → 下载安装包 → 校验 SHA512 → 安装 |
| ECS | 任意后端语言 | 提供版本检查 API |
| OSS | 阿里云对象存储 | 托管 .exe / .dmg / .AppImage |
---
## 二、项目脚本速查
### 总入口:`build.mjs`
```bash
node build.mjs --help # 查看完整帮助
npm run build:win # 打包 Windows 个人版(快捷命令)
```
### npm 命令速查
```bash
# 构建(仅打包,不上传/发布)
npm run build:win # Windows 个人版
npm run build:win:enterprise # Windows 企业版
npm run build:mac # macOS 个人版
npm run build:linux # Linux 个人版
# 工具
npm run build:clean # 清理 release/ + dist/ + dist-electron/
node build.mjs --win --personal --help # 查看完整 CLI 指南
# 独立脚本(需传参数)
node scripts/generate-update-info.mjs <平台> <版本类型> [版本号] [--url <url>]
node scripts/upload-oss.mjs <平台> <版本类型> [版本号]
node scripts/publish-release.mjs <平台> <版本类型> [版本号] [--url <url>]
```
### `build.mjs` 操作组合
| 操作 | 说明 |
| ----------- | -------------------------------------------------------- |
| `--build` | 构建tsc + vite + electron-builder + update-info.json |
| `--clean` | 清理旧产物 |
| `--sign` | 代码签名(配合 `--cert` 或环境变量) |
| `--upload` | 上传 OSS |
| `--publish` | POST 版本信息到后端 API |
| `--all` | 全流程 = clean + build + sign + upload + publish |
---
## 三、发版工作流
### 路径 A快速打包日常开发 / 内部测试)
```bash
# 1. 修改版本号
# package.json → "version": "0.1.0"
# 2. 写更新日志
# CHANGELOG.md → 顶部新增 ## 0.1.02026-xx-xx...
# 3. 打包
npm run build:win && npm run build:win:enterprise
# 4. 产物在 release/0.1.0/ 下:
# - xxx-Setup.exe安装包
# - xxx-update-info.json所有 API 字段downloadUrl 为预判 URL
# 5. 手动上传 exe 到 OSS releases/0.1.0/
# 6. 将 update-info.json 内容录入数据库 releases 表
```
### 路径 B先上传再确定 URL
```bash
# 打包
npm run build:win
# 上传到 OSS自动打印实际 URL
node scripts/upload-oss.mjs win32 personal 0.1.0
# 用实际 URL 重新生成 update-info.json
node scripts/generate-update-info.mjs win32 personal 0.1.0 \
--url "https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases/0.1.0/xxx.exe"
# 将 update-info.json 录入数据库
```
### 路径 C全自动上传 + 发布一条龙)
```bash
# 一步到位(需先配置环境变量)
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com \
OSS_BUCKET=heixiu \
OSS_ACCESS_KEY_ID=xxx \
OSS_ACCESS_KEY_SECRET=xxx \
UPDATE_API_KEY=xxx \
node build.mjs --win --personal --upload --publish
```
---
## 四、代码签名
### 证书获取
| 平台 | 格式 | 渠道 | 年费 |
| ------- | ----------------- | ------------------------------- | -------- |
| Windows | `.pfx` / `.p12` | DigiCert / Sectigo / GlobalSign | $200-700 |
| macOS | `.p12` / Keychain | Apple Developer Program | $99 |
> EV 证书立即消除 SmartScreen 警告OV 证书需积累下载量。
### 签名方式
```bash
# 方式 1build.mjs 参数
node build.mjs --win --personal --sign --cert ./cert.pfx --cert-password xxx
# 方式 2环境变量CI/CD 友好)
CSC_LINK=/path/to/cert.pfx CSC_KEY_PASSWORD=xxx npm run build:win
```
> SHA512 校验值会在签名后自动计算(签名改变文件字节,必须先签后校)。
---
## 五、API 接口
### `GET /api/v1/update/check`
**参数**`platform`win32/darwin/linux`version`(三段式 semver`edition`personal/enterprise
**有更新**
```json
{
"code": 0,
"data": {
"hasUpdate": true,
"latestVersion": "0.1.0",
"downloadUrl": "https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases/0.1.0/xxx.exe",
"fileSize": 98765432,
"sha512": "Base64...",
"changelog": "更新日志...",
"releaseDate": "2026-06-03",
"forceUpdate": false,
"minimumVersion": "0.0.0"
}
}
```
**无更新**
```json
{ "code": 0, "data": { "hasUpdate": false } }
```
### `POST /api/v1/releases`(发布用)
```json
{
"platform": "win32",
"edition": "personal",
"latestVersion": "0.1.0",
"downloadUrl": "https://...",
"fileSize": 112910987,
"sha512": "Base64...",
"changelog": "...",
"releaseDate": "2026-06-03",
"forceUpdate": false,
"minimumVersion": "0.0.0"
}
```
> Header: `Authorization: Bearer <UPDATE_API_KEY>`
---
## 六、数据库
```sql
CREATE TABLE releases (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
platform VARCHAR(10) NOT NULL COMMENT 'win32 / darwin / linux',
edition VARCHAR(10) NOT NULL COMMENT 'personal / enterprise',
version VARCHAR(20) NOT NULL COMMENT '三段式 semver',
file_url VARCHAR(500) NOT NULL COMMENT 'OSS HTTPS 地址',
file_size BIGINT NOT NULL COMMENT '字节数',
sha512 VARCHAR(128) NOT NULL COMMENT 'Base64 编码',
changelog TEXT,
force_update TINYINT(1) DEFAULT 0,
min_version VARCHAR(20) DEFAULT '',
release_date DATE NOT NULL,
status VARCHAR(10) DEFAULT 'draft' COMMENT 'draft / published / deprecated',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_platform_edition_version (platform, edition, version),
INDEX idx_platform_edition_status (platform, edition, status)
);
```
---
## 七、客户端行为
| 场景 | 行为 |
| -------- | ---------------------------------------------------- |
| 启动 | 5 秒后静默检查更新(仅生产环境) |
| 手动检查 | 点击按钮 → 调用 API → 反馈结果 |
| 下载 | 流式写入临时目录SHA512 实时校验,每 250ms 推送进度 |
| 安装 | 用户确认 → spawn NSIS /S → app.quit() |
| 强制更新 | `forceUpdate=true` 时不可跳过 |
| 开发模式 | 跳过所有更新检查 |
---
## 八、环境变量速查
| 变量 | 默认值 | 用途 |
| ----------------------- | ------------------------------------------------------ | ----------------------- |
| `UPDATE_API_URL` | `https://www.heixiu.com` | 版本检查 API 基地址 |
| `UPDATE_API_KEY` | — | 发布 API 鉴权 Key |
| `UPDATE_DOWNLOAD_BASE` | `https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases` | 预判 URL 基地址 |
| `OSS_ENDPOINT` | — | 阿里云 OSS endpoint |
| `OSS_BUCKET` | — | 阿里云 OSS bucket |
| `OSS_ACCESS_KEY_ID` | — | 阿里云 AccessKey ID |
| `OSS_ACCESS_KEY_SECRET` | — | 阿里云 AccessKey Secret |
| `OSS_BASE_PATH` | `releases` | OSS 内基路径 |
| `CSC_LINK` | — | 签名证书路径 |
| `CSC_KEY_PASSWORD` | — | 签名证书密码 |
| `UPDATE_FORCE` | `false` | 强制更新标记(打包时) |
| `UPDATE_CHANGELOG` | 读取 CHANGELOG.md | 覆盖更新日志CI/CD |
---
## 九、文件结构
```
ele-heixiu/
├ build.mjs ← 构建总管npm run build 入口)
├ CHANGELOG.md ← 更新日志(打包时自动提取)
├ UpdateA.md ← 本文档
├ package.json ← 仅保留简短的 npm 快捷命令
├ scripts/
│ ├ clean.mjs ← 清理产物
│ ├ generate-update-info.mjs ← 生成 API 信息 JSON
│ ├ upload-oss.mjs ← 上传 OSS
│ └ publish-release.mjs ← 发布到 APIaxios
├ release/ ← 构建产物目录(按版本分)
│ └ 0.1.0/
│ ├ xxx-Setup.exe
│ └ xxx-update-info.json
├ dist/ ← Vite 前端产物(构建输入)
└ dist-electron/ ← Electron 主进程产物(构建输入)
```
---
_如有接口或流程变更,请同步更新此文档。_

324
build.mjs Normal file
View File

@@ -0,0 +1,324 @@
// ============================================================
// build.mjs — 构建总管
//
// 用法node build.mjs [选项]
//
// 平台(必选其一):
// --win Windows
// --mac macOS
// --linux Linux
//
// 版本(可选,默认 personal
// --personal 个人版
// --enterprise 企业版
//
// 操作(可选组合,默认只执行 --build
// --build 仅构建(默认)
// --clean 构建前清理 release/ dist/ dist-electron/
// --sign 代码签名
// --upload 上传安装包到 OSS
// --publish 发布版本信息到后端 API
// --all 全流程clean + build + sign + upload + publish
//
// 签名(仅 --sign 时可用):
// --cert <path> 证书文件路径 (.pfx)
// --cert-password <p> 证书密码
//
// 示例:
// node build.mjs --win --personal # 仅打包
// node build.mjs --win --personal --upload --publish # 打包 + 上传 + 发布
// node build.mjs --win --enterprise --all # 全流程
// node build.mjs --win --personal --sign --cert ./cert.pfx --cert-password xxx
// ============================================================
import { spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = __dirname;
// ANSI 颜色工具(在其它代码之前定义,因为 printHelp() 在参数解析时就会调用)
const c = (code, text) => `\x1b[${code}m${text}\x1b[0m`;
const dim = (t) => c(2, t);
const bold = (t) => c(1, t);
const cyan = (t) => c(36, t);
const green = (t) => c(32, t);
const yellow = (t) => c(33, t);
// ---------- 参数解析 ----------
const args = process.argv.slice(2);
const opts = {
win: false,
mac: false,
linux: false,
personal: false,
enterprise: false,
clean: false,
build: false,
sign: false,
upload: false,
publish: false,
cert: '',
certPassword: '',
};
let i = 0;
while (i < args.length) {
const a = args[i];
switch (a) {
case '--win': opts.win = true; break;
case '--mac': opts.mac = true; break;
case '--linux': opts.linux = true; break;
case '--personal': opts.personal = true; break;
case '--enterprise': opts.enterprise = true; break;
case '--clean': opts.clean = true; break;
case '--build': opts.build = true; break;
case '--sign': opts.sign = true; break;
case '--upload': opts.upload = true; break;
case '--publish': opts.publish = true; break;
case '--all':
opts.clean = opts.build = opts.sign = opts.upload = opts.publish = true;
break;
case '--cert':
opts.cert = args[++i] || '';
break;
case '--cert-password':
opts.certPassword = args[++i] || '';
break;
case '--help': // fallthrough
case '-h':
printHelp();
process.exit(0);
break; // eslint 友好
default:
// 忽略未知参数
break;
}
i++;
}
// ---------- 校验 ----------
const platformCount = [opts.win, opts.mac, opts.linux].filter(Boolean).length;
if (platformCount !== 1) {
console.error('错误: 请指定且仅指定一个平台(--win / --mac / --linux');
process.exit(1);
}
const editionCount = [opts.personal, opts.enterprise].filter(Boolean).length;
if (editionCount === 0) {
opts.personal = true; // 默认个人版
} else if (editionCount > 1) {
console.error('错误: --personal 和 --enterprise 不能同时使用');
process.exit(1);
}
// 默认执行 build
if (!opts.clean && !opts.build && !opts.sign && !opts.upload && !opts.publish) {
opts.build = true;
}
const platform = opts.win ? 'win32' : opts.mac ? 'darwin' : 'linux';
const edition = opts.personal ? 'personal' : 'enterprise';
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
const version = pkg.version;
// ---------- 工具函数 ----------
function run(cmd, args, opts = {}) {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, {
cwd: ROOT,
stdio: 'inherit',
shell: true,
...opts,
env: { ...process.env, ...(opts.env || {}) },
});
child.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`${cmd} ${args.join(' ')} 退出码: ${code}`));
});
child.on('error', reject);
});
}
function runScript(name, ...args) {
const scriptPath = join(ROOT, 'scripts', `${name}.mjs`);
return run('node', [scriptPath, ...args]);
}
function step(title) {
console.log(`\n${'='.repeat(60)}`);
console.log(` ${title}`);
console.log(`${'='.repeat(60)}\n`);
}
// ---------- 主流程 ----------
async function main() {
const electronBuilderPlatform = opts.win ? '--win' : opts.mac ? '--mac' : '--linux';
const editionEnv = { EDITION: edition };
console.log('┌──────────────────────────────────────┐');
console.log(`│ HeiXiu 构建 v${version} ${platform}/${edition}`.padEnd(45) + '│');
console.log('└──────────────────────────────────────┘');
console.log(`操作: ${[
opts.clean && '清理',
opts.build && '构建',
opts.sign && '签名',
opts.upload && '上传',
opts.publish && '发布',
].filter(Boolean).join(' → ')}`);
// ── Step 1: 清理 ──
if (opts.clean) {
step('1/ 清理构建产物');
await runScript('clean');
}
// ── Step 2: 构建 ──
if (opts.build) {
step('2/ 构建应用');
// 签名环境变量electron-builder 原生支持 CSC_LINK / CSC_KEY_PASSWORD
const signEnv = {};
if (opts.sign) {
if (opts.cert) signEnv.CSC_LINK = opts.cert;
if (opts.certPassword) signEnv.CSC_KEY_PASSWORD = opts.certPassword;
console.log(opts.cert ? `签名证书: ${opts.cert}` : '签名: 使用系统证书存储');
}
// 2a. TypeScript 编译
console.log('[tsc] 类型检查...');
await run('npx', ['tsc'], { env: editionEnv });
// 2b. Vite 构建
console.log('[vite] 打包前端 + Electron...');
await run('npx', ['vite', 'build'], { env: editionEnv });
// 2c. electron-builder 打包
console.log(`[electron-builder] ${electronBuilderPlatform} 打包...`);
await run('npx', ['electron-builder', electronBuilderPlatform], {
env: { ...editionEnv, ...signEnv },
});
// 2d. 生成 update-info.json
console.log('[info] 生成 update-info.json...');
await runScript('generate-update-info', platform, edition, version);
}
// ── Step 3: 上传 OSS ──
if (opts.upload) {
step('3/ 上传 OSS');
await runScript('upload-oss', platform, edition, version);
}
// ── Step 4: 发布到 API ──
if (opts.publish) {
step('4/ 发布版本到 API');
await runScript('publish-release', platform, edition, version);
}
console.log('\n✅ 全部完成');
}
function printHelp() {
const W = 62;
const box = (title) => {
const pad = Math.max(0, W - 4 - title.length);
console.log(`\n${cyan('┌')}${cyan('─'.repeat(W - 2))}${cyan('┐')}`);
console.log(`${cyan('│')} ${bold(title)}${' '.repeat(pad)}${cyan('│')}`);
console.log(`${cyan('└')}${cyan('─'.repeat(W - 2))}${cyan('┘')}`);
};
const row = (flag, desc) => {
console.log(` ${green(flag.padEnd(22))}${dim(desc)}`);
};
const hdr = (t) => console.log(`\n ${bold(yellow(t))}`);
console.log('');
console.log(cyan(' ╔') + cyan('═══════════════════════════════════════════════════') + cyan('╗'));
console.log(cyan(' ║') + bold(' 船长·HeiXiu — 构建工具') + ' ' + cyan('║'));
console.log(cyan(' ╚') + cyan('═══════════════════════════════════════════════════') + cyan('╝'));
// 用法
box('用法');
console.log(` ${bold('node build.mjs')} ${dim('<平台> [版本] [操作...]')}`);
console.log(` ${bold('npm run build:win')} ${dim('— 等效快捷命令')}`);
// 平台
hdr('平台(必选其一)');
row('--win', 'Windows');
row('--mac', 'macOS');
row('--linux', 'Linux');
// 版本
hdr('版本(可选,默认 --personal');
row('--personal', '个人版');
row('--enterprise', '企业版');
// 操作
hdr('操作(可选组合,不指定则默认 --build');
row('--build', '构建应用tsc + vite + electron-builder + info');
row('--clean', '构建前清理 release/ dist/ dist-electron/');
row('--sign', '代码签名(需配合 --cert 或环境变量)');
row('--upload', '上传安装包到 OSS');
row('--publish', '发布版本信息到后端 API');
row('--all', '全流程 = clean + build + sign + upload + publish');
// 签名
hdr('签名参数(配合 --sign');
row('--cert <path>', '证书文件路径(.pfx / .p12');
row('--cert-password <p>', '证书密码');
console.log(` ${dim('也可通过 CSC_LINK / CSC_KEY_PASSWORD 环境变量设置')}`);
// 示例
box('示例');
console.log(` ${dim('# 日常开发')}`);
console.log(` ${bold('node build.mjs --win --personal')}`);
console.log(` ${bold('npm run build:win')} ${dim('— 等效')}`);
console.log('');
console.log(` ${dim('# 打包 + 上传 OSS + 发布到 API')}`);
console.log(` ${bold('node build.mjs --win --personal --upload --publish')}`);
console.log('');
console.log(` ${dim('# 全流程(清理 → 构建 → 签名 → 上传 → 发布)')}`);
console.log(` ${bold('node build.mjs --win --enterprise --all --cert ./cert.pfx --cert-password xxx')}`);
console.log('');
console.log(` ${dim('# 仅清理')}`);
console.log(` ${bold('npm run build:clean')}`);
// 环境变量
box('环境变量');
console.log(` ${yellow('UPDATE_API_URL')} ${dim('版本检查 API 基地址')}`);
console.log(` ${yellow('UPDATE_API_KEY')} ${dim('发布 API 鉴权 Key')}`);
console.log(` ${yellow('UPDATE_DOWNLOAD_BASE')} ${dim('OSS 下载基地址(预判 URL 用)')}`);
console.log(` ${yellow('OSS_ENDPOINT')} ${dim('阿里云 OSS endpoint')}`);
console.log(` ${yellow('OSS_BUCKET')} ${dim('阿里云 OSS bucket 名称')}`);
console.log(` ${yellow('OSS_ACCESS_KEY_ID')} ${dim('阿里云 AccessKey ID')}`);
console.log(` ${yellow('OSS_ACCESS_KEY_SECRET')} ${dim('阿里云 AccessKey Secret')}`);
console.log(` ${yellow('CSC_LINK')} ${dim('签名证书路径(与 --cert 等效)')}`);
console.log(` ${yellow('CSC_KEY_PASSWORD')} ${dim('签名证书密码(与 --cert-password 等效)')}`);
// 快捷命令
box('npm 快捷命令');
console.log(` ${bold('npm run build:win')} ${dim('→ node build.mjs --win --personal --build')}`);
console.log(` ${bold('npm run build:win:enterprise')} ${dim('→ node build.mjs --win --enterprise --build')}`);
console.log(` ${bold('npm run build:mac')} ${dim('→ node build.mjs --mac --personal --build')}`);
console.log(` ${bold('npm run build:linux')} ${dim('→ node build.mjs --linux --personal --build')}`);
console.log(` ${bold('npm run build:clean')} ${dim('→ node scripts/clean.mjs')}`);
console.log(` ${bold('npm run upload:oss')} ${dim('→ node scripts/upload-oss.mjs')}`);
console.log(` ${bold('npm run publish:release')} ${dim('→ node scripts/publish-release.mjs')}`);
console.log('');
}
// ---------- 入口 ----------
main().catch((err) => {
console.error(`\n❌ 构建失败: ${err.message}`);
process.exit(1);
});

27
electron/electron-env.d.ts vendored Normal file
View File

@@ -0,0 +1,27 @@
/// <reference types="vite-plugin-electron/electron-env" />
declare namespace NodeJS {
interface ProcessEnv {
/**
* The built directory structure
*
* ```tree
* ├─┬─┬ dist
* │ │ └── index.html
* │ │
* │ ├─┬ dist-electron
* │ │ ├── main.js
* │ │ └── preload.js
* │
* ```
*/
APP_ROOT: string;
/** /dist/ or /public/ */
VITE_PUBLIC: string;
}
}
// Used in Renderer process, expose in `preload.ts`
interface Window {
ipcRenderer: import('electron').IpcRenderer;
}

126
electron/main.ts Normal file
View File

@@ -0,0 +1,126 @@
import { app, BrowserWindow } from 'electron';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { getPlatform, isMacOS } from './main/utils/platform';
import { getWindowIconPath } from './main/utils/logo';
import { buildWindowTitle, parseEdition } from '../shared/constants/app';
import { initUpdater, registerUpdateIpcHandlers } from './main/updater';
import { setupAppMenu } from './main/menu';
createRequire(import.meta.url);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
process.env.APP_ROOT = path.join(__dirname, '..');
export const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL'];
export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron');
export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist');
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
? path.join(process.env.APP_ROOT, 'public')
: RENDERER_DIST;
// ---------- 平台 & 版本信息 ----------
const currentPlatform = getPlatform();
const currentEdition = parseEdition(process.env.EDITION);
const WINDOW_TITLE = buildWindowTitle(currentPlatform, currentEdition);
// ============================================================
// 窗口引用
// ============================================================
let mainWindow: BrowserWindow | null = null;
// ============================================================
// 主窗口
// ============================================================
function createMainWindow(): BrowserWindow {
const iconPath = getWindowIconPath(app.getAppPath());
mainWindow = new BrowserWindow({
title: WINDOW_TITLE,
icon: iconPath,
width: 1280,
height: 800,
minWidth: 960,
minHeight: 600,
show: false,
...(isMacOS() ? { titleBarStyle: 'hiddenInset' as const } : {}),
webPreferences: {
preload: path.join(__dirname, 'preload.mjs'),
contextIsolation: true,
nodeIntegration: false,
},
});
mainWindow.setTitle(WINDOW_TITLE);
if (VITE_DEV_SERVER_URL) {
mainWindow.webContents.openDevTools();
}
// macOS 全屏事件
if (isMacOS()) {
mainWindow.on('enter-full-screen', () =>
mainWindow?.webContents.send('window-fullscreen-changed', true),
);
mainWindow.on('leave-full-screen', () =>
mainWindow?.webContents.send('window-fullscreen-changed', false),
);
}
mainWindow.webContents.on('did-finish-load', () => {
mainWindow?.webContents.send('main-process-message', new Date().toLocaleString());
mainWindow?.webContents.send('platform-info', {
platform: currentPlatform,
edition: currentEdition,
});
});
// ready-to-show 后显示,避免白屏闪烁
mainWindow.once('ready-to-show', () => {
mainWindow?.show();
});
if (VITE_DEV_SERVER_URL) {
mainWindow.loadURL(VITE_DEV_SERVER_URL);
} else {
mainWindow.loadFile(path.join(RENDERER_DIST, 'index.html'));
}
return mainWindow;
}
// ============================================================
// 应用生命周期
// ============================================================
app.on('window-all-closed', () => {
if (!isMacOS()) {
app.quit();
}
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createMainWindow();
}
});
app.on('before-quit', () => {
// 清理工作
});
app.whenReady().then(() => {
setupAppMenu();
app.setName(WINDOW_TITLE);
registerUpdateIpcHandlers();
// 始终打开主窗口
// 登录/注册由渲染进程内的 Modal 弹层处理,不再新开窗口
const win = createMainWindow();
initUpdater(win);
});

View File

@@ -0,0 +1,58 @@
// ============================================================
// 最小化系统菜单
//
// 策略:已全面采用 H5 自定义导航栏,系统菜单仅保留 macOS 必需的 Edit 菜单
// (否则 Cmd+C/V/A 等系统级快捷键会失效)。
//
// macOS → 仅 Edit 菜单undo/redo/cut/copy/paste/selectAll
// Windows → 完全移除系统菜单栏
// Linux → 完全移除系统菜单栏
// ============================================================
import { app, Menu, type MenuItemConstructorOptions } from 'electron';
import { isMacOS } from '../utils/platform';
import { APP_NAME } from '../../../shared/constants/app';
import { APP_VERSION } from '../../../shared/constants/version';
/**
* macOS 最小 Edit 菜单
* 必须保留,否则 Cmd+C / Cmd+V / Cmd+A / Cmd+Z 等快捷键全部失效
*/
function buildMinimalEditMenu(): MenuItemConstructorOptions {
return {
label: 'Edit',
submenu: [
{ label: '撤销', accelerator: 'Cmd+Z', role: 'undo' },
{ label: '重做', accelerator: 'Shift+Cmd+Z', role: 'redo' },
{ type: 'separator' },
{ label: '剪切', accelerator: 'Cmd+X', role: 'cut' },
{ label: '复制', accelerator: 'Cmd+C', role: 'copy' },
{ label: '粘贴', accelerator: 'Cmd+V', role: 'paste' },
{ label: '全选', accelerator: 'Cmd+A', role: 'selectAll' },
],
};
}
/**
* 设置应用菜单
* macOS → 最小 Edit 菜单
* Windows/Linux → 无菜单(导航栏由 H5 组件实现)
*/
export function setupAppMenu(): void {
if (isMacOS()) {
const template: MenuItemConstructorOptions[] = [buildMinimalEditMenu()];
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
} else {
// Windows / Linux完全移除菜单栏
Menu.setApplicationMenu(null);
}
// 关于面板信息macOS 原生 About / Windows 自定义)
app.setAboutPanelOptions({
applicationName: APP_NAME,
applicationVersion: APP_VERSION,
version: '',
credits: 'Powered by Electron + React 19 + TypeScript',
});
}

316
electron/main/updater.ts Normal file
View File

@@ -0,0 +1,316 @@
// ============================================================
// 自动更新模块 — 自定义 API 版本检查 + OSS 文件下载
//
// 架构:
// 客户端 → GET /api/v1/update/check → ECS (API) → 返回 JSON
// 客户端 → GET <downloadUrl> → OSS → 下载安装包
// 下载完成 → SHA512 校验 → spawn 安装器 → app.quit()
//
// 与旧版 (electron-updater generic provider) 的区别:
// - 版本检查走自定义 API后端可做灰度/强更/统计
// - 不再依赖 latest.yml 静态文件
// - 安装包仍托管于 OSS下载地址由 API 动态返回
//
// 环境变量:
// UPDATE_API_URL — 版本检查 API 基地址(默认 https://www.heixiu.com
// ============================================================
import { BrowserWindow, net, app, ipcMain } from 'electron';
import { createWriteStream } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { spawn } from 'node:child_process';
import { unlink } from 'node:fs/promises';
import type { UpdateCheckResult } from '../../shared/types';
import { APP_VERSION } from '../../shared/constants/version';
// ---------- IPC 通道(对渲染进程暴露,与旧版兼容)----------
export const UPDATE_CHANNELS = {
UPDATE_AVAILABLE: 'update-available',
UPDATE_PROGRESS: 'update-progress',
UPDATE_DOWNLOADED: 'update-downloaded',
UPDATE_ERROR: 'update-error',
UPDATE_NOT_AVAILABLE: 'update-not-available',
INSTALL_UPDATE: 'install-update',
CHECK_FOR_UPDATE: 'check-for-update',
} as const;
// ---------- 配置 ----------
function getApiBaseUrl(): string {
return process.env.UPDATE_API_URL || 'https://www.heixiu.com';
}
// ---------- 状态 ----------
let updateWindow: BrowserWindow | null = null;
let updateChecked = false;
/** 当前正在下载的临时文件路径(用于安装后清理) */
let pendingInstallerPath: string | null = null;
// ============================================================
// 初始化
// ============================================================
export function initUpdater(mainWindow: BrowserWindow): void {
updateWindow = mainWindow;
if (import.meta.env.DEV) {
console.log('[Updater] 开发模式跳过自动检查更新。API:', getApiBaseUrl());
return;
}
// 启动 5 秒后静默检查
setTimeout(() => {
checkForUpdates();
}, 5000);
}
// ============================================================
// 版本检查(调用 API
// ============================================================
async function fetchUpdateInfo(): Promise<UpdateCheckResult | null> {
const platform = process.platform; // 'win32' | 'darwin' | 'linux'
const edition = process.env.EDITION || 'personal';
const apiUrl = `${getApiBaseUrl()}/api/v1/update/check?platform=${platform}&version=${APP_VERSION}&edition=${edition}`;
console.log('[Updater] 请求版本检查 API:', apiUrl);
return new Promise((resolve, reject) => {
const req = net.request({ method: 'GET', url: apiUrl });
req.on('response', (response) => {
let body = '';
response.on('data', (chunk) => (body += chunk.toString()));
response.on('end', () => {
try {
const json = JSON.parse(body);
// 兼容两种 API 响应格式:
// 格式 A: { code: 0, data: UpdateCheckResult }
// 格式 B: UpdateCheckResult 直接返回
const data: UpdateCheckResult = json.data ?? json;
if (!data.hasUpdate) {
console.log('[Updater] 当前已是最新版本');
return resolve(null);
}
console.log('[Updater] 发现新版本:', data.latestVersion);
resolve(data);
} catch (err) {
reject(new Error(`API 响应解析失败: ${(err as Error).message}`));
}
});
});
req.on('error', (err) => reject(new Error(`API 请求失败: ${err.message}`)));
req.end();
});
}
// ============================================================
// 下载安装包
// ============================================================
async function downloadInstaller(info: UpdateCheckResult): Promise<string> {
const ext =
process.platform === 'win32' ? '.exe' : process.platform === 'darwin' ? '.dmg' : '.AppImage';
const tempPath = join(tmpdir(), `HeiXiu-${info.latestVersion}-update${ext}`);
console.log('[Updater] 开始下载:', info.downloadUrl);
console.log('[Updater] 临时文件:', tempPath);
return new Promise((resolve, reject) => {
const req = net.request({ method: 'GET', url: info.downloadUrl });
const fileStream = createWriteStream(tempPath);
const hash = createHash('sha512');
const totalSize = info.fileSize || 0;
let downloadedSize = 0;
let lastReportTime = 0;
req.on('response', (response) => {
const statusCode = response.statusCode;
if (statusCode !== 200) {
fileStream.close();
unlink(tempPath).catch(() => {});
return reject(new Error(`下载失败HTTP ${statusCode}`));
}
response.on('data', (chunk: Buffer) => {
fileStream.write(chunk);
hash.update(chunk);
downloadedSize += chunk.length;
// 限频:最多每 250ms 推送一次进度
const now = Date.now();
if (totalSize > 0 && now - lastReportTime >= 250) {
lastReportTime = now;
const percent = Math.round((downloadedSize / totalSize) * 100);
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_PROGRESS, {
percent,
bytesPerSecond: 0, // 简化处理,不计算实时速度
transferred: downloadedSize,
total: totalSize,
});
}
});
response.on('end', () => {
fileStream.end();
fileStream.on('finish', () => {
// SHA512 校验
const actualSha512 = hash.digest('base64');
if (info.sha512 && actualSha512 !== info.sha512) {
unlink(tempPath).catch(() => {});
return reject(
new Error(
`SHA512 校验失败\n期望: ${info.sha512.slice(0, 16)}...\n实际: ${actualSha512.slice(0, 16)}...`,
),
);
}
console.log('[Updater] 下载完成SHA512 校验通过');
pendingInstallerPath = tempPath;
resolve(tempPath);
});
});
response.on('error', (err) => {
fileStream.close();
unlink(tempPath).catch(() => {});
reject(new Error(`下载中断: ${err.message}`));
});
});
req.on('error', (err) => {
fileStream.close();
unlink(tempPath).catch(() => {});
reject(new Error(`下载请求失败: ${err.message}`));
});
req.end();
});
}
// ============================================================
// 操作方法
// ============================================================
export async function checkForUpdates(): Promise<void> {
if (updateChecked) {
console.log('[Updater] 已检查过更新,跳过');
return;
}
if (import.meta.env.DEV) {
console.log('[Updater] 开发模式,跳过更新检查');
return;
}
try {
updateChecked = true;
const info = await fetchUpdateInfo();
if (!info) {
// 无更新,不发通知(静默)
return;
}
// 通知渲染进程:有新版本
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_AVAILABLE, {
version: info.latestVersion,
releaseDate: info.releaseDate,
releaseNotes: info.changelog,
forceUpdate: info.forceUpdate,
});
// 自动开始下载
await downloadInstaller(info);
// 下载完成 → 通知渲染进程
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_DOWNLOADED);
} catch (error) {
console.error('[Updater] 更新流程失败:', (error as Error).message);
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
message: (error as Error).message || '更新检查失败',
});
updateChecked = false;
}
}
export async function checkForUpdatesManual(): Promise<void> {
try {
updateChecked = false;
const info = await fetchUpdateInfo();
if (!info) {
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_NOT_AVAILABLE);
return;
}
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_AVAILABLE, {
version: info.latestVersion,
releaseDate: info.releaseDate,
releaseNotes: info.changelog,
forceUpdate: info.forceUpdate,
});
await downloadInstaller(info);
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_DOWNLOADED);
} catch (error) {
console.error('[Updater] 手动更新失败:', (error as Error).message);
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
message: (error as Error).message || '检查更新失败',
});
}
}
export function installUpdateNow(): void {
if (!pendingInstallerPath) {
console.error('[Updater] 没有待安装的更新文件');
return;
}
console.log('[Updater] 开始安装更新:', pendingInstallerPath);
if (process.platform === 'win32') {
// Windows: NSIS 安装器,/S 静默安装
spawn(pendingInstallerPath, ['/S'], {
detached: true,
stdio: 'ignore',
});
} else if (process.platform === 'darwin') {
// macOS: 挂载 DMG
spawn('hdiutil', ['attach', pendingInstallerPath], {
detached: true,
stdio: 'ignore',
});
} else {
// Linux: AppImagechmod +x 后执行(通常需要手动替换)
console.log('[Updater] Linux 更新请手动替换 AppImage 文件');
}
// 退出应用,让安装器接管
setImmediate(() => {
app.quit();
});
}
// ============================================================
// IPC 注册
// ============================================================
export function registerUpdateIpcHandlers(): void {
ipcMain.handle(UPDATE_CHANNELS.CHECK_FOR_UPDATE, async () => {
await checkForUpdatesManual();
});
ipcMain.on(UPDATE_CHANNELS.INSTALL_UPDATE, () => {
installUpdateNow();
});
}

100
electron/main/utils/logo.ts Normal file
View File

@@ -0,0 +1,100 @@
// ============================================================
// 主进程 Logo 自适应工具
// 根据平台返回正确的图标路径,处理 .ico / .png / .icns 格式差异
// ============================================================
import path from 'node:path';
import { isMacOS, isWindows } from './platform';
// Logo 资源目录(相对于构建后的 dist-electron 目录或开发时的 electron/ 目录)
// 开发时 app.getAppPath() 指向项目根目录
// 生产时指向 app.asar 所在目录
/** Logo 目录在项目中的相对路径 */
const LOGO_DIR = 'src/assets/logo';
/**
* 获取 Logo 资源目录的绝对路径
* @param appPath - Electron app.getAppPath() 的返回值
*/
export function getLogoDir(appPath: string): string {
return path.join(appPath, LOGO_DIR);
}
/**
* 按平台获取窗口图标路径
*
* 平台格式要求:
* Windows → .ico支持多尺寸
* macOS → .icns首选或 .png回退
* Linux → .png
*
* @param appPath - Electron app.getAppPath()
* @returns 图标文件的绝对路径
*/
export function getWindowIconPath(appPath: string): string {
const logoDir = path.join(appPath, LOGO_DIR);
if (isWindows()) {
// Windows 首选 .ico
return path.join(logoDir, 'heixiu.ico');
}
if (isMacOS()) {
return path.join(logoDir, 'heixiu.icns');
}
// Linux 使用 .png
return path.join(logoDir, 'HX.png');
}
/**
* 按平台获取系统托盘图标路径
* 托盘图标通常用 .png跨平台通用且尺寸较小
*
* @param appPath - Electron app.getAppPath()
* @returns 托盘图标绝对路径
*/
export function getTrayIconPath(appPath: string): string {
const logoDir = path.join(appPath, LOGO_DIR);
if (isWindows()) {
// Windows 托盘可用 .ico 或小尺寸 .png
return path.join(logoDir, 'heixiu.ico');
}
// macOS / Linux 托盘用 .png
return path.join(logoDir, 'HX.png');
}
/**
* 按平台获取应用安装包图标路径
* electron-builder 打包时使用,各平台格式不同:
* Windows → .ico
* macOS → .icns
* Linux → .png通常 256x256 或 512x512
*
* @param appPath - 应用根目录路径
* @returns 安装包图标的绝对路径
*/
export function getInstallerIconPath(appPath: string): string {
const logoDir = path.join(appPath, LOGO_DIR);
if (isWindows()) {
return path.join(logoDir, 'heixiu.ico');
}
if (isMacOS()) {
return path.join(logoDir, 'heixiu.icns');
}
// Linux
return path.join(logoDir, 'HX.png');
}
/**
* 获取通用 PNG Logo不区分平台用于 About 对话框等)
*/
export function getPngLogoPath(appPath: string): string {
return path.join(appPath, LOGO_DIR, 'HX.png');
}

View File

@@ -0,0 +1,53 @@
// ============================================================
// 主进程平台判定工具 —— 直接使用 Node.js process.platform
// ============================================================
import type { Platform } from '../../../shared/types';
/** 获取当前平台标识 */
export function getPlatform(): Platform {
return process.platform as Platform;
}
/** 是否为 macOS */
export function isMacOS(): boolean {
return process.platform === 'darwin';
}
/** 是否为 Windows */
export function isWindows(): boolean {
return process.platform === 'win32';
}
/** 是否为 Linux */
export function isLinux(): boolean {
return process.platform === 'linux';
}
/** 获取平台名称(中文) */
export function getPlatformName(): string {
if (isMacOS()) return 'macOS';
if (isWindows()) return 'Windows';
if (isLinux()) return 'Linux';
return '未知平台';
}
/** 获取平台专用的快捷键修饰符描述macOS 用 ⌘Windows/Linux 用 Ctrl */
export function getShortcutModifier(): string {
return isMacOS() ? 'Cmd' : 'Ctrl';
}
/** 判断是否需要在任务栏显示图标macOS 不需要Windows/Linux 需要) */
export function shouldShowTaskbarIcon(): boolean {
return !isMacOS();
}
/** 获取用户数据目录的基础路径 */
export function getUserDataPath(): string {
// Electron app.getPath('userData') 在各平台自动适配
// 此处仅作为工具函数的补充
const home = process.env.HOME || process.env.USERPROFILE || '';
if (isMacOS()) return `${home}/Library/Application Support`;
if (isWindows()) return process.env.APPDATA || `${home}\\AppData\\Roaming`;
return `${home}/.config`;
}

24
electron/preload.ts Normal file
View File

@@ -0,0 +1,24 @@
import { ipcRenderer, contextBridge } from 'electron';
// --------- Expose some API to the Renderer process ---------
contextBridge.exposeInMainWorld('ipcRenderer', {
on(...args: Parameters<typeof ipcRenderer.on>) {
const [channel, listener] = args;
return ipcRenderer.on(channel, (event, ...args) => listener(event, ...args));
},
off(...args: Parameters<typeof ipcRenderer.off>) {
const [channel, ...omit] = args;
return ipcRenderer.off(channel, ...omit);
},
send(...args: Parameters<typeof ipcRenderer.send>) {
const [channel, ...omit] = args;
return ipcRenderer.send(channel, ...omit);
},
invoke(...args: Parameters<typeof ipcRenderer.invoke>) {
const [channel, ...omit] = args;
return ipcRenderer.invoke(channel, ...omit);
},
// You can expose other APTs you need here.
// ...
});

40
eslint.config.mjs Normal file
View File

@@ -0,0 +1,40 @@
import tseslint from '@typescript-eslint/eslint-plugin';
import tsparser from '@typescript-eslint/parser';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
export default [
{
ignores: ['dist/**', 'dist-electron/**', 'release/**', 'node_modules/**', '.eslintrc.cjs'],
},
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: tsparser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
},
plugins: {
'@typescript-eslint': tseslint,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
// ESLint 推荐规则
...tseslint.configs.recommended.rules,
// React Hooks 规则(关闭 set-state-in-effect — 挂载时拉数据/弹窗是正常模式)
...reactHooks.configs.recommended.rules,
'react-hooks/set-state-in-effect': 'off',
// React Refresh
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
// 自定义
'semi': ['warn', 'always'],
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
},
},
];

15
index.html Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<!-- 使用项目 Logo 作为 faviconWindows 可用 .ico -->
<link rel="icon" type="image/png" href="/src/assets/logo/HX.png" />
<!-- <meta name="viewport" content="width=device-width, initial-scale=1.0" />-->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;">
<title>船长·HeiXiu</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1
null Normal file
View File

@@ -0,0 +1 @@
Active code page: 65001

9535
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

115
package.json Normal file
View File

@@ -0,0 +1,115 @@
{
"name": "ele-heixiu",
"private": true,
"version": "0.0.1",
"description": "船长·HeiXiu — 桌面效率工作台",
"author": "HeiXiu 杨烨",
"type": "module",
"scripts": {
"dev": "chcp 65001 > null && cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ vite",
"build": "node build.mjs --win --personal --build",
"build:win": "node build.mjs --win --personal --build",
"build:win:enterprise": "node build.mjs --win --enterprise --build",
"build:mac": "node build.mjs --mac --personal --build",
"build:mac:enterprise": "node build.mjs --mac --enterprise --build",
"build:linux": "node build.mjs --linux --personal --build",
"build:linux:enterprise": "node build.mjs --linux --enterprise --build",
"build:clean": "node scripts/clean.mjs",
"upload:oss": "node scripts/upload-oss.mjs",
"publish:release": "node scripts/publish-release.mjs",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\" \"electron/**/*.ts\" \"shared/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\" \"electron/**/*.ts\" \"shared/**/*.ts\"",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test"
},
"dependencies": {
"antd": "^6.4.3",
"axios": "^1.16.1",
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
"build": {
"appId": "com.heixiu.electron",
"productName": "船长·HeiXiu",
"asar": true,
"directories": {
"output": "release/${version}"
},
"files": [
"dist",
"dist-electron"
],
"electronDownload": {
"mirror": "https://npmmirror.com/mirrors/electron/"
},
"publish": {
"provider": "generic",
"url": "https://update.heixiu.com"
},
"win": {
"icon": "src/assets/logo/heixiu.ico",
"target": [
{
"target": "NSIS",
"arch": [
"x64"
]
}
],
"artifactName": "${productName}-Windows-${version}-${env.EDITION}-Setup.${ext}"
},
"mac": {
"icon": "src/assets/logo/heixiu.icns",
"target": [
"dmg"
],
"artifactName": "${productName}-Mac-${version}-${env.EDITION}-Installer.${ext}"
},
"linux": {
"icon": "src/assets/logo/HX.png",
"target": [
"AppImage"
],
"artifactName": "${productName}-Linux-${version}-${env.EDITION}.${ext}"
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true,
"deleteAppDataOnUninstall": false,
"installerIcon": "src/assets/logo/heixiu.ico",
"uninstallerIcon": "src/assets/logo/heixiu.ico"
}
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@tailwindcss/vite": "^4.3.0",
"@types/electron": "^1.4.38",
"@types/node": "^25.9.1",
"@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.60.1",
"@typescript-eslint/parser": "^8.60.1",
"@vitejs/plugin-react": "^6.0.0",
"autoprefixer": "^10.5.0",
"cross-env": "^10.1.0",
"electron": "42.3.0",
"electron-builder": "^26.8.1",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"postcss": "^8.5.15",
"prettier": "^3.8.3",
"rollup-plugin-visualizer": "^7.0.1",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3",
"vite": "8.0.16",
"vite-plugin-electron": "^1.0.0",
"vite-plugin-electron-renderer": "^1.0.0"
},
"main": "dist-electron/main.js"
}

19
playwright.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
timeout: 30000,
retries: 1,
use: {
// Electron 应用通过自定义 fixture 启动,不走 browser 配置
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
// 仅列出涉及的项目类型,实际 Electron 启动在 fixture 中处理
projects: [
{
name: 'electron',
// Electron E2E 不需要 playwright 的 browser由 electron 命令启动
},
],
});

4107
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
allowBuilds:
electron: true
esbuild: true

25
scripts/clean.mjs Normal file
View File

@@ -0,0 +1,25 @@
// ============================================================
// 清理所有构建产物
// npm run build:clean
// ============================================================
import { rmSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
const dirs = ['release', 'dist', 'dist-electron'];
for (const dir of dirs) {
const p = join(ROOT, dir);
if (existsSync(p)) {
rmSync(p, { recursive: true, force: true });
console.log(`已清理: ${dir}/`);
} else {
console.log(`跳过(不存在): ${dir}/`);
}
}
console.log('\n清理完成。');

View File

@@ -0,0 +1,170 @@
// ============================================================
// 打包后自动生成 update-info.json
//
// 用法:
// node scripts/generate-update-info.mjs <platform> <edition> [version] [--url <url>]
//
// 两种用法:
// 方式 A预判 URL打包后自动拼接 URL → 适用于 OSS 路径可预测时
// node scripts/generate-update-info.mjs win32 personal
//
// 方式 B指定 URL上传后用实际 URL 重新生成 → 适用于上传后才知道 URL 时
// node scripts/generate-update-info.mjs win32 personal 0.1.0 --url "https://oss.../xxx.exe"
//
// 环境变量(可选):
// UPDATE_DOWNLOAD_BASE — 方式 A 的 URL 基地址,默认 https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases
// UPDATE_FORCE — 是否强制更新,默认 false
// UPDATE_MIN_VERSION — 最低兼容版本,默认 0.0.0
// UPDATE_CHANGELOG — 覆盖 CHANGELOG.md 中的日志内容
// ============================================================
import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createHash } from 'node:crypto';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
// ---------- 参数解析 ----------
const args = process.argv.slice(2);
// 提取 --url 参数
let urlOverride = '';
const positional = [];
for (let i = 0; i < args.length; i++) {
if (args[i] === '--url' && args[i + 1]) {
urlOverride = args[++i];
} else if (!args[i].startsWith('--')) {
positional.push(args[i]);
}
}
const platform = positional[0];
const edition = positional[1];
if (!platform || !edition) {
console.error('用法: node scripts/generate-update-info.mjs <platform> <edition> [version] [--url <url>]');
console.error('示例: node scripts/generate-update-info.mjs win32 personal');
console.error(' node scripts/generate-update-info.mjs win32 personal 0.1.0 --url "https://..."');
process.exit(1);
}
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
const version = positional[2] || pkg.version;
const productName = pkg.build?.productName || '船长·HeiXiu';
// ---------- 查找安装包 ----------
const releaseDir = join(ROOT, 'release', version);
let installerPath;
try {
const files = readdirSync(releaseDir);
// 按扩展名找安装包
const exts = platform === 'win32' ? ['.exe'] : platform === 'darwin' ? ['.dmg'] : ['.AppImage'];
const match = files.find((f) => exts.some((ext) => f.endsWith(ext)));
if (!match) {
console.error(`未在 ${releaseDir} 中找到安装包文件`);
process.exit(1);
}
installerPath = join(releaseDir, match);
console.log(`找到安装包: ${match}`);
} catch {
console.error(`目录不存在: ${releaseDir}\n请先执行构建(如 npm run build:win`);
process.exit(1);
}
// ---------- 计算文件信息 ----------
const fileBuffer = readFileSync(installerPath);
const stats = statSync(installerPath);
// SHA512Base64 编码,与 certutil 输出一致)
const sha512 = createHash('sha512').update(fileBuffer).digest('base64').replace(/\r?\n/g, '');
const fileSize = stats.size;
console.log(`文件大小: ${(fileSize / 1024 / 1024).toFixed(2)} MB`);
console.log(`SHA512: ${sha512.slice(0, 32)}...`);
// ---------- 构建下载 URL ----------
const fileName = installerPath.split(/[/\\]/).pop();
let downloadUrl;
if (urlOverride) {
// 方式 B使用指定的 URL
downloadUrl = urlOverride;
console.log(`下载 URL手动指定: ${downloadUrl}`);
} else {
// 方式 A根据基地址 + 版本号 + 文件名拼接(预判 URL
const downloadBase = process.env.UPDATE_DOWNLOAD_BASE || 'https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases';
downloadUrl = `${downloadBase.replace(/\/+$/, '')}/${version}/${encodeURI(fileName)}`;
console.log(`下载 URL自动拼接: ${downloadUrl}`);
}
// ---------- 读取更新日志 ----------
let changelog;
if (process.env.UPDATE_CHANGELOG) {
// 环境变量覆盖(适用于 CI/CD 流水线)
changelog = process.env.UPDATE_CHANGELOG;
} else {
// 从 CHANGELOG.md 提取最新版本的日志
const changelogPath = join(ROOT, 'CHANGELOG.md');
try {
const changelogContent = readFileSync(changelogPath, 'utf-8');
// 匹配最新版本块:## x.y.z日期 ... ## 或 EOF
const versionSection = changelogContent.match(
/^##\s+(\d+\.\d+\.\d+)[^\n]*\n([\s\S]*?)(?=^##\s|\Z)/m,
);
if (versionSection) {
changelog = versionSection[0].trim();
} else {
changelog = `## ${version}\n\n更新内容待补充`;
}
console.log(`更新日志: 已从 CHANGELOG.md 提取`);
} catch {
changelog = `## ${version}\n\n更新内容待补充`;
}
}
// ---------- 其他配置 ----------
const forceUpdate = process.env.UPDATE_FORCE === 'true';
const minimumVersion = process.env.UPDATE_MIN_VERSION || '0.0.0';
const releaseDate = new Date().toISOString().slice(0, 10);
// ---------- 生成 JSON ----------
const updateInfo = {
// --- 必填API 返回字段 ---
hasUpdate: true,
latestVersion: version,
downloadUrl,
fileSize,
sha512,
changelog,
releaseDate,
forceUpdate,
minimumVersion,
// --- 附加:方便后端录入 ---
_meta: {
platform,
edition,
fileName,
generatedAt: new Date().toISOString(),
},
};
const outputPath = join(
releaseDir,
`${productName}-${platform}-${edition}-v${version}-update-info.json`,
);
writeFileSync(outputPath, JSON.stringify(updateInfo, null, 2), 'utf-8');
console.log(`\n✅ 已生成: ${outputPath}`);
console.log(JSON.stringify(updateInfo, null, 2));

130
scripts/publish-release.mjs Normal file
View File

@@ -0,0 +1,130 @@
// ============================================================
// 发布版本信息到后端 API
//
// 用法:
// node scripts/publish-release.mjs <platform> <edition> [version] [--url <url>]
//
// 环境变量:
// UPDATE_API_URL — API 基地址(默认 https://www.heixiu.com
// UPDATE_API_KEY — API 鉴权 Key通过 Authorization: Bearer 头传递)
// ============================================================
import { readFileSync, readdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import axios from 'axios';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
// ---------- 参数 ----------
const args = process.argv.slice(2);
const positional = [];
let urlOverride = '';
let jsonOverride = '';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--url' && args[i + 1]) {
urlOverride = args[++i];
} else if (args[i] === '--json' && args[i + 1]) {
jsonOverride = args[++i];
} else if (!args[i].startsWith('--')) {
positional.push(args[i]);
}
}
const platform = positional[0];
const edition = positional[1];
if (!platform || !edition) {
console.error('用法: node scripts/publish-release.mjs <platform> <edition> [version] [--url <url>]');
console.error('示例: node scripts/publish-release.mjs win32 personal 0.1.0');
process.exit(1);
}
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
const version = positional[2] || pkg.version;
// ---------- 读取 update-info.json ----------
let updateInfo;
if (jsonOverride) {
try {
updateInfo = JSON.parse(jsonOverride);
} catch {
console.error('错误: --json 参数不是有效的 JSON');
process.exit(1);
}
} else {
const releaseDir = join(ROOT, 'release', version);
let infoFile;
try {
const all = readdirSync(releaseDir);
infoFile = all.find((f) =>
f.includes(platform) && f.includes(edition) && f.endsWith('-update-info.json'),
);
if (!infoFile) console.error(`未找到 ${platform}+${edition} 的 update-info.json`);
} catch (err) {
console.error(`错误: ${releaseDir}${err.message}`);
process.exit(1);
}
updateInfo = JSON.parse(readFileSync(join(releaseDir, infoFile), 'utf-8'));
if (urlOverride) updateInfo.downloadUrl = urlOverride;
}
// ---------- 发送到 API ----------
const apiBase = (process.env.UPDATE_API_URL || 'https://www.heixiu.com').replace(/\/+$/, '');
const apiKey = process.env.UPDATE_API_KEY || '';
const payload = {
platform,
edition,
latestVersion: updateInfo.latestVersion || version,
downloadUrl: updateInfo.downloadUrl,
fileSize: updateInfo.fileSize,
sha512: updateInfo.sha512,
changelog: updateInfo.changelog,
releaseDate: updateInfo.releaseDate,
forceUpdate: updateInfo.forceUpdate || false,
minimumVersion: updateInfo.minimumVersion || '0.0.0',
};
const endpoint = `${apiBase}/api/v1/releases`;
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('📡 发布版本到 API');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log(`POST ${endpoint}`);
console.log(`版本: ${version} (${platform}/${edition})`);
console.log(`URL: ${payload.downloadUrl}\n`);
try {
const response = await axios.post(endpoint, payload, {
headers: {
'Content-Type': 'application/json',
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
},
timeout: 30000,
});
console.log('✅ 发布成功');
console.log(`响应: ${JSON.stringify(response.data, null, 2)}`);
} catch (err) {
const status = err.response?.status;
const data = err.response?.data;
if (status) {
console.error(`❌ 发布失败 HTTP ${status}: ${JSON.stringify(data)}`);
} else {
console.error(`❌ 请求失败: ${err.message}`);
}
console.log('\n💡 手动备选 — 把以下 JSON 发给后端:');
console.log(JSON.stringify(payload, null, 2));
process.exit(1);
}
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');

161
scripts/upload-oss.mjs Normal file
View File

@@ -0,0 +1,161 @@
// ============================================================
// 上传安装包到阿里云 OSS
//
// 用法:
// node scripts/upload-oss.mjs <platform> <edition> [version]
//
// 环境变量(使用 Node.js 直传时需要):
// OSS_ENDPOINT — OSS endpoint如 oss-cn-hangzhou.aliyuncs.com
// OSS_BUCKET — Bucket 名称
// OSS_ACCESS_KEY_ID — AccessKey ID
// OSS_ACCESS_KEY_SECRET — AccessKey Secret
// OSS_BASE_PATH — Bucket 内基路径,默认 releases
//
// 不传 OSS 环境变量时 → 打印手动上传命令ossutil / 控制台)
//
// 示例:
// # 仅打印上传命令
// node scripts/upload-oss.mjs win32 personal 0.1.0
//
// # 直接上传
// OSS_BUCKET=heixiu OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com \
// OSS_ACCESS_KEY_ID=xxx OSS_ACCESS_KEY_SECRET=xxx \
// node scripts/upload-oss.mjs win32 personal 0.1.0
// ============================================================
import {readFileSync, readdirSync} from 'node:fs';
import {join, dirname} from 'node:path';
import {fileURLToPath} from 'node:url';
import {createHmac} from 'node:crypto';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
// ---------- 参数 ----------
const platform = process.argv[2];
const edition = process.argv[3];
if (!platform || !edition) {
console.error('用法: node scripts/upload-oss.mjs <platform> <edition> [version]');
console.error('示例: node scripts/upload-oss.mjs win32 personal 0.1.0');
process.exit(1);
}
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
const version = process.argv[4] || pkg.version;
// ---------- 查找安装包 ----------
const releaseDir = join(ROOT, 'release', version);
let installerName;
try {
const all = readdirSync(releaseDir);
const exts = platform === 'win32' ? ['.exe'] : platform === 'darwin' ? ['.dmg'] : ['.AppImage'];
installerName = all.find((f) => exts.some((e) => f.endsWith(e)));
if (!installerName) console.error(`错误: ${releaseDir} — 未找到安装包`);
} catch (err) {
console.error(`错误: ${releaseDir}${err.message}`);
process.exit(1);
}
const installerPath = join(releaseDir, installerName);
// ---------- OSS 配置 ----------
const {
OSS_ENDPOINT,
OSS_BUCKET,
OSS_ACCESS_KEY_ID,
OSS_ACCESS_KEY_SECRET,
} = process.env;
const basePath = (process.env.OSS_BASE_PATH || 'releases').replace(/^\/+|\/+$/g, '');
const objectKey = `${basePath}/${version}/${installerName}`;
// ---------- 模式 A无 OSS 配置 → 打印手动命令 ----------
if (!OSS_ENDPOINT || !OSS_BUCKET || !OSS_ACCESS_KEY_ID) {
const exampleBucket = OSS_BUCKET || '<bucket>';
const exampleEndpoint = OSS_ENDPOINT || '<endpoint>';
const downloadUrl = `https://${exampleBucket}.${exampleEndpoint}/${objectKey}`;
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('📦 上传安装包(手动)');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log(`文件: ${installerPath}`);
console.log(`大小: ${(readFileSync(installerPath).length / 1024 / 1024).toFixed(2)} MB\n`);
console.log('方式 1 — ossutil CLI推荐');
console.log(` ossutil cp "${installerPath}" oss://${exampleBucket}/${objectKey}\n`);
console.log('方式 2 — 阿里云控制台:');
console.log(` 登录 OSS 控制台 → ${exampleBucket} → 上传 → 目标路径: ${objectKey}\n`);
console.log('上传后执行以下命令,用实际 URL 重新生成 info');
console.log(` node scripts/generate-update-info.mjs ${platform} ${edition} ${version} --url "${downloadUrl}"\n`);
console.log('或直接发布到后端 API');
console.log(` node scripts/publish-release.mjs ${platform} ${edition} ${version} --url "${downloadUrl}"`);
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
process.exit(0);
}
// ---------- 模式 BNode.js 直传 OSS ----------
const fileBuffer = readFileSync(installerPath);
const contentType = platform === 'win32'
? 'application/vnd.microsoft.portable-executable'
: 'application/octet-stream';
const date = new Date().toUTCString();
// 阿里云 OSS Authorization 签名
const stringToSign = [
'PUT',
'',
contentType,
date,
`/${OSS_BUCKET}/${objectKey}`,
].join('\n');
const signature = createHmac('sha1', OSS_ACCESS_KEY_SECRET)
.update(stringToSign)
.digest('base64');
const authHeader = `OSS ${OSS_ACCESS_KEY_ID}:${signature}`;
const downloadUrl = `https://${OSS_BUCKET}.${OSS_ENDPOINT}/${objectKey}`;
console.log(`上传中: ${installerName} (${(fileBuffer.length / 1024 / 1024).toFixed(2)} MB)`);
console.log(`目标: oss://${OSS_BUCKET}/${objectKey}`);
try {
const response = await fetch(downloadUrl, {
method: 'PUT',
headers: {
'Content-Type': contentType,
'Content-Length': String(fileBuffer.length),
'Date': date,
'Authorization': authHeader,
},
body: fileBuffer,
});
if (response.ok) {
console.log(`✅ 上传成功`);
console.log(`下载 URL: ${downloadUrl}\n`);
console.log('接下来:');
console.log(` # 重新生成 update-info.json`);
console.log(` node scripts/generate-update-info.mjs ${platform} ${edition} ${version} --url "${downloadUrl}"`);
console.log(`\n # 或直接发布到后端:`);
console.log(` node scripts/publish-release.mjs ${platform} ${edition} ${version} --url "${downloadUrl}"`);
} else {
const body = await response.text();
console.error(`❌ 上传失败 HTTP ${response.status}: ${body}`);
process.exit(1);
}
} catch (err) {
console.error(`❌ 上传失败: ${err.message}`);
process.exit(1);
}

59
shared/constants/app.ts Normal file
View File

@@ -0,0 +1,59 @@
// ============================================================
// 应用常量 —— 版本号、版本类型、窗口标题
// 主进程 & 渲染进程共享
// ============================================================
import type { Edition, Platform } from '../types';
import { APP_VERSION } from './version';
/** 应用显示名称 */
export const APP_NAME = '船长·HeiXiu';
// APP_VERSION 从 ./version.ts 导入(读取 package.json不再硬编码
// ============================================================
// 版本类型(企业版 / 个人版)
// ============================================================
/**
* 解析版本类型字符串
* @param raw - 环境变量值,如 "enterprise" / undefined
* @returns 版本类型,默认 personal
*/
export function parseEdition(raw: string | undefined): Edition {
if (raw === 'enterprise') return 'enterprise';
return 'personal';
}
/** 版本中文名称 */
export function getEditionName(edition: Edition): string {
return edition === 'enterprise' ? '企业版' : '个人版';
}
// ============================================================
// 窗口标题构建
// ============================================================
/** 平台中文名称(简写) */
function platformShortName(platform: Platform): string {
if (platform === 'darwin') return 'MAC';
if (platform === 'win32') return 'Windows';
return 'Linux';
}
/**
* 构建完整窗口标题
* 格式船长·HeiXiu V{版本号} {平台}{版本}
* 示例:"船长·HeiXiu V0.0.1 MAC企业版"
* "船长·HeiXiu V0.0.1 Windows个人版"
*
* @param platform - 当前操作系统平台
* @param edition - 版本类型
* @param version - 可选覆盖版本号(默认 APP_VERSION
*/
export function buildWindowTitle(platform: Platform, edition: Edition, version?: string): string {
const ver = version ?? APP_VERSION;
const os = platformShortName(platform);
const edName = getEditionName(edition);
return `${APP_NAME} V${ver} ${os}${edName}`;
}

View File

@@ -0,0 +1,43 @@
// ============================================================
// IPC 通道名称常量 —— 避免魔法字符串
// ============================================================
/** 主进程 → 渲染进程 */
export const MAIN_TO_RENDERER = {
/** 主进程消息推送 */
MAIN_PROCESS_MESSAGE: 'main-process-message',
/** 主题变更通知 */
THEME_CHANGED: 'theme-changed',
/** 窗口聚焦通知 */
WINDOW_FOCUSED: 'window-focused',
/** 窗口失焦通知 */
WINDOW_BLURRED: 'window-blurred',
} as const;
/** 渲染进程 → 主进程 */
export const RENDERER_TO_MAIN = {
/** 打开子窗口 */
OPEN_SUB_WINDOW: 'open-sub-window',
/** 关闭子窗口 */
CLOSE_SUB_WINDOW: 'close-sub-window',
/** 设置窗口标题 */
SET_WINDOW_TITLE: 'set-window-title',
/** 获取平台信息 */
GET_PLATFORM_INFO: 'get-platform-info',
/** 最小化窗口 */
MINIMIZE_WINDOW: 'minimize-window',
/** 最大化/还原窗口 */
TOGGLE_MAXIMIZE: 'toggle-maximize',
/** 关闭窗口 */
CLOSE_WINDOW: 'close-window',
} as const;
/** 双向通信通道 */
export const BIDIRECTIONAL = {
/** 读取应用配置 */
READ_CONFIG: 'read-config',
/** 写入应用配置 */
WRITE_CONFIG: 'write-config',
/** 文件对话框 */
FILE_DIALOG: 'file-dialog',
} as const;

View File

@@ -0,0 +1,9 @@
// ============================================================
// 版本号 — 从 package.json 动态读取,保持单一数据源
// 主进程Node.js和渲染进程Vite 打包)均可正常导入 JSON 模块
// ============================================================
import pkg from '../../package.json';
/** 应用版本号,与 package.json 的 version 字段自动同步 */
export const APP_VERSION: string = pkg.version;

71
shared/types/index.ts Normal file
View File

@@ -0,0 +1,71 @@
// ============================================================
// 共享类型定义 —— 主进程 & 渲染进程通用
// ============================================================
/** 平台类型 */
export type Platform = 'win32' | 'darwin' | 'linux';
/** 窗口标识 */
export interface WindowIdentity {
/** 窗口唯一 ID */
id: number;
/** 窗口类型标识 */
type: 'main' | 'settings' | 'preview' | 'custom';
/** 窗口标题 */
title: string;
}
/** IPC 响应包装 */
export interface IpcResponse<T = unknown> {
success: boolean;
data?: T;
error?: string;
}
/** 主题模式 */
export type ThemeMode = 'light' | 'dark' | 'system';
/** 版本类型 */
export type Edition = 'enterprise' | 'personal';
/** 应用配置 */
export interface AppConfig {
themeMode: ThemeMode;
language: string;
autoLaunch: boolean;
minimizeToTray: boolean;
}
// ============================================================
// 更新相关类型
// ============================================================
/** 更新检查请求参数 */
export interface UpdateCheckParams {
platform: Platform;
version: string;
edition: Edition;
channel?: 'stable' | 'beta' | 'alpha';
}
/** 更新检查响应API 返回的 data 字段) */
export interface UpdateCheckResult {
/** 是否有可用更新 */
hasUpdate: boolean;
/** 最新版本号 */
latestVersion: string;
/** 安装包下载地址 */
downloadUrl: string;
/** 安装包大小(字节) */
fileSize: number;
/** SHA512 校验值 */
sha512: string;
/** 更新日志(支持 Markdown */
changelog: string;
/** 发布日期 */
releaseDate: string;
/** 是否强制更新 */
forceUpdate: boolean;
/** 最低兼容版本(低于此版本必须强制更新) */
minimumVersion: string;
}

75
shared/utils/index.ts Normal file
View File

@@ -0,0 +1,75 @@
// ============================================================
// 共享工具函数 —— 主进程 & 渲染进程通用(纯函数,不依赖平台 API
// ============================================================
/**
* 简单防抖函数
* @param fn 目标函数
* @param delay 延迟毫秒数
*/
export function debounce<T extends (...args: unknown[]) => void>(
fn: T,
delay: number,
): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (timer !== null) {
clearTimeout(timer);
}
timer = setTimeout(() => {
fn(...args);
timer = null;
}, delay);
};
}
/**
* 节流函数
* @param fn 目标函数
* @param interval 间隔毫秒数
*/
export function throttle<T extends (...args: unknown[]) => void>(
fn: T,
interval: number,
): (...args: Parameters<T>) => void {
let lastTime = 0;
return (...args: Parameters<T>) => {
const now = Date.now();
if (now - lastTime >= interval) {
lastTime = now;
fn(...args);
}
};
}
/**
* 语义化版本号比较
* @returns 1 (v1 > v2), -1 (v1 < v2), 0 (相等)
*/
export function compareVersions(v1: string, v2: string): number {
const parts1 = v1.split('.').map(Number);
const parts2 = v2.split('.').map(Number);
const maxLen = Math.max(parts1.length, parts2.length);
for (let i = 0; i < maxLen; i++) {
const a = parts1[i] || 0;
const b = parts2[i] || 0;
if (a > b) return 1;
if (a < b) return -1;
}
return 0;
}
/**
* 深拷贝JSON 安全类型)
*/
export function deepClone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
}
/**
* 生成唯一 ID简易版不依赖 crypto
*/
export function generateId(): string {
return `${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 9)}`;
}

323
src/App.tsx Normal file
View File

@@ -0,0 +1,323 @@
import { useState, useEffect, useCallback } from 'react';
import {
Button,
Card,
Space,
Tag,
Typography,
Switch as AntSwitch,
Slider,
Progress,
theme as antTheme,
} from 'antd';
import {
ApiOutlined,
ThunderboltOutlined,
BulbOutlined,
BulbFilled,
DownloadOutlined,
SyncOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
RocketOutlined,
} from '@ant-design/icons';
import { getPlatformName, isDev } from './utils/platform';
import { gradientPrimary } from './theme/tokens';
import { useTheme } from './hooks/use-theme';
import { useUpdater } from './hooks/use-updater';
import { useAppContext } from './contexts/app-context';
import { NavBar } from './components/navbar';
import { LoginPage } from './pages/login';
import { on, off, EVENTS } from './utils/event-bus';
const { Title, Text } = Typography;
/**
* 根组件 — 主窗口
* 未登录时弹出登录 Modal已登录显示主页
* axios 返回 401 → 事件总线触发 → Modal 再次弹出
*/
function App() {
const { isDark, toggleTheme } = useTheme();
const { platform, edition, isLoggedIn, logout } = useAppContext();
const { token } = antTheme.useToken();
const [progress, setProgress] = useState(65);
const {
status: updateStatus,
progress: updateProgress,
info: updateInfo,
error: updateError,
checkForUpdates,
installUpdate,
} = useUpdater();
// 登录 Modal 状态
const [loginOpen, setLoginOpen] = useState(false);
// 首次加载时未登录 → 弹出
useEffect(() => {
if (!isLoggedIn) {
setLoginOpen(true);
}
}, [isLoggedIn]);
// 监听事件总线401 → 弹出登录 / 导航栏点击登录 → 弹出登录
useEffect(() => {
const handler = () => setLoginOpen(true);
on(EVENTS.AUTH_REQUIRED, handler);
on(EVENTS.SHOW_LOGIN, handler);
return () => {
off(EVENTS.AUTH_REQUIRED, handler);
off(EVENTS.SHOW_LOGIN, handler);
};
}, []);
const handleLoginClose = useCallback(() => {
setLoginOpen(false);
}, []);
return (
<>
{/* 登录 Modal — 可关闭,关闭后主页可操作 */}
<LoginPage open={loginOpen && !isLoggedIn} onClose={handleLoginClose} />
{/* ========== 主页 ========== */}
<div
className="min-h-screen flex flex-col transition-colors duration-300"
style={{ background: token.colorBgLayout }}
>
{/* ======== 自定义导航栏(替代系统菜单) ======== */}
<NavBar />
{/* ======== 页面内容 ======== */}
<div className="flex-1 p-8">
<div className="mx-auto max-w-4xl space-y-6">
{/* ---- 标题区 + 主题开关 ---- */}
<div className="text-center space-y-2">
<div className="flex items-center justify-center gap-4">
<Title level={1} className="mb-0! gradient-primary-text">
HeiXiu ·
</Title>
<AntSwitch
checked={isDark}
onChange={toggleTheme}
checkedChildren={<BulbFilled />}
unCheckedChildren={<BulbOutlined />}
title={isDark ? '切换亮色模式' : '切换暗色模式'}
/>
</div>
<Text type="secondary">
Electron + React 19 + TypeScript + Ant Design + Tailwind CSS
</Text>
<br />
<Space size={4}>
<Tag color={isDark ? 'blue' : 'purple'}>
{isDark ? '🌙 暗色' : '☀️ 亮色'}
</Tag>
<Tag color={edition === 'enterprise' ? 'gold' : 'green'}>
{edition === 'enterprise' ? '企业版' : '个人版'}
</Tag>
{isLoggedIn ? (
<>
<Tag color="blue"></Tag>
<Button size="small" type="link" danger onClick={logout}>
退
</Button>
</>
) : (
<Tag color="default"></Tag>
)}
</Space>
</div>
{/* ---- 导航栏状态提示 ---- */}
<Card size="small" variant="borderless" className="rounded-lg">
<Text type="secondary" className="text-xs">
💡 使 <Text strong>H5 </Text>
{platform === 'darwin'
? ' macOS 保留了最小 Edit 菜单以支持 Cmd+C/V 等快捷键。'
: ' Windows/Linux 已完全移除系统菜单栏。'}
</Text>
</Card>
{/* ---- 主题色预览 ---- */}
<Card
title="🎨 蓝紫渐变主题色预览"
variant="borderless"
className="rounded-lg shadow-lg"
>
<Space orientation="vertical" size="large" className="w-full">
{/* 渐变展示 */}
<div>
<Text strong></Text>
<div className="flex gap-3 mt-2">
<div
className="flex-1 h-16 rounded-lg flex items-center justify-center text-white font-medium shadow-md"
style={{ background: gradientPrimary }}
>
135°
</div>
<div
className="flex-1 h-16 rounded-lg flex items-center justify-center text-white font-medium shadow-md"
style={{ background: 'linear-gradient(90deg, #4F46E5, #7C3AED)' }}
>
90°
</div>
</div>
</div>
{/* 组件示例 */}
<div>
<Text strong>Ant Design </Text>
<Space wrap className="mt-2">
<Button type="primary" icon={<ThunderboltOutlined />}>
</Button>
<Button></Button>
<Button type="dashed">线</Button>
<Button type="text"></Button>
<Button type="link"></Button>
</Space>
</div>
{/* 滑块 + 进度条 */}
<div>
<Text strong></Text>
<div className="flex items-center gap-4 mt-2">
<span className="text-sm" style={{ color: token.colorTextSecondary }}>
:
</span>
<Slider
className="flex-1"
value={progress}
onChange={setProgress}
min={0}
max={100}
/>
<Progress
type="circle"
percent={progress}
size={48}
strokeColor={{ '0%': '#4F46E5', '100%': '#7C3AED' }}
/>
</div>
</div>
</Space>
</Card>
{/* ---- 平台信息 ---- */}
<Card title="🖥️ 系统信息" variant="borderless" className="rounded-lg shadow-lg">
<Space orientation="vertical" size="middle" className="w-full">
<div className="flex items-center gap-2">
<ApiOutlined style={{ color: token.colorPrimary }} />
<Text strong></Text>
<Tag color="purple">{getPlatformName()}</Tag>
</div>
<div className="flex items-center gap-2">
<ApiOutlined style={{ color: token.colorPrimary }} />
<Text strong></Text>
<Tag color={isDev() ? 'blue' : 'green'}>
{isDev() ? '开发模式' : '生产模式'}
</Tag>
</div>
<div className="flex items-center gap-2">
<ApiOutlined style={{ color: token.colorPrimary }} />
<Text strong></Text>
<Tag color="purple">
H5 {edition === 'enterprise' ? '企业版' : '个人版'}
</Tag>
</div>
</Space>
</Card>
{/* ---- 版本更新 ---- */}
<Card title="🔄 版本更新" variant="borderless" className="rounded-lg shadow-lg">
<Space orientation="vertical" size="middle" className="w-full">
{/* 状态提示 */}
{updateStatus === 'checking' && (
<div className="flex items-center gap-2">
<SyncOutlined spin style={{ color: token.colorPrimary }} />
<Text type="secondary">...</Text>
</div>
)}
{updateStatus === 'no-update' && (
<div className="flex items-center gap-2">
<CheckCircleOutlined style={{ color: token.colorSuccess }} />
<Text type="success"></Text>
</div>
)}
{updateStatus === 'error' && (
<div className="flex items-center gap-2">
<ExclamationCircleOutlined style={{ color: token.colorError }} />
<Text type="danger">{updateError?.message || '检查更新失败'}</Text>
</div>
)}
{/* 发现新版本 */}
{(updateStatus === 'available' || updateStatus === 'downloading') && (
<>
<div className="flex items-center gap-2">
<RocketOutlined style={{ color: token.colorPrimary }} />
<Text strong>{updateInfo?.version}</Text>
{updateInfo?.forceUpdate && <Tag color="red"></Tag>}
</div>
{updateInfo?.releaseNotes && (
<Text type="secondary" className="text-xs whitespace-pre-line">
{updateInfo.releaseNotes}
</Text>
)}
</>
)}
{/* 下载进度 */}
{updateStatus === 'downloading' && (
<Progress
percent={updateProgress.percent}
strokeColor={{ '0%': '#4F46E5', '100%': '#7C3AED' }}
/>
)}
{/* 下载完成 */}
{updateStatus === 'downloaded' && (
<>
<div className="flex items-center gap-2">
<CheckCircleOutlined style={{ color: token.colorSuccess }} />
<Text></Text>
</div>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={installUpdate}
>
</Button>
</>
)}
{/* 操作按钮 */}
<div className="flex items-center gap-3">
<Button
icon={<SyncOutlined spin={updateStatus === 'checking'} />}
onClick={checkForUpdates}
disabled={updateStatus === 'checking'}
>
</Button>
{isDev() && (
<Text type="secondary" className="text-xs">
5
</Text>
)}
</div>
</Space>
</Card>
</div>
</div>
</div>
</>
);
}
export default App;

View File

@@ -0,0 +1 @@
<svg fill="currentColor" class="icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"> <path d="M498.602667 191.744a204.714667 204.714667 0 0 1 116.906666 372.8c133.162667 47.317333 229.077333 173.290667 231.893334 322.026667l0.085333 6.784h-64c0-157.333333-127.573333-284.885333-284.885333-284.885334-155.136 0-281.301333 123.968-284.821334 278.250667l-0.085333 6.613333h-64c0-151.68 96.810667-280.746667 232-328.810666a204.714667 204.714667 0 0 1 116.906667-372.8z m0 64a140.714667 140.714667 0 1 0 0 281.450667 140.714667 140.714667 0 0 0 0-281.450667z" /> </svg>

After

Width:  |  Height:  |  Size: 578 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" class="icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"> <path d="M906.666667 202.666667v682.666666h-789.333334v-682.666666h789.333334z m-64 64h-661.333334v554.666666h661.333334v-554.666666zM326.464 510.72L434.432 618.666667l-107.946667 107.946666-45.269333-45.226666L343.914667 618.666667l-62.698667-62.72 45.248-45.226667zM733.909333 661.333333v64H481.792v-64h252.117333z m0-149.333333v64H481.792v-64h252.117333z m2.090667-149.333333v64h-448v-64h448z" /> </svg>

After

Width:  |  Height:  |  Size: 505 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" class="icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M714.666667 117.333333v64h170.666666v704h-746.666666v-704h170.666666v-64h405.333334z m-405.333334 128h-106.666666v576h618.666666v-576h-106.666666v64h-405.333334v-64zM704 618.666667v64H320v-64h384z m0-192v64H320v-64h384zM650.666667 181.333333h-277.333334v64h277.333334v-64z" /></svg>

After

Width:  |  Height:  |  Size: 389 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" class="icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M512 71.274667l373.333333 176.362666v302.698667c0 162.048-133.034667 332.224-344.682666 392.469333l-8.853334 2.453334-19.797333 5.12-20.032-5.184C282.026667 889.045333 146.730667 724.096 138.986667 563.029333l-0.256-6.890666-0.064-308.48L512 71.253333z m-0.021333 70.784l-309.333334 146.112 0.085334 267.136c3.285333 129.6 112.789333 269.354667 288.341333 323.072l8.533333 2.538666 8.661334 2.389334 3.690666 0.96 3.541334-0.896c183.104-48.981333 298.581333-189.141333 305.493333-320.554667l0.256-6.250667 0.085333-6.229333V288.170667L511.978667 142.058667z m104.704 237.141333l45.248 45.248-203.648 203.648-143.296-143.296 45.248-45.248 98.026666 98.048 158.421334-158.4z" /></svg>

After

Width:  |  Height:  |  Size: 789 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" class="icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"> <path d="M501.333333 48.789333l132.501334 132.522667 187.52 0.021333-0.042667 187.434667 132.565333 132.565333-132.565333 132.501334 0.021333 187.52-187.498666-0.042667-132.48 132.565333-132.586667-132.565333L181.333333 821.333333 181.333333 633.834667l-132.522666-132.48 132.522666-132.586667L181.333333 181.333333l187.434667-0.021333L501.333333 48.789333zM501.333333 139.285333l-106.026666 106.026667H245.333333v149.973333l-106.026666 106.026667 106.026666 106.026667V757.333333l149.973334-0.021333 106.026666 106.048 106.026667-106.048 149.994667 0.021333-0.021334-149.973333 106.048-106.048-106.048-106.026667 0.021334-149.973333h-149.994667l-106.026667-106.026667z m131.370667 246.549334l45.248 45.248-203.648 203.648-143.296-143.296 45.248-45.269334 98.048 98.048 158.4-158.378666z" /> </svg>

After

Width:  |  Height:  |  Size: 897 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" class="icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M926.421333 157.12v341.333333H797.013333l0.021334 404.010667-156.266667-77.376-132.138667 66.773333-132.138666-66.773333-155.456 76.8-0.021334-403.434667H94.421333v-341.333333h832z m-193.408 202.645333h-448v439.125334l91.797334-45.333334 131.818666 66.56 131.882667-66.56 92.501333 45.781334V359.765333z m-96.064 244.202667v64h-256v-64h256z m0-147.050667v64h-256v-64h256z m225.472-235.818666h-704v213.333333h62.592l0.021334-138.666667h576l-0.021334 138.666667h65.408v-213.333333z" /></svg>

After

Width:  |  Height:  |  Size: 596 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" class="icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"> <path d="M458.666667 501.333333v341.333334h-298.666667v-341.333334h298.666667z m384 85.333334v256h-298.666667v-256h298.666667z m-448-21.333334h-170.666667v213.333334h170.666667v-213.333334z m384 85.333334h-170.666667v128h170.666667v-128z m64-490.666667v341.333333h-298.666667v-341.333333h298.666667z m-64 64h-170.666667v213.333333h170.666667v-213.333333z m-320-64v256h-298.666667v-256h298.666667z m-64 64h-170.666667v128h170.666667v-128z" /> </svg>

After

Width:  |  Height:  |  Size: 547 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" class="icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M512 96c229.76 0 416 186.24 416 416S741.76 928 512 928 96 741.76 96 512 282.24 96 512 96z m0 64C317.589333 160 160 317.589333 160 512S317.589333 864 512 864 864 706.410667 864 512 706.410667 160 512 160z m105.386667 117.034667l45.226666 45.248-136.661333 136.682666H682.666667v64h-138.666667v81.941334H682.666667v64h-138.666667v100.8h-64v-100.8H341.333333v-64h138.666667v-81.941334H341.333333v-64h156.714667l-136.682667-136.682666 45.269334-45.226667L512 382.4l105.386667-105.386667z" /></svg>

After

Width:  |  Height:  |  Size: 600 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" class="icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" ><path d="M341.333333 661.333333a117.376 117.376 0 0 1 112.917334 85.333334H875.093333v64H454.250667a117.376 117.376 0 0 1-225.834667 0H149.76v-64h78.656A117.376 117.376 0 0 1 341.333333 661.333333z m0 64a53.333333 53.333333 0 1 0 0 106.666667 53.333333 53.333333 0 0 0 0-106.666667z m341.333334-320a117.376 117.376 0 0 1 112.917333 85.333334h79.509333v64h-79.509333a117.376 117.376 0 0 1-225.834667 0H149.76v-64h419.989333A117.376 117.376 0 0 1 682.666667 405.333333z m0 64a53.333333 53.333333 0 1 0 0 106.666667 53.333333 53.333333 0 0 0 0-106.666667zM341.333333 149.333333a117.376 117.376 0 0 1 112.917334 85.333334H875.093333v64H454.250667a117.376 117.376 0 0 1-225.834667 0H149.76v-64h78.656A117.376 117.376 0 0 1 341.333333 149.333333z m0 64a53.333333 53.333333 0 1 0 0 106.666667 53.333333 53.333333 0 0 0 0-106.666667z" /></svg>

After

Width:  |  Height:  |  Size: 934 B

View File

@@ -0,0 +1 @@
<svg fill="currentColor" class="icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M586.666667 160V490.666667h242.048l-111.296-111.296 45.248-45.269334 188.565333 188.586667-188.565333 188.544-45.248-45.248L828.714667 554.666667H586.666667v330.666666h-298.666667V661.333333h64v160h170.666667V554.666667H72.661333v-64H522.666667V224h-170.666667v159.104h-64V160z" /></svg>

After

Width:  |  Height:  |  Size: 394 B

View File

@@ -0,0 +1,30 @@
<svg fill="currentColor" class="icon"
style="width: 1em;height: 1em;vertical-align: middle;fill: currentColor;overflow: hidden;" viewBox="0 0 1024 1024"
xmlns="http://www.w3.org/2000/svg">
<path d="M518.06 316.21c-109.48 0-198.23 88.75-198.23 198.23s88.75 198.23 198.23 198.23c109.48 0 198.23-88.75 198.23-198.23s-88.75-198.23-198.23-198.23z m0 364.07c-91.59 0-165.84-74.25-165.84-165.84S426.47 348.6 518.06 348.6 683.9 422.85 683.9 514.44s-74.25 165.84-165.84 165.84z"/>
<path d="M512.32 247.95l-6.69 234.94h20.51V239.98z"/>
<path d="M503.25 225.73a15.62 25.67 0 1 0 31.24 0 15.62 25.67 0 1 0-31.24 0Z"/>
<path d="M488.03 345.5a30.03 53.93 0 1 0 60.06 0 30.03 53.93 0 1 0-60.06 0Z"/>
<path d="M326.14 329.53l161.4 170.86 14.51-14.51-171.77-171.76z"/>
<path d="M304.016356 320.223887a25.67 15.62 44.999 1 0 22.089631-22.090401 25.67 15.62 44.999 1 0-22.089631 22.090401Z"/>
<path d="M377.943833 415.685666a53.93 30.03 44.999 1 0 42.468092-42.469575 53.93 30.03 44.999 1 0-42.468092 42.469575Z"/>
<path d="M252.18 518.86l234.95 6.69v-20.51H244.22z"/>
<path d="M204.3 512.31a25.67 15.62 0 1 0 51.34 0 25.67 15.62 0 1 0-51.34 0Z"/>
<path d="M295.8 513.12a53.93 30.03 0 1 0 107.86 0 53.93 30.03 0 1 0-107.86 0Z"/>
<path d="M333.76 705.03l170.86-161.39-14.5-14.51-171.76 171.76z"/>
<path d="M295.261178 734.259141a15.62 25.67 44.999 1 0 36.302229-36.303495 15.62 25.67 44.999 1 0-36.302229 36.303495Z"/>
<path d="M360.53674 670.125621a30.03 53.93 44.999 1 0 76.267206-76.269869 30.03 53.93 44.999 1 0-76.267206 76.269869Z"/>
<path d="M523.09 778.99l6.7-234.94h-20.52v242.91z"/>
<path d="M500.92 801.21a15.62 25.67 0 1 0 31.24 0 15.62 25.67 0 1 0-31.24 0Z"/>
<path d="M487.32 681.44a30.03 53.93 0 1 0 60.06 0 30.03 53.93 0 1 0-60.06 0Z"/>
<path d="M709.27 697.42l-161.4-170.87-14.5 14.51 171.76 171.76z"/>
<path d="M709.296892 728.80118a25.67 15.62 44.999 1 0 22.08963-22.090402 25.67 15.62 44.999 1 0-22.08963 22.090402Z"/>
<path d="M614.984176 653.726352a53.93 30.03 44.999 1 0 42.468092-42.469574 53.93 30.03 44.999 1 0-42.468092 42.469574Z"/>
<path d="M783.23 508.08l-234.94-6.69v20.51h242.9z"/>
<path d="M779.78 514.63a25.67 15.62 0 1 0 51.34 0 25.67 15.62 0 1 0-51.34 0Z"/>
<path d="M631.75 513.82a53.93 30.03 0 1 0 107.86 0 53.93 30.03 0 1 0-107.86 0Z"/>
<path d="M701.65 321.91L530.79 483.3l14.5 14.51 171.76-171.76z"/>
<path d="M703.832107 328.985384a15.62 25.67 44.999 1 0 36.302229-36.303496 15.62 25.67 44.999 1 0-36.302229 36.303496Z"/>
<path d="M598.591861 433.085985a30.03 53.93 44.999 1 0 76.267207-76.269869 30.03 53.93 44.999 1 0-76.267207 76.269869Z"/>
<path d="M518.06 470.39c-24.33 0-44.05 19.72-44.05 44.05s19.72 44.05 44.05 44.05 44.05-19.72 44.05-44.05-19.72-44.05-44.05-44.05z m0 65.43c-11.81 0-21.38-9.57-21.38-21.38 0-11.81 9.57-21.38 21.38-21.38 11.81 0 21.38 9.57 21.38 21.38 0 11.81-9.57 21.38-21.38 21.38z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -0,0 +1 @@
<svg fill="currentColor" class="icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"> <path d="M874.666667 553.877333V746.666667h-192v-128.021334h-64v258.986667h-64V554.666667h192v127.978666h64v-128.768h64zM469.333333 554.666667v320H149.333333V554.666667h320z m405.333334 256v64h-192v-64h192z m-469.333334-192h-192v192h192v-192z m-64 64v64h-64v-64h64z m128-533.333334v320H149.333333V149.333333h320z m405.333334 0v320H554.666667V149.333333h320z m-469.333334 64h-192v192h192v-192z m405.333334 0h-192v192h192v-192z m-469.333334 64v64h-64v-64h64z m405.333334 0v64h-64v-64h64z" /> </svg>

After

Width:  |  Height:  |  Size: 595 B

View File

@@ -0,0 +1,274 @@
为了更好地为您提供服务请您仔细阅读《用户服务协议》以下简称“本协议”。在您开始使用“船长Heixiu”平台及相关服务之前请您务必认真阅读并充分理解本协议特别是涉及免除或者限制责任的条款、权利许可和限制责任的条款、同意开通服务的条款、法律适用和争议解决的条款等。其中免除或者限制责任条款等重要内容将以加粗形式提示您注意您应重点阅读。如您未满18周岁请您在法定监护人陪同下仔细阅读并充分理解本协议并征得法定监护人的同意后使用“船长Heixiu”平台及相关服务。
除非您完全接受本协议的全部内容否则您无权注册、登录、使用以下统称“使用”“船长Heixiu”产品或者通过任何方式使用“船长Heixiu”服务及获得“船长Heixiu”平台提供的任何服务及产品本协议中统称“服务”。若您使用“船长Heixiu”平台及相关服务则视为您已充分理解本协议并承诺作为本协议的一方当事人接受协议的约束。
一、适用范围
1.1 本协议是您与[船长Heixiu]以下简称“我们”或“公司”之间就您注册、登录、使用“船长Heixiu”产品并获得“船长Heixiu”平台提供的相关服务所订立的协议。我们有权依“船长Heixiu”平台及相关服务或运营的需要单方决定安排或指定其关联方或公司认可的第三方继续运营“船长Heixiu”平台。并且就本协议项下涉及的某些服务可能会由公司的关联方或公司认可的第三方向您提供。您知晓并同意接受相关服务内容即视为接受相关权利义务关系亦接受本协议约束。
1.2 “用户”指所有直接或间接获取和使用“船长Heixiu”平台及相关服务的使用者包括自然人、法人和其他组织等。在本协议中称为“用户”或称“您”。
1.3 “船长Heixiu”指由我们合法拥有并运营的、标注名称为“船长Heixiu”的应用程序。公司有权对应用程序名称单方变更包括但不限于更名、新增等。公司发出更名公告后本协议所有约定适用更名后的应用程序。
1.4 本协议内容同时包括公司已经发布及后续可能不断发布的关于“船长Heixiu”平台及相关服务的相关协议、规则、说明等内容。前述内容一经正式发布公司将以适当的方式包括但不限于电话、短信、公告、系统通知等提醒您及时阅读。前述相关协议、规则构成是本协议不可分割的组成部分您使用或继续使用“船长Heixiu”平台视为您同意遵守前述相关协议、规则等内容。
二、使用“船长Heixiu”平台及相关服务
2.1 您使用“船长Heixiu”平台及相关服务可以访问“船长Heixiu”相关产品。若您并非从公司或经公司授权的第三方获取本平台的公司无法保证非官方版本的“船长Heixiu”平台能够正常使用或没有其他风险您因此遭受的损失与公司无关。
2.2 您知悉并同意“船长Heixiu”所提供的服务系网络虚拟商品采用先收费后服务的方式您为购买的服务所支付的款项非预付款或者存款、定金、储蓄卡等性质服务一经开通后不可转让或退款如因服务存在重大瑕疵导致您完全无法使用或法律法规要求必须退款或我们判断后认为可以退款的情形除外。为了保证您的用户权益请您通过官方渠道购买我们的服务一切通过非官方公布渠道取得的产品服务均不获保护并且我们有权中止或终止对您的服务由此给您带来的损失我们无须承担责任。
2.3公司可能为不同的终端设备开发了不同的应用程序平台版本您可自行根据需要使用“船长Heixiu”平台及相关服务或更新“船长Heixiu”版本如您不再需要使用“船长Heixiu”平台及相关服务可自行卸载。
2.4 为更好的提升用户体验及服务公司将不定期提供平台更新或改变包括但不限于平台修改、升级、功能强化、开发新服务等。为保证“船长Heixiu”平台及相关服务安全、提升用户体验度本平台及相关服务更新或部分服务内容更新后在可能的情况下公司将以包括但不限于弹窗、邮件、短信、系统提示、公告、站内信等方式提示用户用户有权选择是否接受更新版本或服务如用户不接受由于旧版本平台公司可能不再维护有可能无法使用新功能或部分功能将受到限制或不能继续使用后果由用户自行承担。
2.5 除非得到明示事先书面授权您不得以任何形式对“船长Heixiu”平台及相关服务进行包括但不限于改编、复制、传播、垂直搜索、镜像或交易等未经授权的访问或使用您违法使用造成的一切后果由您承担。
2.6 您理解您使用“船长Heixiu”平台及相关服务需自行准备与平台及相关服务有关的终端设备如电脑、手机等装置一旦您在其终端设备中打开“船长Heixiu”平台或访问船长Heixiu相关产品即视为您使用“船长Heixiu”平台及相关服务。为充分实现“船长Heixiu”的全部功能您可能需要将其终端设备联网且您清楚由您自行承担所需要的费用如流量费、上网费等
2.7 公司许可您个人的、可撤销的、不可转让的、非独占地和非商业的合法使用“船长Heixiu”平台及相关服务的权利。本协议未明示授权的其他一切权利仍由公司保留您在行使该些权利时须另行获得公司的书面许可同时公司如未行使前述任何权利并不构成对该权利的放弃。
2.8 如您发现“船长Heixiu”平台内存在任何侵犯您权利的内容请立即通过客服邮箱2006410116@dididiketang.com 通知公司提供您有相关权利的初步证据公司将会在15个工作日内处理您的投诉。如您在“船长Heixiu”内存在或涉及内容被相关权利人投诉侵权您也可以通过向客服邮箱2006410116@dididiketang.com提交反馈通知书及相关权利证明材料来向公司进行申诉公司同样将会在15个工作日内处理您的申诉。如您收到通知后未能在2个工作日内提供相关申诉材料则本公司有权依法采取暂停传输、封闭账号等措施。
三、关于“账号”
3.1 注册、登录“船长Heixiu”和相关服务
1当您注册、登录“船长Heixiu”及相关服务时您可以通过手机号创建账号我们将通过发送短信验证码来验证您的身份是否有效收集这些信息是为了帮助您完成注册。
2您也可以使用第三方账号(如微信等)登录进入船长Heixiu您此时将授权我们获取您在第三方平台注册的公开信息(头像、昵称等)并在您同意本协议后将您的第三方账号与您的船长Heixiu账户绑定使您可以通过第三方账号直接登录并使用本产品和相关服务。
3请您妥善保管您的任何信息因账号遗失、泄露等造成的损失由您自行承担。
3.2 账号注销
在您需要终止使用“船长Heixiu”账号服务时您可向“船长Heixiu”客服邮箱2006410116@dididiketang.com 申请注销您本人的账号但您仍应对您在注销账号前且使用“船长Heixiu”服务期间的行为承担相应责任。注销成功后我们将停止为您提供产品或服务并且您的账号记录、数据等将被删除无法恢复或提供法律法规另有规定的除外。具体注销事宜可查看《账号注销协议》。
3.3 您理解并承诺,您所设置的账号不得违反国家法律法规及公司的相关规则,您的账号名称、头像等注册信息及其他个人信息中不得出现违法和不良信息,未经他人许可不得用他人名义(包括但不限于冒用他人姓名、名称、字号、头像等足以让人引起混淆的方式)开设账号不得恶意注册“船长Heixiu”账号(包括但不限于频繁注册、批量注册账号等行为)。您在账号注册及使用过程中需遵守相关法律法规,不得实施任何侵害国家利益、损害其他公民权益、有害社会道德风尚的行为。公司有权对您提交的注册信息进行审核。
3.4 您在“船长Heixiu”中的注册账号仅限于您本人使用您对您的账号仅享有使用权未经公司书面同意禁止以任何形式赠与、借用、出租、转让、售卖或以其他方式许可他人使用该账号。如果公司发现或者有合理理由认为使用者并非账号初始注册人为保障账号安全公司有权立即暂停或终止向该注册账号提供服务或注销该账号。
3.5 您有责任维护您的个人账号的安全性与保密性您的注册账号下产生的任何活动均视为您自身的行为您应对此承担全部法律责任包括但不限于您在“船长Heixiu”上进行的款项支付等操作行为。您应高度重视对账号的保密在任何情况下不向他人透露账号及密码。若发现他人未经许可使用您的账号或发生其他任何安全漏洞问题时您应当立即通知公司。
3.6 您的账号在丢失或遗忘密码后可联系客服邮箱2006410116@dididiketang.com申诉按照客服要求完成验证找回账号或密码。公司特别提醒您应妥善保管您的账号和密码。当您使用完毕后应安全退出。因其他人恶意攻击或您自身原因或其他不可抗力因素而导致账号被盗、丢失公司不承担任何责任。
3.7 在注册、使用和管理账号时,您应保证注册账号时填写的身份信息的真实性,请您在注册、管理账号时使用真实、准确、合法、有效的相关身份证明材料及必要信息(包括但不限于您的联系电话等)。依照国家法律法规的规定为使用本平台及服务的部分功能您需要填写真实的身份信息请您按照相关法律规定完成实名认证并注意及时更新上述相关信息。若您提交的材料或提供的信息不准确、不真实、不规范、不合法或者公司有理由怀疑为错误、不实或不合法则公司有权拒绝为您提供相关服务或您可能无法使用“船长Heixiu”平台及相关服务或在使用过程中部分功能受到限制。
3.8 除自行注册“船长Heixiu”账号外您也可选择通过授权使用您合法拥有的包括但不限于公司和/或其关联公司、其他平台用户账号注册并登录使用“船长Heixiu”平台及相关服务但第三方平台或平台对此有限制或禁止的除外。当您以前述已有账号登录使用的应保证相应账号已进行实名注册登记并同意适用本协议中的相关约定。
3.9 如您通过“船长Heixiu”已注册的账号登录本公司的关联公司或合作方运营的系列客户端平台产品及产品时应遵守该平台产品及产品自身的《用户协议》及其他协议条款的规定。
四、用户个人信息保护
4.1 我们非常重视用户信息的保护,保护用户个人信息是公司的基本原则之一。
4.2 在使用“船长Heixiu”平台及相关服务的过程中您可能需要提供您的个人信息以便公司向您提供更好的服务和相应的技术支持。您理解并同意公司有权在更好的为您提供服务和遵守法律法规、本协议以及《船长Heixiu隐私政策》的前提下收集、使用含商业合作使用、存储和分享您的个人信息。同时我们将依法保护您浏览、修改、删除相关个人信息以及撤回同意的权利。更多关于用户个人信息保护的内容请参看《船长Heixiu隐私政策》。
五、 用户行为规范
5.1 用户行为要求
您应对使用本产品及相关服务的行为负责除非法律允许或者经公司事先书面许可您使用“船长Heixiu”平台及相关服务不得具有下列行为
5.1.1 使用未经公司授权或许可的任何插件、外挂、系统或第三方工具对“船长Heixiu”平台及相关服务的正常运行进行干扰、破坏、修改或施加其他影响。
5.1.2 利用或针对“船长Heixiu”平台及相关服务进行任何危害计算机网络安全的行为包括但不限于
1非法侵入他人网络、干扰他人网络正常功能、窃取网络数据等危害网络安全的活动
2提供专门用于从事侵入网络、干扰网络正常功能及防护措施、窃取网络数据等危害网络安全活动的程序、工具
3明知他人从事危害网络安全的活动的为其提供技术支持、广告推广、支付结算等帮助
4使用未经许可的数据或进入未经许可的服务器/账号;
5未经允许进入公众计算机网络或者他人计算机系统并删除、修改、增加存储信息
6未经许可企图探查、扫描、测试“船长Heixiu”系统或网络的弱点或其它实施破坏网络安全的行为
7企图干涉、破坏“船长Heixiu”系统或产品的正常运行故意传播恶意程序或病毒以及其他破坏干扰正常网络信息服务的行为
8伪造TCP/IP数据包名称或部分名称。
5.1.3 对“船长Heixiu”平台进行反向工程、反向汇编、编译或者以其他方式尝试发现本平台的源代码。
5.1.4 恶意注册“船长Heixiu”账号包括但不限于频繁、批量注册账号。
5.1.5 违反法律法规、本协议、公司的相关规则及侵犯他人合法权益的其他行为。
在任何情况下,如果公司有理由认为您的任何行为违反或可能违反上述约定的,公司可独立进行判断并处理,且在任何时候有权在不进行任何事先通知的情况下终止向用户提供服务,并追究相关责任。
5.2 信息内容规范
5.2.1 用户按规定完成实名认证后可以注册账号登录“船长Heixiu”平台使用我们提供的服务。
5.2.2 用户制作、传播的内容应自觉遵守法律法规、社会主义制度、国家利益、公民合法权益、社会公共秩序、道德风尚和信息真实性等“七条底线”要求,否则公司将立即采取限制功能、暂停服务、关闭账号等处理措施。用户不得发表下列信息:
1反对宪法确定的基本原则的
2危害国家安全泄露国家秘密
3颠覆国家政权推翻社会主义制度煽动分裂国家破坏国家统一的
4损害国家荣誉和利益的
5宣扬恐怖主义、极端主义的
6宣扬民族仇恨、民族歧视破坏民族团结的
7煽动地域歧视、地域仇恨的
8破坏国家宗教政策宣扬邪教和迷信的
9编造、散布谣言、虚假信息扰乱社会秩序、破坏社会稳定的
10散布、传播淫秽、色情、赌博、暴力、凶杀、恐怖或者教唆犯罪的
11危害网络安全、利用网络从事危害国家安全、荣誉和利益的
12侮辱或者诽谤他人侵害他人合法权益的
13对他人进行暴力恐吓、威胁实施人肉搜索的
14涉及他人隐私、个人信息或资料的
15散布污言秽语损害社会公序良俗的
16侵犯他人隐私权、名誉权、肖像权、知识产权等合法权益内容的
17散布商业广告或类似的商业招揽信息、过度营销信息及垃圾信息
18使用本产品常用语言文字以外的其他语言文字评论的
19与所评论的信息毫无关系的
20所发表的信息毫无意义的或刻意使用字符组合以逃避技术审核的
21侵害未成年人合法权益或者损害未成年人身心健康的
22未获他人允许偷拍、偷录他人侵害他人合法权利的
23包含恐怖、暴力血腥、高危险性、危害表演者自身或他人身心健康内容的包括但不限于以下情形
1任何暴力和/或自残行为内容;
2任何威胁生命健康、利用刀具等危险器械表演的危及自身或他人人身或财产权利的内容
3怂恿、诱导他人参与可能会造成人身伤害或导致死亡的危险或违法活动的内容。
24其他违反法律法规、政策及公序良俗禁止的信息
25干扰“船长Heixiu”正常运营或侵犯其他用户或第三方合法权益内容的其他信息。
六、信息内容使用规范
6.1 未经公司书面许可用户不得自行或授权、允许、协助任何第三人对本协议“船长Heixiu”平台及相关服务中信息内容进行如下行为
1复制、读取、采用“船长Heixiu”平台及相关服务的信息内容用于包括但不限于宣传、增加阅读量、浏览量等商业用途
2擅自编辑、整理、编排“船长Heixiu”平台及相关服务的信息内容后在船长Heixiu平台及相关服务的源页面以外的渠道进行展示
3采用包括但不限于特殊标识、特殊代码等任何形式的识别方法自行或协助第三人对“船长Heixiu”平台及相关服务的信息或内容产生流量、转移、劫持等不利影响
4其他非法获取“船长Heixiu”平台及相关服务的信息内容的行为。
6.2 经公司书面许可后用户对“船长Heixiu”平台的分享等行为还应符合以下规范
1对抓取、统计、获得的相关数据未经公司事先书面同意不得将数据以任何方式公示、提供、泄露给任何第三人
2不得对“船长Heixiu”平台及相关服务的源网页进行任何形式的任何改动包括但不限于“船长Heixiu”平台及相关服务的首页链接入口也不得对“船长Heixiu”平台及相关服务的源页面的展示进行任何形式的遮挡、插入、弹窗等妨碍
3应当采取安全、有效、严密的措施防止“船长Heixiu”平台及相关服务的信息内容被第三方通过包括但不限于“蜘蛛”spider程序等任何形式进行非法获取
4不得把相关数据内容用于公司书面许可范围之外的目的进行任何形式的销售和商业使用或向第三方泄露、提供或允许第三方以任何方式的使用。
七、生成合成内容标识
船长Heixiu将遵守适用法律对人工智能生成合成内容进行显示或隐式标识请您仔细阅读并理解相关的标识管理要求。
7.1 船长Heixiu中生成合成内容将会标识 "船长Heixiu AI生成"或其他我们不时更新且符合法律法规要求的标识.
7.2 未经我们允许您不得擅自删除、篡改、伪造或隐匿船长Heixiu的任何人工智能生成合成内容标识。
7.3 如果您为付费用户,你将根据付费权益享有要求生成合成内容不添加显示标识的权利,我们将根据您的要求提供不含显式标识的生成合成内容。但是,请您了解并承诺,如您使用网络信息内容传播服务发布 船长Heixiu 生成合成内容的,应当主动声明此内容为人工智能生成合成内容,并使用我们提供的标识功能进行标识。
7.4 您承诺并同意,您在自主标识生成合成内容的过程中会严格遵守相关法律法规的要求真实准确的使用标识,不会通过不正当标识手段损害我们或他人的合法权益。
八、违约处理
8.1 针对您违反本协议或其他服务条款的行为,公司有权独立判断并视情况采取预先警示、限制账号部分或者全部功能直至永久关闭账号等措施。公司有权公告处理结果,且有权根据实际情况决定是否恢复使用。对涉嫌违反法律法规、涉嫌违法犯罪的行为将保存有关记录,并依法向有关主管部门报告、配合有关主管部门调查。对已删除内容公司有权不予恢复及返还。
8.2 因您违反本协议或其他服务条款规定,引起第三方投诉或诉讼索赔的,您应当自行承担全部法律责任。因您的违法或违约行为导致公司及其关联方向任何第三方赔偿或遭受国家机关处罚的,您还应足额赔偿造成的全部损失。
8.3 公司尊重并保护法人、公民的知识产权、名誉权、姓名权、隐私权等合法权益。您保证使用“船长Heixiu”平台及相关服务生成的作品等不侵犯任何第三方的知识产权、名誉权、姓名权、肖像权、隐私权等权利及合法权益。否则公司有权在收到权利方或者相关方通知的情况下移除该涉嫌侵权内容。针对第三方提出的全部权利主张您应自行承担全部法律责任如因您的侵权行为导致公司及其关联公司、控制公司、继承公司遭受损失的包括但不限于经济、商誉等损失您还应足额赔偿。
8.4 您清楚并同意,任何情况下,公司对您的违约及赔偿总额,不超过您使用公司服务已支付的全部费用总额。
九、服务的变更、中断和终止
9.1 您理解并同意公司提供的“船长Heixiu”平台及相关服务是按照现有技术和条件所能达到的现状提供的。公司会尽最大努力向您提供服务确保服务的连贯性和安全性。您理解公司不能随时预见和防范法律、技术以及其他风险包括但不限于不可抗力、病毒、木马、黑客攻击、系统不稳定、第三方服务瑕疵及其他各种安全问题的侵扰等原因可能导致的服务中断、数据丢失以及其他的损失和风险。
9.2 您理解并同意公司基于经营策略的调整或运营需要有权在公告通知后修改、中断、中止或终止“船长Heixiu”平台及相关服务而无须向用户负责或承担任何赔偿责任。
9.3 您理解并同意,服务中止或终止后,公司没有义务向您提供或返还任何数据。
9.4 若用户申请注销账户,开通套餐激活软件时所部署的积分,不予退还。
十、知识产权
10.1 公司在“船长Heixiu”平台及相关服务中提供的内容包括但不限于平台、技术、程序、网页、文字、图片、图像、图表、版面设计、电子文档等的知识产权属于公司或合法第三方所有。公司提供本服务时所依托的平台著作权、专利权及其他知识产权均归公司所有。未经权利人许可您无权擅自使用包括但不限于通过任何机器人、蜘蛛等程序或设备监视、复制、传播、展示、镜像、上载、下载“船长Heixiu”平台及相关服务中的任何内容否则您应自行承担全部后果且本公司或第三方权利人有权追究您的法律责任。
10.2 您理解并同意您在使用“船长Heixiu”平台及相关服务时上传的文字、图片等均由您所有或已获合法授权含转授权不会违反法律法规的规定或侵犯任何第三方的合法权益。您通过“船长Heixiu”上传、合成并保存的任何内容的知识产权归属您或原始著作权人所有如因此引起任何纠纷等由您自行承担全部责任给公司造成损失的公司有权向您追索赔偿。
10.3 内容授权
1您知悉、理解并同意对于您通过“船长Heixiu”平台上传及合成的全部内容包括但不限文字、图片、图像等若您将内容在船长Heixiu平台内主动发布包括公开可见或者部分用户可见等形式的发布则视为您在此授予公司及其关联方一项全球范围内、免费、非独家、可再许可通过多层次的权利包括但不限于复制权、翻译权、汇编权、信息网络传播权、改编权、制作衍生品等使用范围包括但不限于在当前或其他产品、应用程序、产品或终端设备等。您在此确认并同意上述权利的授予包括在与“船长Heixiu”、公司和/或公司品牌有关的任何推广、广告、营销宣传中使用和以其他方式开发内容(全部或部分)的权利和许可。为避免疑惑,您同意,上述权利的授予包括使用、复制和展示您拥有或被许可使用并植入内容中的个人形象、肖像、姓名、商标、服务标志、品牌、名称、标识和公司标记(如有)以及任何其他品牌、营销或推广资产的权利和许可。
2您知悉、理解并同意若您通过“船长Heixiu”平台上传及合成的内容包括但不限文字、图片、图像等未在平台内进行发布仅在本地保存或在服务器内临时存储则公司及关联方不享有任何使用该内容的权利亦不会对该内容进行任何形式的展示、传播、改编或开发利用。但平台会对您生成的内容进行一定期间的临时存储。存储期限届满后平台将通过技术手段自动删除该内容删除后将无法恢复。您可在存储期限内自行导出或备份该内容因逾期未备份导致的内容丢失公司不承担责任。
10.4 公司为“船长Heixiu”开发、运营提供技术支持并对“船长Heixiu”平台及相关服务的开发和运营等过程中产生的所有数据和信息等享有全部权利。
10.5 请您在任何情况下都不要私自使用公司的包括但不限于“船长Heixiu”的任何商标、服务标记、商号、域名、产品名称或其他显著品牌特征等以下统称为“标识”。未经公司事先书面同意您不得将本条款前述标识以单独或结合任何方式展示、使用或申请注册商标、进行域名注册等也不得实施向他人明示或暗示有权展示、使用或其他有权处理该些标识的行为。由于您违反本协议使用公司上述商标、标识等给公司或他人造成损失的由您承担全部法律责任。
十一、免责声明
11.1 您理解并同意“船长Heixiu”平台及相关服务可能会受多种因素的影响或干扰公司不对下述事项向您作出任何保证(包括但不限于)
11.1.1 公司的平台及相关服务完全符合用户的使用要求;
11.1.2 公司不受干扰,及时、安全、可靠或不出现错误;用户经由公司取得的任何平台、服务或其他材料符合用户的期望;
11.1.3 平台中任何错误都将能得到更正。
11.2 对于涉及财产的网络信息、账户密码、广告或推广等信息的,用户请谨慎对待并自行进行判断,基于前述原因您因此遭受的利润、商业信誉、资料损失或其他有形或无形损失,公司不承担任何直接、间接、附带、特别、衍生性或惩罚性的赔偿责任。
11.3 用户理解并同意在使用“船长Heixiu”平台及相关服务过程中可能遇到不可抗力等因素不可抗力是指不能预见、不能克服并不能避免的客观事件包括但不限于政府行为、自然灾害、网络原因、黑客攻击、战争或任何其它类似事件。出现不可抗力情况时公司将努力在第一时间及时修复但因不可抗力给用户造成了损失用户同意公司不承担责任。
11.4 公司依据本协议约定获得处理违法违规内容的权利,该权利不构成公司的义务或承诺,公司不能保证及时发现违法行为或进行相应处理。
11.5 用户明确了解并同意:关于本协议服务,公司不提供任何种类的明示或暗示担保或条件,包括但不限于商业适售性、特定用途适用性等。您对本协议平台及相关服务的使用行为必须自行承担相应风险。
11.6 用户明确了解并同意,本协议是在保障遵守国家法律法规、维护公序良俗、保护他人合法权益,公司在能力范围内尽最大的努力按照相关法律法规进行判断,但并不保证公司判断完全与司法机关、行政机关的判断一致,如因此产生的后果用户已经理解并同意自行承担。
十二、未成年人使用条款
12.1 我们的产品主要是面向成年人若您是未满18周岁的未成年人在使用“船长Heixiu”平台及相关服务前应在您的父母或其他监护人监护、指导下阅读并同意本协议。
12.2 公司重视对未成年人个人信息的保护未成年用户在填写个人信息时请加强个人保护意识并谨慎对待并在监护人指导下正确使用“船长Heixiu”平台及相关服务。
12.3 如未成年用户违反法律法规、本协议内容,未成年人的监护人应依照法律规定承担因此而导致的一切后果。
12.4 未成年人用户特别提示
12.4.1 青少年及使用“船长Heixiu” 平台及相关服务应该在其监护人的监督指导下,在合理范围内正确学习使用网络,避免沉迷虚拟的网络空间,养成良好上网习惯。
12.4.2 青少年用户必须遵守《全国青少年网络文明公约》:
1要善于网上学习不浏览不良信息
2要诚实友好交流不侮辱欺诈他人
3要增强自护意识不随意约会网友
4要维护网络安全不破坏网络秩序
5要有益身心健康不沉溺虚拟时空。
12.4.3 为更好的保护未成年人隐私权益未成年人使用“船长Heixiu”平台开通付费会员或其他同等类型服务的应在监护人陪同下操作。公司提醒用户慎重上传包含未成年人素材的内容一经上传即视为用户同意“船长Heixiu”展示未成年人的信息、肖像、声音等且允许“船长Heixiu”依据本协议使用、处理该等与未成年人相关的内容。有此产生的责任由您自行承担。
十三、其他
13.1 本协议的成立、生效、履行、解释及争议的解决均应适用中华人民共和国大陆地区法律。本协议的签署地点为中华人民共和国重庆市,若您与公司发生争议的,双方应尽量友好协商解决,协商不成,您同意应将争议提交至本协议签订地(即重庆渝中区)有管辖权的人民法院管辖。
13.2 公司有权依据国家政策、技术条件、产品功能等变化需要而进行修改本协议公司会将修改后的协议予以发布。前述内容一经正式发布公司将以适当的方式短信、公告、弹窗、系统通知等提醒您更新的内容以便您及时了解本协议的最新版本。修改后的内容将构成本协议不可分割的组成部分您应同样遵守。您对修改后的协议有异议的请立即停止登录、使用“船长Heixiu”平台及相关服务若您登录或继续使用“船长Heixiu”平台及相关服务视为认可修改后的协议。
13.3 您和公司均是独立的主体,在任何情况下本协议不构成公司对用户的任何形式的明示、暗示担保或条件,双方之间亦不构成代理、合伙、合营或雇佣关系。
13.4 本协议中的标题仅为方便及阅读而设,并不影响本协议中任何规定的含义或解释。

View File

@@ -0,0 +1,73 @@
积分充值服务协议
本《积分充值服务协议》(以下简称 "本协议")由您与【重庆森焱启智科技有限责任公司】(以下简称 "我们")共同订立,旨在明确您使用我们提供的积分充值服务时双方的权利和义务。请您在使用积分充值服务前,仔细阅读本协议全部内容。如您对本协议内容有任何疑问,可向我们的客服咨询。当您使用积分充值服务时,即表示您已充分理解并自愿接受本协议的全部内容。
一、定义
1.积分:指由我们为您提供的,用于存储资金、进行交易支付等操作的电子虚拟账户。
2.充值:指您通过我们支持的支付渠道,将资金存入积分账户的行为。
3.支付渠道:包括但不限于第三方支付平台、网上银行、快捷支付等我们与相关合作机构共同提供的支付方式。
二、服务提供方的权利与义务
1.我们有权根据法律法规、监管要求、业务发展等情况,对积分充值服务的功能、支付渠道、充值限额等进行调整,并通过官方网站、短信、邮箱等一种或多种方式通知您。如您继续使用充值服务,即视为接受调整后的内容。
2.我们承诺采取合理的技术和管理措施,保障积分充值服务的正常运行,保护您的资金安全和个人信息安全。但因不可抗力、通信网络故障、第三方支付渠道问题等非我们原因导致的服务中断、资金损失等情况,我们不承担责任,但会尽力协助您解决问题。
3.我们有权对您的充值行为进行监测和管理,如发现异常充值行为(包括但不限于涉嫌洗钱、诈骗、套现等违法违规行为),我们有权暂停或终止为您提供充值服务,并有权采取相应的风险控制措施,包括但不限于冻结您的积分账户资金、限制交易等,并将相关情况报告监管部门。
4.我们应按照约定及时处理您的充值请求,并在充值成功后,及时更新您积分账户的余额信息。
三、用户的权力与义务
1.您有权在遵守本协议及相关法律法规的前提下,使用我们提供的积分充值服务,并在充值成功后,合法合理地使用积分账户内的资金。
2.您应确保提供的注册信息、身份信息等资料真实、准确、完整、有效,并及时更新。如因您提供的信息不真实、不准确、不完整或未及时更新,导致无法使用充值服务或产生其他不利后果,由您自行承担责任。
3.您应妥善保管自己的积分账户信息(包括但不限于账户名、密码、验证码等)及支付工具信息(如银行卡号、支付密码等),不得将其泄露给任何第三方。如因您自身原因导致账户信息或支付工具信息泄露,造成资金损失的,由您自行承担责任。
4.您应按照我们规定的充值流程和要求进行充值操作,并确认充值金额、支付方式等信息无误后提交充值请求。如因您操作失误导致充值错误(如充错账户、重复充值等),我们将根据实际情况协助您处理,但不保证一定能够追回资金或进行退款,相关损失由您自行承担。
5.您承诺使用充值服务进行的交易活动均符合法律法规、社会公德及本协议的约定,不得利用积分充值服务从事任何违法违规、损害他人合法权益或违反公序良俗的行为。如您违反上述规定,我们有权采取相应措施追究您的责任,包括但不限于终止服务、要求您承担赔偿责任等,并将相关情况报告监管部门。
四、充值操作与交易规则
1.充值方式:您可通过我们支持的支付渠道进行充值,不同支付渠道可能有不同的到账时间,具体以充值页面展示的信息为准。
2.充值限额:为保障您的资金安全和防范风险,我们对单笔充值金额、单日充值金额、单月充值金额等设置了限额。如您需要调整充值限额,可按照我们规定的流程申请,但我们有权根据您的账户情况、风险评估结果等决定是否同意您的申请。
3.到账时间:充值资金一般会在您提交充值请求并支付成功后的一定时间内到账,具体到账时间以各支付渠道的实际处理时间为准。如遇网络繁忙、系统维护、支付渠道故障等特殊情况,到账时间可能会延迟。如您的充值资金长时间未到账,请及时联系我们的客服人员进行查询和处理。
4.交易凭证您的充值记录将作为交易凭证您可以在订单明细中查询充值历史记录。如对充值交易有疑问您应在充值交易发生后的7个工作日内我们提出异议并提供相关证明材料。我们将在收到您的异议后进行核实并在规定时间内将处理结果告知您。
五、资金管理与安全
1.您积分账户内的资金属于您所有,我们不会将您的资金用于除本协议约定用途之外的其他任何目的。
2.我们将采取多种安全技术和措施保障您积分账户资金的安全,包括但不限于数据加密、身份认证、风险监控等。但由于互联网存在诸多不确定性,如您发现积分账户存在异常交易或资金被盗用等情况,请立即联系我们的客服人员,并配合我们进行调查和处理。我们将在核实情况后,根据实际情况采取相应措施保障您的资金安全,如冻结账户、协助追回资金等。但如因您自身原因导致资金损失(如未妥善保管账户信息等),我们不承担赔偿责任。
六、协议的变更与终止
1.我们有权根据法律法规变化、业务调整等情况对本协议内容进行变更。变更后的协议将通过官方网站、短信、邮件等一种或多种方式通知您。如您在变更后的协议生效后继续使用积分充值服务,即视为您已充分阅读、理解并接受变更后的协议内容;如您不同意变更后的协议内容,您应立即停止使用积分充值服务。
2.在以下情况下,我们有权单方面终止本协议,并停止为您提供积分充值服务:
您违反本协议约定,从事违法违规、损害他人合法权益或违反公序良俗的行为;
您的账户存在异常交易行为或涉嫌洗钱、诈骗、套现等违法违规风险,经我们调查核实后情况属实;
因不可抗力、法律法规变化、监管要求等原因,导致我们无法继续提供积分充值服务。
3.本协议终止后,我们将停止为您提供充值服务,并根据法律法规规定和本协议约定处理您积分账户内的剩余资金。如您的账户不存在未处理的交易纠纷、违法违规行为等情况,我们将按照您指定的方式退还您账户内的剩余资金;如您的账户存在未处理的交易纠纷、违法违规行为等情况,我们将在纠纷解决或违规行为处理完毕后,再按照上述方式处理剩余资金。
七、争议解决
如您与我们在本协议履行过程中发生争议,应首先通过友好协商解决;协商不成的,任何一方均有权向重庆市渝中区人民法院提起诉讼。
八、其他条款
1.本协议自您使用积分充值服务之日起生效,对您和我们均具有法律约束力。
2.本协议的任何条款被认定为无效或不可执行,不影响其他条款的效力和执行。
3.我们未行使或延迟行使本协议项下的任何权利,不构成对该权利的放弃;我们对您的任何违约行为给予任何宽限或延缓,均不影响我们对您后续违约行为追究责任的权利。
4.为了提升服务质量,缩短充值到账时间,我们特此委托济南煜邦炫盛智能科技有限责任公司代为收取您的充值金额,特此告知。
九、如何联系我们
1.本协议版权为我们所有,在法律允许的范围内,我们拥有解释和修改的权利。
2.如您在使用我们的产品与/或服务时对协议有任何问题、举报或投诉可通过我们的客服及相关负责人邮箱2006410116@dididiketang.com联系我们。

BIN
src/assets/logo/HX.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

BIN
src/assets/logo/HX2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
src/assets/logo/heixiu.icns Normal file

Binary file not shown.

BIN
src/assets/logo/heixiu.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -0,0 +1,61 @@
// ============================================================
// AppProvider — 应用状态 Provider 组件
// ============================================================
import { useState, useCallback, useEffect, type ReactNode } from 'react';
import type { Platform, Edition } from '@shared/types';
import { getPlatform, isDev } from '../utils/platform';
import { parseEdition } from '@shared/constants/app';
import { safeIpcOn, safeIpcOff } from '../utils/ipc';
import { AppContext } from '../contexts/app-context';
import type { AppContextValue } from '../contexts/app-context';
interface AppProviderProps {
children: ReactNode;
}
export function AppProvider({ children }: AppProviderProps) {
const [platform] = useState<Platform>(getPlatform);
const [edition] = useState<Edition>(() => {
return parseEdition(import.meta.env.VITE_EDITION);
});
const [isLoggedIn, setIsLoggedIn] = useState(false);
const login = useCallback(() => {
// TODO: 接入真实登录流程
setIsLoggedIn(true);
}, []);
const logout = useCallback(() => {
// TODO: 清理 token、用户状态等
setIsLoggedIn(false);
}, []);
// 监听主进程推送的平台/版本信息
useEffect(() => {
const handler = (_event: unknown, info: unknown) => {
const data = info as { platform?: string; edition?: string } | undefined;
if (data) {
if (isDev()) {
console.log('[AppContext] 主进程平台信息:', data);
}
}
};
safeIpcOn('platform-info', handler);
return () => {
safeIpcOff('platform-info', handler);
};
}, []);
const value: AppContextValue = {
platform,
edition,
isLoggedIn,
login,
logout,
isDevMode: isDev(),
};
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}

View File

@@ -0,0 +1,72 @@
// ============================================================
// ThemeProvider — 主题 Provider 组件
// ============================================================
import { useState, useEffect, useCallback, type ReactNode } from 'react';
import { ConfigProvider } from 'antd';
import { getThemeConfig } from '../theme/antd-theme';
import {
ThemeContext,
readStoredTheme,
writeStoredTheme,
applyHtmlTheme,
STORAGE_KEY,
} from '../hooks/use-theme';
import type { ThemeContextValue } from '../hooks/use-theme';
interface ThemeProviderProps {
children: ReactNode;
}
export function ThemeProvider({ children }: ThemeProviderProps) {
const [isDark, setIsDark] = useState<boolean>(readStoredTheme);
// 挂载时同步
useEffect(() => {
applyHtmlTheme(isDark);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const toggleTheme = useCallback(() => {
setIsDark((prev) => {
const next = !prev;
writeStoredTheme(next);
applyHtmlTheme(next);
return next;
});
}, []);
const setLight = useCallback(() => {
setIsDark(false);
writeStoredTheme(false);
applyHtmlTheme(false);
}, []);
const setDark = useCallback(() => {
setIsDark(true);
writeStoredTheme(true);
applyHtmlTheme(true);
}, []);
// 监听系统主题变化(仅用户从未手动切换时跟随)
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === null) {
setIsDark(e.matches);
applyHtmlTheme(e.matches);
}
};
mq.addEventListener('change', handleChange);
return () => mq.removeEventListener('change', handleChange);
}, []);
const ctx: ThemeContextValue = { isDark, toggleTheme, setLight, setDark };
return (
<ThemeContext.Provider value={ctx}>
<ConfigProvider theme={getThemeConfig(isDark)}>{children}</ConfigProvider>
</ThemeContext.Provider>
);
}

View File

@@ -0,0 +1,77 @@
// ============================================================
// LegalTextModal — 协议/法律文本展示弹窗
// 从 src/assets/legal/ 加载 .txt 文件,主题适配渲染
// ============================================================
import { Modal, Typography } from 'antd';
import { FileTextOutlined } from '@ant-design/icons';
import { useTheme } from '@/hooks/use-theme';
// Vite ?raw 导入:将 .txt 内容作为字符串直接内联
import serviceAgreement from '@/assets/legal/用户服务协议.txt?raw';
import pointsAgreement from '@/assets/legal/积分充值服务协议.txt?raw';
const { Paragraph } = Typography;
/** 协议名称 → 文本内容映射 */
const legalTexts: Record<string, { title: string; content: string }> = {
service: { title: '用户服务协议', content: serviceAgreement },
points: { title: '积分充值服务协议', content: pointsAgreement },
};
interface LegalTextModalProps {
open: boolean;
/** 协议标识:'service' | 'points' */
type: 'service' | 'points';
onClose: () => void;
}
export function LegalTextModal({ open, type, onClose }: LegalTextModalProps) {
const { isDark } = useTheme();
const legal = legalTexts[type];
if (!legal) return null;
return (
<Modal
open={open}
onCancel={onClose}
footer={null}
width={640}
centered
title={
<span className="flex items-center gap-2">
<FileTextOutlined />
{legal.title}
</span>
}
styles={{
body: {
maxHeight: '60vh',
overflowY: 'auto',
padding: '20px 24px',
background: isDark ? '#1A1730' : '#FFFFFF',
color: isDark ? '#E8E6F0' : '#1E1B4B',
fontSize: 14,
lineHeight: 2,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'var(--font-family-base)',
},
}}
>
<Paragraph
style={{
color: isDark ? '#E8E6F0' : '#1E1B4B',
fontSize: 14,
lineHeight: 2,
whiteSpace: 'pre-wrap',
margin: 0,
}}
>
{legal.content}
</Paragraph>
</Modal>
);
}

View File

@@ -0,0 +1,147 @@
// ============================================================
// AnnouncementDrawer — 公告侧拉抽屉SPA 内部弹出)
// 数据来源GET /api/v1/announcements
// ============================================================
import { useEffect, useState, useCallback } from 'react';
import { Drawer, List, Typography, Empty, Tag, Spin } from 'antd';
import { NotificationOutlined } from '@ant-design/icons';
import { fetchAnnouncements, type Announcement } from '@/services/modules';
const { Text, Paragraph } = Typography;
// ---------- 辅助 ----------
/** 公告类型 → Tag 颜色映射 */
const typeColorMap: Record<string, string> = {
system: 'blue',
feature: 'purple',
activity: 'orange',
maintenance: 'red',
};
/** 公告类型 → 中文标签 */
const typeLabelMap: Record<string, string> = {
system: '系统',
feature: '功能',
activity: '活动',
maintenance: '维护',
};
/** 格式化日期YYYY-MM-DD */
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
} catch {
return iso;
}
}
// ---------- 组件 ----------
interface AnnouncementDrawerProps {
open: boolean;
onClose: () => void;
}
export function AnnouncementDrawer({ open, onClose }: AnnouncementDrawerProps) {
const [list, setList] = useState<Announcement[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 打开抽屉时请求公告列表
const loadAnnouncements = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await fetchAnnouncements();
// 仅展示有效公告
setList(data.filter((item) => item.is_active));
} catch (err) {
setError((err as Error).message || '加载公告失败');
setList([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (open) {
loadAnnouncements();
}
}, [open, loadAnnouncements]);
// ---- 渲染 ----
return (
<Drawer
title={
<span className="flex items-center gap-2">
<NotificationOutlined />
</span>
}
placement="right"
size={380}
open={open}
onClose={onClose}
styles={{ body: { padding: 0 } }}
>
{loading ? (
<div className="flex items-center justify-center h-64">
<Spin description="加载中..." />
</div>
) : error ? (
<div className="flex items-center justify-center h-64">
<Empty description={error} />
</div>
) : list.length === 0 ? (
<div className="flex items-center justify-center h-64">
<Empty description="暂无公告" />
</div>
) : (
<List
dataSource={list}
renderItem={(item) => (
<List.Item className="px-6! py-4!">
<div className="w-full space-y-2">
{/* 标题 + 类型标签 */}
<div className="flex items-center justify-between gap-2">
<Text strong className="text-sm flex-1" ellipsis>
{item.pinned && '📌 '}
{item.title}
</Text>
<Tag
color={typeColorMap[item.type] || 'default'}
className="m-0! text-xs shrink-0"
>
{typeLabelMap[item.type] || item.type}
</Tag>
</div>
{/* 内容 */}
<Paragraph
className="mb-0! text-xs"
type="secondary"
ellipsis={{ rows: 2 }}
>
{item.content}
</Paragraph>
{/* 日期 */}
<Text type="secondary" className="text-xs">
{formatDate(item.created_at)}
</Text>
</div>
</List.Item>
)}
/>
)}
</Drawer>
);
}

View File

@@ -0,0 +1,135 @@
// ============================================================
// NavBar — 自定义顶部导航栏(替代系统菜单)
//
// 逻辑:
// - 所有导航项默认全部显示(不再按登录状态隐藏)
// - 未登录时点击需登录的项 → 弹出提示"请先登录"并触发登录 Modal
// - 登录/注册 与 退出登录 仍按登录状态互斥显示
// ============================================================
import { useState, useCallback, useMemo } from 'react';
import { App } from 'antd';
import { useAppContext } from '@/contexts/app-context';
import { useTheme } from '@/hooks/use-theme';
import { emit, EVENTS } from '@/utils/event-bus.ts';
import { NavItem } from './NavItem';
import { AnnouncementDrawer } from './AnnouncementDrawer';
import { personalLeft, personalRight, enterpriseLeft, enterpriseRight } from './nav-config';
import type { NavItemConfig } from './types';
const NAVBAR_HEIGHT = 56;
export function NavBar() {
const { platform, edition, isLoggedIn, logout } = useAppContext();
const { isDark } = useTheme();
const { message } = App.useApp();
const [drawerOpen, setDrawerOpen] = useState(false);
// 根据版本选择导航项
const { leftItems, rightItems } = useMemo(() => {
const isEnterprise = edition === 'enterprise';
return {
leftItems: isEnterprise ? enterpriseLeft : personalLeft,
rightItems: isEnterprise ? enterpriseRight : personalRight,
};
}, [edition]);
// 过滤规则:
// requireGuest → 仅未登录时可见(登录/注册)
// "退出登录" → 仅已登录时可见
// 其他所有项 → 始终可见,未登录点击时弹"请先登录"
const visibleLeft = useMemo(
() =>
leftItems.filter((item) => {
if (item.requireGuest && isLoggedIn) return false;
return !(item.key === 'logout' && !isLoggedIn);
}),
[leftItems, isLoggedIn],
);
const visibleRight = useMemo(
() =>
rightItems.filter((item) => {
if (item.requireGuest && isLoggedIn) return false;
return !(item.key === 'logout' && !isLoggedIn);
}),
[rightItems, isLoggedIn],
);
// 点击导航项
const handleNavClick = useCallback(
(item: NavItemConfig) => {
// 需要登录但未登录 → 提示并弹出登录 Modal
if (item.requireLogin && !isLoggedIn) {
message.warning('请先登录');
emit(EVENTS.SHOW_LOGIN);
return;
}
switch (item.action) {
case 'drawer':
if (item.key === 'announcements') {
setDrawerOpen(true);
}
break;
case 'button':
if (item.key === 'logout') {
logout();
}
break;
case 'external':
if (item.target) {
window.open(item.target, '_blank');
}
break;
case 'navigate':
case 'spa':
if (item.key === 'auth') {
emit(EVENTS.SHOW_LOGIN);
} else {
console.log('[NavBar] 导航至:', item.target);
}
break;
}
},
[isLoggedIn, logout, message],
);
const macPaddingLeft = platform === 'darwin' ? 80 : 12;
return (
<>
<div
className="navbar-drag-region flex items-center justify-between select-none"
style={{
height: NAVBAR_HEIGHT,
paddingLeft: macPaddingLeft,
paddingRight: 12,
background: isDark
? 'linear-gradient(180deg, #1E1B4B 0%, #1A1730 100%)'
: 'linear-gradient(180deg, #FFFFFF 0%, #F5F3FF 100%)',
borderBottom: `1px solid ${isDark ? '#2E2A4A' : '#E5E7EB'}`,
boxShadow: isDark ? '0 1px 4px rgba(0,0,0,0.3)' : '0 1px 4px rgba(99,102,241,0.06)',
}}
>
<div className="navbar-no-drag flex items-center gap-0.5">
{visibleLeft.map((item) => (
<NavItem key={item.key} item={item} onClick={handleNavClick} />
))}
</div>
<div className="navbar-no-drag flex items-center gap-0.5">
{visibleRight.map((item) => (
<NavItem key={item.key} item={item} onClick={handleNavClick} />
))}
</div>
</div>
<AnnouncementDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
</>
);
}

View File

@@ -0,0 +1,52 @@
// ============================================================
// NavItem — 单个导航按钮
// 布局图标SVG在上文字在下
// ============================================================
import { useTheme } from '@/hooks/use-theme.ts';
import type { NavItemConfig } from './types';
interface NavItemProps {
item: NavItemConfig;
onClick: (item: NavItemConfig) => void;
}
export function NavItem({ item, onClick }: NavItemProps) {
const { isDark } = useTheme();
const isLogout = item.key === 'logout';
return (
<button
type="button"
onClick={() => onClick(item)}
title={item.label}
className="navbar-no-drag flex flex-col items-center justify-center gap-0.5 px-3 py-1 rounded-md
transition-all duration-200 cursor-pointer border-0
hover:brightness-95 active:scale-95"
style={{
background: isLogout ? 'transparent' : 'transparent',
minWidth: 56,
minHeight: 48,
}}
>
{/* SVG 图标 — 内联渲染currentColor 继承父元素 color */}
{item.iconSrc && (
<span
className="w-5 h-5 inline-block opacity-80"
style={{ color: '#6366F1' }}
dangerouslySetInnerHTML={{ __html: item.iconSrc }}
/>
)}
{/* 文字标签 */}
<span
className="text-xs leading-tight whitespace-nowrap select-none"
style={{
color: isLogout ? (isDark ? '#F87171' : '#EF4444') : isDark ? '#E8E6F0' : '#1E1B4B',
}}
>
{item.label}
</span>
</button>
);
}

View File

@@ -0,0 +1,3 @@
export { NavBar } from './NavBar';
export { AnnouncementDrawer } from './AnnouncementDrawer';
export type { NavItemConfig, NavBarConfig, NavAction } from './types';

View File

@@ -0,0 +1,198 @@
// ============================================================
// 导航栏配置 —— 按版本(个人版 / 企业版)定义导航项
// 每个导航项的 iconSrc 指向 src/assets/header_actions/ 下的 SVG 图标
// ============================================================
import type { NavItemConfig } from './types';
// ---------- SVG 图标导入(?raw = 内联渲染,使 currentColor 生效)----------
import complianceAssetsSvg from '@/assets/header_actions/compliance_assets.svg?raw';
import announcementsSvg from '@/assets/header_actions/announcements.svg?raw';
import accountSvg from '@/assets/header_actions/account.svg?raw';
import membershipSvg from '@/assets/header_actions/membership.svg?raw';
import rechargeSvg from '@/assets/header_actions/recharge.svg?raw';
import billingSvg from '@/assets/header_actions/billing.svg?raw';
import orderDetailsSvg from '@/assets/header_actions/order_details.svg?raw';
import wechatWorkSvg from '@/assets/header_actions/wechat_work.svg?raw';
import settingsSvg from '@/assets/header_actions/settings.svg?raw';
import signOutSvg from '@/assets/header_actions/sign_out.svg?raw';
import projectManagementSvg from '@/assets/header_actions/project_management.svg?raw';
// ============================================================
// 个人版导航项
// ============================================================
/** 个人版 — 左侧 */
const personalLeft: NavItemConfig[] = [
{
key: 'compliance-assets',
label: '合规素材库',
iconSrc: complianceAssetsSvg,
action: 'navigate',
target: '/compliance-assets',
position: 'left',
},
];
/** 个人版 — 右侧(从左至右排列) */
const personalRight: NavItemConfig[] = [
{
key: 'announcements',
label: '公告',
iconSrc: announcementsSvg,
action: 'drawer',
position: 'right',
},
{
key: 'account',
label: '查帐户',
iconSrc: accountSvg,
action: 'navigate',
target: '/account',
position: 'right',
requireLogin: true,
},
{
key: 'vip-activate',
label: '开会员/激活',
iconSrc: membershipSvg,
action: 'navigate',
target: '/vip',
position: 'right',
},
{
key: 'recharge-points',
label: '充值积分',
iconSrc: rechargeSvg,
action: 'navigate',
target: '/recharge',
position: 'right',
requireLogin: true,
},
{
key: 'billing-history',
label: '消费记录',
iconSrc: billingSvg,
action: 'navigate',
target: '/billing',
position: 'right',
requireLogin: true,
},
{
key: 'order-details',
label: '订单明细',
iconSrc: orderDetailsSvg,
action: 'navigate',
target: '/orders',
position: 'right',
requireLogin: true,
},
{
key: 'wechat-work',
label: '企业微信',
iconSrc: wechatWorkSvg,
action: 'external',
target: 'https://work.weixin.qq.com/',
position: 'right',
},
{
key: 'settings',
label: '设置',
iconSrc: settingsSvg,
action: 'navigate',
target: '/settings',
position: 'right',
},
{
key: 'auth',
label: '登录/注册',
iconSrc: accountSvg,
action: 'navigate',
target: '/login',
position: 'right',
requireGuest: true,
},
{
key: 'logout',
label: '退出登录',
iconSrc: signOutSvg,
action: 'button',
position: 'right',
requireLogin: true,
},
];
// ============================================================
// 企业版导航项
// ============================================================
/** 企业版 — 左侧 */
const enterpriseLeft: NavItemConfig[] = [
{
key: 'compliance-assets',
label: '合规素材库',
iconSrc: complianceAssetsSvg,
action: 'navigate',
target: '/compliance-assets',
position: 'left',
},
{
key: 'project-management',
label: '项目管理',
iconSrc: projectManagementSvg,
action: 'navigate',
target: '/projects',
position: 'left',
},
];
/** 企业版 — 右侧(从左至右排列) */
const enterpriseRight: NavItemConfig[] = [
{
key: 'announcements',
label: '公告',
iconSrc: announcementsSvg,
action: 'drawer',
position: 'right',
},
{
key: 'quota-check',
label: '查配额',
iconSrc: accountSvg,
action: 'navigate',
target: '/quota',
position: 'right',
requireLogin: false,
},
{
key: 'settings',
label: '设置',
iconSrc: settingsSvg,
action: 'navigate',
target: '/settings',
position: 'right',
},
{
key: 'auth',
label: '登录/注册',
iconSrc: accountSvg,
action: 'navigate',
target: '/login',
position: 'right',
requireGuest: true,
},
{
key: 'logout',
label: '退出登录',
iconSrc: signOutSvg,
action: 'button',
position: 'right',
requireLogin: true,
},
];
// ============================================================
// 导出
// ============================================================
export { personalLeft, personalRight, enterpriseLeft, enterpriseRight };

View File

@@ -0,0 +1,41 @@
// ============================================================
// 导航栏类型定义
// ============================================================
import type { Edition } from '@shared/types';
/** 导航项的行为类型 */
export type NavAction =
| 'navigate' // 新开窗口
| 'spa' // 单页应用内导航(当前窗口)
| 'drawer' // 侧拉抽屉(公告等)
| 'button' // 纯按钮行为(退出登录)
| 'external'; // 外部链接
/** 单个导航项配置 */
export interface NavItemConfig {
/** 唯一标识 */
key: string;
/** 显示文字 */
label: string;
/** SVG 图标资源路径Vite import 后的 URL */
iconSrc?: string;
/** 行为类型 */
action: NavAction;
/** 点击回调 / 路由路径 / 外部 URL取决于 action */
target?: string;
/** 仅在指定版本下显示 */
edition?: Edition | Edition[];
/** 仅已登录时显示 */
requireLogin?: boolean;
/** 仅未登录时显示 */
requireGuest?: boolean;
/** 排列位置 */
position: 'left' | 'right';
}
/** 导航栏配置(按版本分组) */
export interface NavBarConfig {
left: NavItemConfig[];
right: NavItemConfig[];
}

View File

@@ -0,0 +1,35 @@
// ============================================================
// AppContext — 全局应用状态
// ============================================================
import { createContext, useContext } from 'react';
import type { Platform, Edition } from '@shared/types';
// ---------- Context 类型 ----------
export interface AppContextValue {
/** 当前操作系统平台 */
platform: Platform;
/** 产品版本(个人版 / 企业版) */
edition: Edition;
/** 用户是否已登录 */
isLoggedIn: boolean;
/** 登录TODO: 接入真实认证) */
login: () => void;
/** 退出登录 */
logout: () => void;
/** 是否为开发环境 */
isDevMode: boolean;
}
export const AppContext = createContext<AppContextValue | null>(null);
// ---------- Consumer Hook ----------
export function useAppContext(): AppContextValue {
const ctx = useContext(AppContext);
if (!ctx) {
throw new Error('useAppContext() 必须在 <AppProvider> 内部调用');
}
return ctx;
}

61
src/hooks/use-theme.ts Normal file
View File

@@ -0,0 +1,61 @@
// ============================================================
// useTheme — 亮/暗主题切换 Hook
// ============================================================
import { createContext, useContext } from 'react';
// ---------- Context 类型 ----------
export interface ThemeContextValue {
isDark: boolean;
toggleTheme: () => void;
setLight: () => void;
setDark: () => void;
}
export const ThemeContext = createContext<ThemeContextValue | null>(null);
// ---------- 工具函数 ----------
export const STORAGE_KEY = 'ele-heixiu-theme';
export function readStoredTheme(): boolean {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'dark') return true;
if (stored === 'light') return false;
} catch {
/* 静默忽略 */
}
if (typeof window !== 'undefined' && window.matchMedia) {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
return false;
}
export function writeStoredTheme(isDark: boolean): void {
try {
localStorage.setItem(STORAGE_KEY, isDark ? 'dark' : 'light');
} catch {
/* 静默忽略 */
}
}
export function applyHtmlTheme(isDark: boolean): void {
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
// ---------- Consumer Hook ----------
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) {
throw new Error('useTheme() 必须在 <ThemeProvider> 内部调用');
}
return ctx;
}

136
src/hooks/use-updater.ts Normal file
View File

@@ -0,0 +1,136 @@
// ============================================================
// useUpdater — 渲染进程中的更新状态管理
//
// 用法:
// const { status, progress, checkForUpdates, installUpdate } = useUpdater();
//
// 状态机idle → checking → (no-update | available → downloading → downloaded → idle)
// ============================================================
import { useState, useEffect, useCallback, useRef } from 'react';
import { safeIpcInvoke, safeIpcOn, safeIpcOff, safeIpcSend } from '@/utils/ipc';
// ---------- 类型 ----------
export type UpdaterStatus =
| 'idle' // 空闲
| 'checking' // 正在检查
| 'no-update' // 已是最新
| 'available' // 发现新版本,准备下载
| 'downloading' // 下载中
| 'downloaded' // 下载完成,待安装
| 'error'; // 出错
export interface UpdateInfo {
version: string;
releaseDate?: string;
releaseNotes?: string;
forceUpdate?: boolean;
}
export interface UpdateProgress {
percent: number;
bytesPerSecond?: number;
transferred?: number;
total?: number;
}
export interface UpdateError {
message: string;
}
// ---------- IPC 通道名(与主进程 updater.ts 保持一致)----------
const CH = {
UPDATE_AVAILABLE: 'update-available',
UPDATE_PROGRESS: 'update-progress',
UPDATE_DOWNLOADED: 'update-downloaded',
UPDATE_ERROR: 'update-error',
UPDATE_NOT_AVAILABLE: 'update-not-available',
INSTALL_UPDATE: 'install-update',
CHECK_FOR_UPDATE: 'check-for-update',
} as const;
// ---------- Hook ----------
export function useUpdater() {
const [status, setStatus] = useState<UpdaterStatus>('idle');
const [info, setInfo] = useState<UpdateInfo | null>(null);
const [progress, setProgress] = useState<UpdateProgress>({ percent: 0 });
const [error, setError] = useState<UpdateError | null>(null);
// 防止 StrictMode 双重监听
const registered = useRef(false);
useEffect(() => {
if (registered.current) return;
registered.current = true;
safeIpcOn(CH.UPDATE_AVAILABLE, (_event: unknown, ...args: unknown[]) => {
setInfo(args[0] as UpdateInfo);
setStatus('available');
});
safeIpcOn(CH.UPDATE_PROGRESS, (_event: unknown, ...args: unknown[]) => {
setStatus('downloading');
setProgress(args[0] as UpdateProgress);
});
safeIpcOn(CH.UPDATE_DOWNLOADED, () => {
setStatus('downloaded');
});
safeIpcOn(CH.UPDATE_ERROR, (_event: unknown, ...args: unknown[]) => {
setError(args[0] as UpdateError);
setStatus('error');
});
safeIpcOn(CH.UPDATE_NOT_AVAILABLE, () => {
setStatus('no-update');
});
return () => {
safeIpcOff(CH.UPDATE_AVAILABLE, () => {});
safeIpcOff(CH.UPDATE_PROGRESS, () => {});
safeIpcOff(CH.UPDATE_DOWNLOADED, () => {});
safeIpcOff(CH.UPDATE_ERROR, () => {});
safeIpcOff(CH.UPDATE_NOT_AVAILABLE, () => {});
};
}, []);
/** 手动检查更新 */
const checkForUpdates = useCallback(async () => {
setStatus('checking');
setError(null);
try {
await safeIpcInvoke(CH.CHECK_FOR_UPDATE);
} catch (err) {
setStatus('error');
setError({ message: (err as Error).message || '检查更新失败' });
}
}, []);
/** 确认安装更新(退出 → 安装 → 重启) */
const installUpdate = useCallback(() => {
safeIpcSend(CH.INSTALL_UPDATE);
}, []);
/** 重置状态 */
const reset = useCallback(() => {
setStatus('idle');
setInfo(null);
setProgress({ percent: 0 });
setError(null);
}, []);
return {
status,
info,
progress,
error,
checkForUpdates,
installUpdate,
reset,
} as const;
}

30
src/main.tsx Normal file
View File

@@ -0,0 +1,30 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App as AntdApp } from 'antd';
import App from './App';
import { ThemeProvider } from './components/ThemeProvider';
import { AppProvider } from './components/AppProvider';
// 全局样式(包含 Tailwind 指令 + 主题扩展 + 重置)
import './theme/globals.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
{/* AppProvider — 全局应用状态(平台/版本/登录态) */}
<AppProvider>
{/* ThemeProvider 内部管理 ConfigProvider亮色/暗色切换) */}
<ThemeProvider>
{/* AntdApp 提供 message / notification / modal 的静态方法上下文 */}
<AntdApp>
<App />
</AntdApp>
</ThemeProvider>
</AppProvider>
</React.StrictMode>,
);
// 仅在 Electron 环境中监听主进程消息(浏览器预览时跳过)
import { safeIpcOn } from './utils/ipc';
safeIpcOn('main-process-message', (_event: unknown, message: unknown) => {
console.log('[Main Process Message]:', message);
});

View File

@@ -0,0 +1,92 @@
// ============================================================
// FormField — 单个表单字段(数据驱动渲染)
// 根据 FieldConfig 自动选择 Input / Input.Password 及附加操作按钮
// ============================================================
import { useCallback } from 'react';
import { Input, Button, Form } from 'antd';
import type { FieldConfig } from '../types';
interface FormFieldProps {
config: FieldConfig;
value: string;
onChange: (name: string, value: string) => void;
errors: string[];
/** 发送短信验证码回调 */
onSendSms?: () => void;
/** 验证码按钮冷却中 */
smsCooldown?: boolean;
/** 冷却剩余秒数 */
smsSecondsLeft?: number;
}
export function FormField({
config,
value,
onChange,
errors,
onSendSms,
smsCooldown,
smsSecondsLeft,
}: FormFieldProps) {
const hasError = errors.length > 0;
const isPassword = config.inputType === 'password';
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChange(config.name, e.target.value);
},
[config.name, onChange],
);
return (
<Form.Item
label={
<span className="text-xs">
{config.label}
{config.optional && <span className="text-gray-400 ml-1">()</span>}
</span>
}
validateStatus={hasError ? 'error' : undefined}
help={hasError ? errors[0] : undefined}
className="!mb-3"
>
{isPassword ? (
<Input.Password
value={value}
onChange={handleChange}
placeholder={config.placeholder}
size="middle"
allowClear
/>
) : config.extraAction === 'send-sms' ? (
<div className="flex gap-2">
<Input
value={value}
onChange={handleChange}
placeholder={config.placeholder}
size="middle"
className="flex-1"
allowClear
/>
<Button
size="middle"
disabled={smsCooldown}
onClick={onSendSms}
className="shrink-0 text-xs"
>
{smsCooldown ? `${smsSecondsLeft}s` : '发送验证码'}
</Button>
</div>
) : (
<Input
value={value}
onChange={handleChange}
placeholder={config.placeholder}
size="middle"
allowClear
/>
)}
</Form.Item>
);
}

View File

@@ -0,0 +1,216 @@
// ============================================================
// LoginForm — 登录表单
// ============================================================
import { useState, useCallback } from 'react';
import { Input, Button, Checkbox, Typography } from 'antd';
import { UserOutlined, LockOutlined } from '@ant-design/icons';
import { loginFields } from '../config/login-fields';
import { validateForm } from '../config/validators';
import { LegalTextModal } from '@/components/common/LegalTextModal';
import type { LoginFormValues } from '../types';
const { Text } = Typography;
interface LoginFormProps {
initialUsername?: string;
initialPassword?: string;
initialRemember?: boolean;
initialAutoLogin?: boolean;
submitting?: boolean;
onSubmit: (values: LoginFormValues) => void;
onRememberChange: (checked: boolean) => void;
onAutoLoginChange: (checked: boolean) => void;
}
export function LoginForm({
initialUsername,
initialPassword,
initialRemember = false,
initialAutoLogin = false,
submitting = false,
onSubmit,
onRememberChange,
onAutoLoginChange,
}: LoginFormProps) {
const [username, setUsername] = useState(initialUsername || '');
const [password, setPassword] = useState(initialPassword || '');
const [remember, setRemember] = useState(initialRemember);
const [autoLogin, setAutoLogin] = useState(initialAutoLogin);
const [agreed, setAgreed] = useState(false);
const [errors, setErrors] = useState<Record<string, string[]>>({});
// 协议弹窗
const [legalType, setLegalType] = useState<'service' | 'points' | null>(null);
const clearError = (name: string) =>
setErrors((p) => {
const n = { ...p };
delete n[name];
return n;
});
const handleSubmit = useCallback(() => {
const values = { username, password };
const fieldErrors = validateForm(values, loginFields);
if (Object.keys(fieldErrors).length > 0) {
setErrors(fieldErrors);
return;
}
if (!agreed) {
setErrors({ _agreement: ['请先阅读并同意用户协议'] });
return;
}
setErrors({});
onSubmit({ username, password, remember, autoLogin: remember && autoLogin });
}, [username, password, remember, autoLogin, agreed, onSubmit]);
return (
<>
<div className="space-y-5">
{/* 用户名 */}
<div>
<Input
size="large"
prefix={<UserOutlined className="opacity-40" />}
placeholder="用户名"
value={username}
onChange={(e) => {
setUsername(e.target.value);
clearError('username');
}}
status={errors.username ? 'error' : undefined}
allowClear
/>
{errors.username && (
<Text type="danger" className="text-xs ml-1">
{errors.username[0]}
</Text>
)}
</div>
{/* 密码 */}
<div>
<Input.Password
size="large"
prefix={<LockOutlined className="opacity-40" />}
placeholder="密码"
value={password}
onChange={(e) => {
setPassword(e.target.value);
clearError('password');
}}
status={errors.password ? 'error' : undefined}
allowClear
/>
{errors.password && (
<Text type="danger" className="text-xs ml-1">
{errors.password[0]}
</Text>
)}
</div>
{/* 记住密码 / 自动登录 / 忘记密码 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<Checkbox
checked={remember}
onChange={(e) => {
const checked = e.target.checked;
setRemember(checked);
onRememberChange(checked);
// 取消记住密码 → 同时取消自动登录
if (!checked) {
setAutoLogin(false);
onAutoLoginChange(false);
}
}}
className="text-xs"
>
</Checkbox>
<Checkbox
checked={autoLogin}
onChange={(e) => {
const checked = e.target.checked;
setAutoLogin(checked);
onAutoLoginChange(checked);
// 勾选自动登录 → 同时勾选记住密码
if (checked && !remember) {
setRemember(true);
onRememberChange(true);
}
}}
className="text-xs"
>
</Checkbox>
</div>
<a
className="text-xs"
onClick={() => {
/* TODO: 忘记密码流程 */
}}
>
?
</a>
</div>
{/* 用户协议 */}
<div>
<Checkbox
checked={agreed}
onChange={(e) => {
setAgreed(e.target.checked);
clearError('_agreement');
}}
className="text-xs"
>
<a
className="text-xs mx-0.5"
onClick={(e) => {
e.stopPropagation();
setLegalType('service');
}}
>
</a>
<a
className="text-xs mx-0.5"
onClick={(e) => {
e.stopPropagation();
setLegalType('points');
}}
>
</a>
</Checkbox>
{errors._agreement && (
<Text type="danger" className="text-xs block mt-1">
{errors._agreement[0]}
</Text>
)}
</div>
{/* 登录按钮 */}
<Button
type="primary"
block
size="large"
icon={<UserOutlined />}
loading={submitting}
onClick={handleSubmit}
className="h-11! text-base! font-medium! mt-2!"
>
</Button>
</div>
{/* 协议弹窗 */}
{legalType && <LegalTextModal open type={legalType} onClose={() => setLegalType(null)} />}
</>
);
}

View File

@@ -0,0 +1,258 @@
// ============================================================
// RegisterForm — 注册表单(仅个人版)
// ============================================================
import { useState, useCallback, useRef } from 'react';
import { Input, Button, Checkbox, Typography } from 'antd';
import { LegalTextModal } from '@/components/common/LegalTextModal';
import {
UserOutlined,
LockOutlined,
PhoneOutlined,
SafetyCertificateOutlined,
LaptopOutlined,
TagOutlined,
SmileOutlined,
GiftOutlined,
} from '@ant-design/icons';
import { registerFields } from '../config/register-fields';
import { validateForm } from '../config/validators';
import type { RegisterFormValues } from '../types';
const { Text } = Typography;
interface RegisterFormProps {
submitting?: boolean;
onSubmit: (values: RegisterFormValues) => void;
onSendSms?: (phone: string) => void;
}
/** 字段名 → 图标映射 */
const iconMap: Record<string, React.ReactNode> = {
username: <UserOutlined className="opacity-40" />,
password: <LockOutlined className="opacity-40" />,
confirmPassword: <LockOutlined className="opacity-40" />,
phone: <PhoneOutlined className="opacity-40" />,
sms_code: <SafetyCertificateOutlined className="opacity-40" />,
device_id: <LaptopOutlined className="opacity-40" />,
device_label: <TagOutlined className="opacity-40" />,
display_name: <SmileOutlined className="opacity-40" />,
referral_code: <GiftOutlined className="opacity-40" />,
};
export function RegisterForm({ submitting = false, onSubmit, onSendSms }: RegisterFormProps) {
const [values, setValues] = useState<Record<string, string>>({});
const [agreed, setAgreed] = useState(false);
const [errors, setErrors] = useState<Record<string, string[]>>({});
const [legalType, setLegalType] = useState<'service' | 'points' | null>(null);
// 验证码冷却
const [smsCooldown, setSmsCooldown] = useState(false);
const [smsSeconds, setSmsSeconds] = useState(0);
const smsTimer = useRef<ReturnType<typeof setInterval> | null>(null);
// 字段值变更
const handleChange = useCallback((name: string, value: string) => {
setValues((p) => ({ ...p, [name]: value }));
setErrors((p) => {
const n = { ...p };
delete n[name];
return n;
});
}, []);
// 发送验证码
const handleSendSms = useCallback(() => {
const phone = values.phone || '';
const phoneConfig = registerFields.find((f) => f.name === 'phone')!;
const phoneErrors = validateForm({ phone }, [phoneConfig]);
if (Object.keys(phoneErrors).length > 0) {
setErrors((p) => ({ ...p, phone: phoneErrors.phone }));
return;
}
setSmsCooldown(true);
setSmsSeconds(60);
smsTimer.current = setInterval(() => {
setSmsSeconds((prev) => {
if (prev <= 1) {
clearInterval(smsTimer.current!);
setSmsCooldown(false);
return 0;
}
return prev - 1;
});
}, 1000);
onSendSms?.(phone);
}, [values.phone, onSendSms]);
// 跨字段校验
const crossValidators = {
confirmPassword: (val: string) => {
if (!val) return '请再次输入密码';
if (val !== values.password) return '两次密码不一致';
return null;
},
};
// 提交
const handleSubmit = useCallback(() => {
const fieldErrors = validateForm(values, registerFields, crossValidators);
if (Object.keys(fieldErrors).length > 0) {
setErrors(fieldErrors);
return;
}
if (!agreed) {
setErrors({ _agreement: ['请先阅读并同意用户协议'] });
return;
}
setErrors({});
onSubmit(values as unknown as RegisterFormValues);
}, [values, agreed, onSubmit]);
// 渲染单个字段
const renderField = (field: (typeof registerFields)[number]) => {
const val = values[field.name] || '';
const err = errors[field.name];
const isPassword = field.inputType === 'password';
const hasSms = field.extraAction === 'send-sms';
return (
<div key={field.name}>
{hasSms ? (
<div className="flex gap-2">
<Input
size="large"
prefix={iconMap[field.name]}
placeholder={field.placeholder}
value={val}
onChange={(e) => handleChange(field.name, e.target.value)}
status={err ? 'error' : undefined}
className="flex-1"
allowClear
/>
<Button
size="large"
type="primary"
disabled={smsCooldown}
onClick={handleSendSms}
className="text-xs! shrink-0 px-3!"
>
{smsCooldown ? `${smsSeconds}s` : '发送验证码'}
</Button>
</div>
) : isPassword ? (
<Input.Password
size="large"
prefix={iconMap[field.name]}
placeholder={field.placeholder}
value={val}
onChange={(e) => handleChange(field.name, e.target.value)}
status={err ? 'error' : undefined}
allowClear
/>
) : (
<Input
size="large"
prefix={iconMap[field.name]}
placeholder={field.placeholder}
value={val}
onChange={(e) => handleChange(field.name, e.target.value)}
status={err ? 'error' : undefined}
allowClear
/>
)}
{err && (
<Text type="danger" className="text-xs ml-1">
{err[0]}
</Text>
)}
</div>
);
};
const requiredFields = registerFields.filter((f) => !f.optional);
const optionalFields = registerFields.filter((f) => f.optional);
return (
<>
<div className="space-y-4">
{/* 必填字段 */}
{requiredFields.map(renderField)}
{/* 选填字段 */}
{optionalFields.length > 0 && (
<>
<Text type="secondary" className="text-xs block mt-5! mb-1!">
</Text>
{optionalFields.map(renderField)}
</>
)}
{/* 邀请码说明 */}
<Text type="secondary" className="text-xs! block mt-1!">
</Text>
{/* 用户协议 */}
<div className="mt-5!">
<Checkbox
checked={agreed}
onChange={(e) => {
setAgreed(e.target.checked);
setErrors((p) => {
const n = { ...p };
delete n._agreement;
return n;
});
}}
className="text-xs"
>
<a
className="text-xs mx-0.5"
onClick={(e) => {
e.stopPropagation();
setLegalType('service');
}}
>
</a>
<a
className="text-xs mx-0.5"
onClick={(e) => {
e.stopPropagation();
setLegalType('points');
}}
>
</a>
</Checkbox>
{errors._agreement && (
<Text type="danger" className="text-xs block mt-1">
{errors._agreement[0]}
</Text>
)}
</div>
{/* 注册按钮 */}
<Button
type="primary"
block
size="large"
loading={submitting}
onClick={handleSubmit}
className="h-11! text-base! font-medium! mt-2!"
>
</Button>
</div>
{/* 协议弹窗 */}
{legalType && <LegalTextModal open type={legalType} onClose={() => setLegalType(null)} />}
</>
);
}

View File

@@ -0,0 +1,40 @@
// ============================================================
// 登录表单 — 字段配置(纯数据,驱动 UI 渲染)
// 修改校验规则或字段属性只需改此文件,无需动 UI 代码
// ============================================================
import type { FieldConfig } from '../types';
export const loginFields: FieldConfig[] = [
{
name: 'username',
label: '用户名',
placeholder: '请输入用户名',
inputType: 'text',
rules: [
{ required: true, message: '请输入用户名' },
{ minLen: 3, message: '用户名至少 3 位' },
{ maxLen: 64, message: '用户名最多 64 位' },
{ pattern: /^[a-zA-Z0-9\-_.]+$/, message: '仅允许字母、数字、-、_、.' },
],
},
{
name: 'password',
label: '密码',
placeholder: '请输入密码',
inputType: 'password',
rules: [
{ required: true, message: '请输入密码' },
{ minLen: 6, message: '密码至少 6 位' },
{ maxLen: 128, message: '密码最多 128 位' },
],
},
];
/** 登录表单初始值 */
export const loginInitialValues = {
username: '',
password: '',
remember: false,
autoLogin: false,
};

View File

@@ -0,0 +1,116 @@
// ============================================================
// 注册表单 — 字段配置(纯数据,驱动 UI 渲染)
// 修改校验规则或字段属性只需改此文件,无需动 UI 代码
// ============================================================
import type { FieldConfig } from '../types';
export const registerFields: FieldConfig[] = [
{
name: 'username',
label: '用户名',
placeholder: '3~64位字母/数字/-/_/.',
inputType: 'text',
rules: [
{ required: true, message: '请输入用户名' },
{ minLen: 3, message: '用户名至少 3 位' },
{ maxLen: 64, message: '用户名最多 64 位' },
{ pattern: /^[a-zA-Z0-9\-_.]+$/, message: '仅允许字母、数字、-、_、.' },
],
},
{
name: 'password',
label: '密码',
placeholder: '6~128位',
inputType: 'password',
rules: [
{ required: true, message: '请输入密码' },
{ minLen: 6, message: '密码至少 6 位' },
{ maxLen: 128, message: '密码最多 128 位' },
],
},
{
name: 'confirmPassword',
label: '确认密码',
placeholder: '请再次输入密码',
inputType: 'password',
rules: [{ required: true, message: '请再次输入密码' }],
},
{
name: 'phone',
label: '手机号',
placeholder: '请输入手机号',
inputType: 'tel',
rules: [
{ required: true, message: '请输入手机号' },
{ minLen: 5, message: '手机号至少 5 位' },
{ maxLen: 32, message: '手机号最多 32 位' },
{ pattern: /^\+?[0-9]+$/, message: '手机号格式不正确' },
],
},
{
name: 'sms_code',
label: '短信验证码',
placeholder: '请输入验证码',
inputType: 'text',
rules: [
{ required: true, message: '请输入验证码' },
{ minLen: 4, message: '验证码至少 4 位' },
{ maxLen: 8, message: '验证码最多 8 位' },
{ pattern: /^[0-9]+$/, message: '验证码仅允许数字' },
],
extraAction: 'send-sms',
cooldown: 60,
},
{
name: 'device_id',
label: '设备标识',
placeholder: '网卡MAC地址',
inputType: 'text',
rules: [
{ required: true, message: '请输入设备标识' },
{ minLen: 8, message: '设备标识至少 8 位' },
{ maxLen: 128, message: '设备标识最多 128 位' },
],
},
{
name: 'device_label',
label: '设备标签',
placeholder: '如"办公电脑"(可选)',
inputType: 'text',
optional: true,
rules: [{ maxLen: 64, message: '设备标签最多 64 位' }],
},
{
name: 'display_name',
label: '个人昵称',
placeholder: '如何称呼您(可选)',
inputType: 'text',
optional: true,
rules: [{ maxLen: 32, message: '昵称最多 32 位' }],
},
{
name: 'referral_code',
label: '邀请码',
placeholder: '如有邀请码请填写(可选)',
inputType: 'text',
optional: true,
rules: [
{ maxLen: 32, message: '邀请码最多 32 位' },
{ pattern: /^[a-zA-Z0-9]*$/, message: '仅允许字母和数字' },
],
},
];
/** 注册表单初始值 */
export const registerInitialValues = {
username: '',
password: '',
confirmPassword: '',
phone: '',
sms_code: '',
device_id: '',
device_label: '',
display_name: '',
referral_code: '',
};

View File

@@ -0,0 +1,136 @@
// ============================================================
// 校验函数 — 纯函数,与 UI 层解耦
// 每个校验器返回 string错误信息或 null通过
// ============================================================
type ValidateResult = string | null;
/** 用户名3~64 位,仅允许字母、数字、-、_、. */
export function validateUsername(value: string): ValidateResult {
if (!value || !value.trim()) return '请输入用户名';
if (value.length < 3) return '用户名至少 3 位';
if (value.length > 64) return '用户名最多 64 位';
if (!/^[a-zA-Z0-9\-_.]+$/.test(value)) return '仅允许字母、数字、-、_、.';
return null;
}
/** 密码6~128 位 */
export function validatePassword(value: string): ValidateResult {
if (!value) return '请输入密码';
if (value.length < 6) return '密码至少 6 位';
if (value.length > 128) return '密码最多 128 位';
return null;
}
/** 确认密码:与密码一致 */
export function validateConfirmPassword(value: string, password: string): ValidateResult {
if (!value) return '请再次输入密码';
if (value !== password) return '两次输入的密码不一致';
return null;
}
/** 手机号5~32 位数字(可含 + 前缀) */
export function validatePhone(value: string): ValidateResult {
if (!value) return '请输入手机号';
if (value.length < 5) return '手机号至少 5 位';
if (value.length > 32) return '手机号最多 32 位';
if (!/^\+?[0-9]+$/.test(value)) return '手机号格式不正确';
return null;
}
/** 短信验证码4~8 位数字 */
export function validateSmsCode(value: string): ValidateResult {
if (!value) return '请输入验证码';
if (value.length < 4) return '验证码至少 4 位';
if (value.length > 8) return '验证码最多 8 位';
if (!/^[0-9]+$/.test(value)) return '验证码仅允许数字';
return null;
}
/** 设备 ID8~128 位 */
export function validateDeviceId(value: string): ValidateResult {
if (!value) return '请输入设备标识';
if (value.length < 8) return '设备标识至少 8 位';
if (value.length > 128) return '设备标识最多 128 位';
return null;
}
/** 可选字段:非空时不超过 maxLen */
export function optionalMaxLen(maxLen: number, label: string) {
return (value: string): ValidateResult => {
if (!value) return null;
if (value.length > maxLen) return `${label}最多 ${maxLen}`;
return null;
};
}
/** 邀请码:可选,最多 32 位字母数字 */
export function validateReferralCode(value: string): ValidateResult {
if (!value) return null;
if (value.length > 32) return '邀请码最多 32 位';
if (!/^[a-zA-Z0-9]+$/.test(value)) return '仅允许字母和数字';
return null;
}
/**
* 对一条字段配置执行所有规则校验
* @returns 错误信息数组(空数组表示通过)
*/
export function validateField(value: string, rules: import('../types').FieldRule[]): string[] {
const errors: string[] = [];
for (const rule of rules) {
// required
if (rule.required && (!value || !value.trim())) {
errors.push(rule.message || '此字段为必填');
continue;
}
if (!value) continue; // 非必填且为空 → 跳过后续校验
// minLen
if (rule.minLen && value.length < rule.minLen) {
errors.push(rule.message || `至少 ${rule.minLen}`);
}
// maxLen
if (rule.maxLen && value.length > rule.maxLen) {
errors.push(rule.message || `最多 ${rule.maxLen}`);
}
// pattern
if (rule.pattern && !rule.pattern.test(value)) {
errors.push(rule.message || '格式不正确');
}
}
return errors;
}
/**
* 校验整个表单
* @returns 错误映射 { fieldName: errorMsg[] },空对象表示全部通过
*/
export function validateForm<T extends Record<string, string>>(
values: T,
fieldConfigs: import('../types').FieldConfig[],
crossValidators?: Record<string, (value: string, allValues: T) => string | null>,
): Record<string, string[]> {
const errors: Record<string, string[]> = {};
for (const config of fieldConfigs) {
const value = values[config.name] || '';
const fieldErrors = validateField(value, config.rules);
if (fieldErrors.length > 0) {
errors[config.name] = fieldErrors;
}
}
// 跨字段校验(如确认密码)
if (crossValidators) {
for (const [name, fn] of Object.entries(crossValidators)) {
const value = values[name] || '';
const err = fn(value, values);
if (err) {
errors[name] = [...(errors[name] || []), err];
}
}
}
return errors;
}

View File

@@ -0,0 +1,131 @@
// ============================================================
// useAuthState — 记住密码 / 自动登录 状态管理
// 持久化到 localStorage下次打开页面自动回填
// ============================================================
import { useState, useCallback } from 'react';
const STORAGE_KEY = 'ele-heixiu-auth';
interface StoredAuth {
/** 上次登录的用户名 */
username: string;
/** 加密后的密码Base64 编码,非安全加密,仅方便回填) */
password: string;
/** 是否记住密码 */
remember: boolean;
/** 是否自动登录 */
autoLogin: boolean;
}
/** 简单 Base64 编解码(非安全加密,仅避免明文存储) */
function encode(str: string): string {
try {
return btoa(decodeURI(encodeURIComponent(str)));
} catch {
return '';
}
}
function decode(str: string): string {
try {
return decodeURIComponent(decodeURI(atob(str)));
} catch {
return '';
}
}
/** 从 localStorage 读取已保存的认证信息 */
function readAuth(): StoredAuth | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw) as StoredAuth;
} catch {
return null;
}
}
/** 写入 localStorage */
function writeAuth(auth: StoredAuth): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(auth));
} catch {
/* 静默 */
}
}
/** 清除 localStorage */
function clearAuth(): void {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
/* 静默 */
}
}
// ---------- Hook ----------
export function useAuthState() {
// 初始化:从 localStorage 恢复
const [username, setUsername] = useState(() => readAuth()?.username || '');
const [password, setPassword] = useState(() => {
const auth = readAuth();
return auth?.remember ? decode(auth.password) : '';
});
const [remember, setRemember] = useState(() => readAuth()?.remember || false);
const [autoLogin, setAutoLogin] = useState(() => readAuth()?.autoLogin || false);
// 勾选"记住密码"变化时持久化
const handleRememberChange = useCallback((checked: boolean) => {
setRemember(checked);
if (!checked) {
// 取消记住密码 → 同时取消自动登录
setAutoLogin(false);
clearAuth();
}
}, []);
// 勾选"自动登录"变化时持久化
const handleAutoLoginChange = useCallback((checked: boolean) => {
setAutoLogin(checked);
}, []);
// 登录成功后保存
const saveAuth = useCallback(
(user: string, pass: string) => {
setUsername(user);
if (remember) {
setPassword(pass);
writeAuth({
username: user,
password: encode(pass),
remember,
autoLogin,
});
}
},
[remember, autoLogin],
);
// 清除保存的认证信息
const clearSavedAuth = useCallback(() => {
setUsername('');
setPassword('');
setRemember(false);
setAutoLogin(false);
clearAuth();
}, []);
return {
username,
password,
remember,
autoLogin,
setUsername,
setPassword,
handleRememberChange,
handleAutoLoginChange,
saveAuth,
clearSavedAuth,
};
}

149
src/pages/login/index.tsx Normal file
View File

@@ -0,0 +1,149 @@
// ============================================================
// LoginPage — 登录/注册 Modal 弹层
//
// 行为:
// - 未登录时弹出不可操作主页Modal 遮罩)
// - 可关闭(右上 X关闭后主页可操作但 API 401 时会再次弹出
// - 登录成功 → 关闭 Modal更新全局登录态
// ============================================================
import { useState, useCallback } from 'react';
import { Modal, Tabs, Typography, App } from 'antd';
import { useAppContext } from '@/contexts/app-context.ts';
import { useTheme } from '@/hooks/use-theme.ts';
import { LoginForm } from './components/LoginForm';
import { RegisterForm } from './components/RegisterForm';
import { useAuthState } from './hooks/use-auth-state';
import type { LoginFormValues, RegisterFormValues } from './types';
const { Text } = Typography;
interface LoginPageProps {
open: boolean;
onClose: () => void;
}
export function LoginPage({ open, onClose }: LoginPageProps) {
const { edition, login } = useAppContext();
const { isDark } = useTheme();
const { message } = App.useApp();
const isEnterprise = edition === 'enterprise';
const [activeTab, setActiveTab] = useState<string>('login');
const [submitting, setSubmitting] = useState(false);
const authState = useAuthState();
// ========== 登录 ==========
const handleLogin = useCallback(
async (values: LoginFormValues) => {
setSubmitting(true);
try {
// TODO: 接入登录 API
console.log('[Login]', values);
authState.saveAuth(values.username, values.password);
message.success('登录成功Demo');
login();
onClose();
} catch (err) {
message.error((err as Error).message || '登录失败');
} finally {
setSubmitting(false);
}
},
[authState, message, login, onClose],
);
// ========== 注册 ==========
const handleRegister = useCallback(
async (values: RegisterFormValues) => {
setSubmitting(true);
try {
console.log('[Register]', values);
message.success('注册成功Demo请登录');
setActiveTab('login');
} catch (err) {
message.error((err as Error).message || '注册失败');
} finally {
setSubmitting(false);
}
},
[message],
);
const handleSendSms = useCallback(
(phone: string) => {
console.log('[SMS]', phone);
message.info(`验证码已发送到 ${phone}Demo`);
},
[message],
);
// ========== 渲染 ==========
const loginTab = (
<LoginForm
initialUsername={authState.username}
initialPassword={authState.password}
initialRemember={authState.remember}
initialAutoLogin={authState.autoLogin}
submitting={submitting}
onSubmit={handleLogin}
onRememberChange={authState.handleRememberChange}
onAutoLoginChange={authState.handleAutoLoginChange}
/>
);
const registerTab = (
<RegisterForm submitting={submitting} onSubmit={handleRegister} onSendSms={handleSendSms} />
);
return (
<Modal
open={open}
onCancel={onClose}
footer={null}
width={440}
centered
destroyOnHidden={false}
mask={{
closable: false,
}}
styles={{
body: {
padding: '24px 32px 16px',
background: isDark ? '#1A1730' : '#FFFFFF',
},
}}
>
{/* 标题 */}
<div className="text-center mb-5">
<Text strong className="text-lg gradient-primary-text block">
· HeiXiu
</Text>
<Text type="secondary" className="text-xs">
{isEnterprise ? '企业版 — 员工账号登录' : '个人版'}
</Text>
</div>
{/* 个人版 Tab | 企业版仅登录 */}
{isEnterprise ? (
loginTab
) : (
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
centered
size="small"
items={[
{ key: 'login', label: '登录', children: loginTab },
{ key: 'register', label: '注册', children: registerTab },
]}
/>
)}
</Modal>
);
}

52
src/pages/login/types.ts Normal file
View File

@@ -0,0 +1,52 @@
// ============================================================
// 登录/注册 — 类型定义
// ============================================================
// ---------- 登录 ----------
export interface LoginFormValues {
username: string;
password: string;
remember: boolean;
autoLogin: boolean;
}
// ---------- 注册(个人版) ----------
export interface RegisterFormValues {
username: string;
password: string;
confirmPassword: string;
phone: string;
sms_code: string;
device_id: string;
device_label: string;
display_name: string;
referral_code: string;
}
// ---------- 字段配置(数据驱动 UI 渲染) ----------
export interface FieldRule {
required?: boolean;
minLen?: number;
maxLen?: number;
pattern?: RegExp;
message?: string;
}
export type InputType = 'text' | 'password' | 'tel';
export interface FieldConfig {
name: string;
label: string;
placeholder: string;
inputType: InputType;
rules: FieldRule[];
/** 仅注册表单的选填字段 */
optional?: boolean;
/** 附带操作按钮标识 */
extraAction?: 'send-sms';
/** 冷却秒数(发送验证码按钮用) */
cooldown?: number;
}

View File

@@ -0,0 +1,38 @@
// ============================================================
// 公告模块 — 类型定义 + API 请求
// GET /api/v1/announcements
// ============================================================
import { get } from '../request';
// ---------- 类型 ----------
/** 公告类型枚举 */
export type AnnouncementType = 'system' | 'feature' | 'activity' | 'maintenance' | string;
/** 单条公告 */
export interface Announcement {
id: number;
title: string;
content: string;
type: AnnouncementType;
is_active: boolean;
pinned: boolean;
expires_at: string;
created_by: string;
created_at: string;
updated_at: string;
}
/** 公告列表响应 */
export type AnnouncementListResponse = Announcement[];
// ---------- API ----------
/**
* 获取公告列表
* GET /api/v1/announcements
*/
export function fetchAnnouncements(): Promise<AnnouncementListResponse> {
return get<AnnouncementListResponse>('/api/v1/announcements');
}

View File

@@ -0,0 +1,11 @@
// ============================================================
// API 模块统一导出
// 后续新增模块(如 auth、user、billing 等)在此追加
// ============================================================
export {
fetchAnnouncements,
type Announcement,
type AnnouncementType,
type AnnouncementListResponse,
} from './announcement';

285
src/services/request.ts Normal file
View File

@@ -0,0 +1,285 @@
// ============================================================
// Axios 请求封装
//
// 功能:
// - 统一 BaseURL环境变量 VITE_API_BASE_URL
// - 请求拦截器:自动附加 Token
// - 响应拦截器:统一错误处理、业务码校验
// - 类型安全的 GET / POST / PUT / DELETE / UPLOAD
// - 请求超时、取消请求支持
// ============================================================
import axios, {
type AxiosInstance,
type AxiosRequestConfig,
type AxiosResponse,
type InternalAxiosRequestConfig,
} from 'axios';
import type { ApiResponse } from './types';
import { RequestError, RequestErrorType } from './types';
import { emit, EVENTS } from '../utils/event-bus';
// ---------- 配置 ----------
/** API 基础地址(可通过 Vite 环境变量覆盖) */
const BASE_URL = import.meta.env.VITE_API_BASE_URL;
/** 请求超时时间(毫秒) */
const TIMEOUT = 30000;
/** localStorage 中 Token 的键名 */
const TOKEN_KEY = 'ele-heixiu-token';
// ---------- Token 管理 ----------
/** 获取存储的 Token */
export function getToken(): string | null {
try {
return localStorage.getItem(TOKEN_KEY);
} catch {
return null;
}
}
/** 设置 Token */
export function setToken(token: string): void {
try {
localStorage.setItem(TOKEN_KEY, token);
} catch {
/* 静默忽略 */
}
}
/** 清除 Token */
export function clearToken(): void {
try {
localStorage.removeItem(TOKEN_KEY);
} catch {
/* 静默忽略 */
}
}
// ---------- Axios 实例 ----------
const instance: AxiosInstance = axios.create({
baseURL: BASE_URL,
timeout: TIMEOUT,
headers: {
'Content-Type': 'application/json',
},
});
// ---------- 请求拦截器 ----------
instance.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 自动附加 Token
const token = getToken();
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
() => {
return Promise.reject(new RequestError(RequestErrorType.NETWORK, '请求配置失败'));
},
);
// ---------- 响应拦截器 ----------
instance.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
const { data } = response;
// HTTP 200 但业务码非 0 → 业务错误
if (data && data.code !== 0) {
const err = new RequestError(RequestErrorType.BUSINESS, data.message || '请求失败', {
code: data.code,
traceId: data.traceId,
});
// 特殊业务码处理
if (data.code === 401) {
clearToken();
emit(EVENTS.AUTH_REQUIRED);
}
return Promise.reject(err);
}
return response;
},
(error) => {
// 请求已被取消
if (axios.isCancel(error)) {
return Promise.reject(new RequestError(RequestErrorType.CANCELLED, '请求已取消'));
}
// 超时
if (error.code === 'ECONNABORTED') {
return Promise.reject(new RequestError(RequestErrorType.TIMEOUT, '请求超时,请检查网络'));
}
// 无网络
if (!error.response) {
return Promise.reject(new RequestError(RequestErrorType.NETWORK, '网络异常,请检查连接'));
}
// HTTP 状态码错误
const { status, data } = error.response;
let errMsg = '请求失败';
switch (status) {
case 400:
errMsg = '请求参数有误';
break;
case 401:
errMsg = '登录已过期,请重新登录';
clearToken();
emit(EVENTS.AUTH_REQUIRED);
break;
case 403:
errMsg = '没有访问权限';
break;
case 404:
errMsg = '请求的资源不存在';
break;
case 500:
errMsg = '服务器内部错误';
break;
case 502:
errMsg = '网关错误';
break;
case 503:
errMsg = '服务暂时不可用';
break;
}
return Promise.reject(
new RequestError(RequestErrorType.HTTP, data?.message || errMsg, {
httpStatus: status,
code: data?.code,
}),
);
},
);
// ---------- 请求方法 ----------
/**
* 发起 GET 请求
* @param url - 请求路径
* @param params - 查询参数
* @param config - Axios 配置覆盖
*/
export async function get<T>(
url: string,
params?: Record<string, unknown>,
config?: AxiosRequestConfig,
): Promise<T> {
const res = await instance.get<ApiResponse<T>>(url, { ...config, params });
return res.data.data;
}
/**
* 发起 POST 请求JSON
* @param url - 请求路径
* @param data - 请求体
* @param config - Axios 配置覆盖
*/
export async function post<T>(
url: string,
data?: unknown,
config?: AxiosRequestConfig,
): Promise<T> {
const res = await instance.post<ApiResponse<T>>(url, data, config);
return res.data.data;
}
/**
* 发起 PUT 请求JSON
*/
export async function put<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
const res = await instance.put<ApiResponse<T>>(url, data, config);
return res.data.data;
}
/**
* 发起 DELETE 请求
*/
export async function del<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const res = await instance.delete<ApiResponse<T>>(url, config);
return res.data.data;
}
/**
* 文件上传multipart/form-data
* @param url - 上传接口路径
* @param formData - FormData 对象
* @param onProgress - 上传进度回调 (0~100)
*/
export async function upload<T>(
url: string,
formData: FormData,
onProgress?: (percent: number) => void,
): Promise<T> {
const res = await instance.post<ApiResponse<T>>(url, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 120000, // 上传超时 2 分钟
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total);
onProgress(percent);
}
},
});
return res.data.data;
}
/**
* 文件下载
* @param url - 下载接口路径
* @param filename - 保存的文件名
*/
export async function download(url: string, filename?: string): Promise<void> {
const res = await instance.get(url, {
responseType: 'blob',
});
const blob = new Blob([res.data]);
const downloadUrl = window.URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = downloadUrl;
anchor.download = filename || 'download';
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
window.URL.revokeObjectURL(downloadUrl);
}
/**
* 创建可取消的请求控制器
* 用法:
* const { controller, request } = createCancellableRequest();
* request(get('/some/url')); // 发起请求
* controller.abort(); // 取消请求
*/
export function createCancellableRequest() {
const controller = new AbortController();
return {
controller,
signal: controller.signal,
/** 包裹一个请求 Promise使其可通过 controller.abort() 取消 */
request: <T>(promise: Promise<T>): Promise<T> => {
return new Promise<T>((resolve, reject) => {
promise.then(resolve).catch(reject);
controller.signal.addEventListener('abort', () => {
reject(new RequestError(RequestErrorType.CANCELLED, '请求已取消'));
});
});
},
};
}
// ---------- 导出 axios 实例(供高级场景直接使用) ----------
export { instance as axiosInstance };
export { RequestError, RequestErrorType };

71
src/services/types.ts Normal file
View File

@@ -0,0 +1,71 @@
// ============================================================
// API 响应通用类型
// ============================================================
/** 后端统一响应结构 */
export interface ApiResponse<T = unknown> {
/** 业务状态码0 表示成功 */
code: number;
/** 响应数据 */
data: T;
/** 提示信息 */
message: string;
/** 请求追踪 ID可选用于排查问题 */
traceId?: string;
}
/** 分页请求参数 */
export interface PaginationParams {
page: number;
pageSize: number;
}
/** 分页响应数据 */
export interface PaginatedData<T> {
/** 数据列表 */
list: T[];
/** 总条数 */
total: number;
/** 当前页码 */
page: number;
/** 每页条数 */
pageSize: number;
}
/** 分页响应包装 */
export type PaginatedResponse<T> = ApiResponse<PaginatedData<T>>;
/** 请求错误类型 */
export enum RequestErrorType {
/** 网络错误(断网等) */
NETWORK = 'NETWORK',
/** 请求超时 */
TIMEOUT = 'TIMEOUT',
/** HTTP 状态码异常4xx / 5xx */
HTTP = 'HTTP',
/** 业务错误code ≠ 0 */
BUSINESS = 'BUSINESS',
/** 请求被取消 */
CANCELLED = 'CANCELLED',
}
/** 请求错误 */
export class RequestError extends Error {
type: RequestErrorType;
code?: number;
httpStatus?: number;
traceId?: string;
constructor(
type: RequestErrorType,
message: string,
options?: { code?: number; httpStatus?: number; traceId?: string },
) {
super(message);
this.name = 'RequestError';
this.type = type;
this.code = options?.code;
this.httpStatus = options?.httpStatus;
this.traceId = options?.traceId;
}
}

312
src/theme/antd-theme.ts Normal file
View File

@@ -0,0 +1,312 @@
// ============================================================
// Ant Design 主题适配器 —— 蓝紫渐变主题(亮色 + 暗色)
// 通过 ConfigProvider 的 theme 属性覆盖 Ant Design 默认主题变量,
// 确保与 Tailwind 主题令牌tokens.ts / globals.css同步。
// ============================================================
import type { ThemeConfig } from 'antd';
import { theme } from 'antd';
import {
colorPrimary,
colorPrimaryLight,
colorSuccess,
colorWarning,
colorError,
colorInfo,
colorTextBase,
colorTextSecondary,
colorBgBase,
colorBgContainer,
colorBgElevated,
colorBgLayout,
colorBorder,
colorBorderSecondary,
borderRadiusBase,
borderRadiusLG,
borderRadiusSM,
borderRadiusXS,
fontFamily,
fontFamilyCode,
fontSizeBase,
fontSizeLG,
fontSizeSM,
fontSizeXL,
boxShadowBase,
boxShadowLG,
gradientPrimary,
// 暗色令牌
darkColorTextBase,
darkColorTextSecondary,
darkColorTextTertiary,
darkColorBgBase,
darkColorBgContainer,
darkColorBgElevated,
darkColorBgLayout,
darkColorBorder,
darkColorBorderSecondary,
} from './tokens';
// ============================================================
// 共享的组件级覆盖(亮色 & 暗色通用)
// ============================================================
const sharedComponents: ThemeConfig['components'] = {
Button: {
colorPrimary,
colorPrimaryHover: colorPrimaryLight,
colorPrimaryActive: '#4F46E5',
primaryShadow: '0 2px 0 rgba(99, 102, 241, 0.15)',
borderRadius: borderRadiusBase,
defaultBorderColor: colorBorder,
},
Menu: {
itemSelectedBg: '#EEF2FF',
itemSelectedColor: colorPrimary,
itemHoverBg: '#F5F3FF',
itemActiveBg: '#E0E7FF',
},
Tabs: {
itemSelectedColor: colorPrimary,
itemHoverColor: colorPrimaryLight,
inkBarColor: colorPrimary,
},
Modal: {
borderRadiusLG,
},
Card: {
borderRadiusLG,
},
Tag: {
defaultBg: '#EEF2FF',
defaultColor: colorPrimary,
},
Input: {
activeBorderColor: colorPrimary,
hoverBorderColor: colorPrimaryLight,
},
Select: {
activeBorderColor: colorPrimary,
hoverBorderColor: colorPrimaryLight,
},
DatePicker: {
activeBorderColor: colorPrimary,
hoverBorderColor: colorPrimaryLight,
},
Switch: {
colorPrimary,
colorPrimaryHover: colorPrimaryLight,
},
Slider: {
colorPrimary,
colorPrimaryBorder: colorPrimary,
colorPrimaryBorderHover: colorPrimaryLight,
},
Progress: {
defaultColor: colorPrimary,
},
Spin: {
colorPrimary,
},
Breadcrumb: {
linkColor: colorPrimary,
linkHoverColor: colorPrimaryLight,
lastItemColor: colorTextBase,
},
Pagination: {
itemActiveBg: colorPrimary,
colorPrimary,
colorPrimaryHover: colorPrimaryLight,
},
Notification: {
borderRadiusLG,
},
Tooltip: {
borderRadius: borderRadiusSM,
},
};
// ============================================================
// 共享的 token 基座(不变部分)
// ============================================================
const sharedTokens = {
// 品牌色
colorPrimary,
colorPrimaryBg: '#EEF2FF',
colorPrimaryBgHover: '#E0E7FF',
colorPrimaryBorder: '#C7D2FE',
colorPrimaryBorderHover: '#A5B4FC',
colorPrimaryHover: colorPrimaryLight,
colorPrimaryActive: '#4F46E5',
colorPrimaryTextHover: colorPrimaryLight,
colorPrimaryText: colorPrimary,
colorPrimaryTextActive: '#4338CA',
// 功能色
colorSuccess,
colorWarning,
colorError,
colorInfo,
// 圆角
borderRadius: borderRadiusBase,
borderRadiusLG,
borderRadiusSM,
borderRadiusXS,
// 字体
fontFamily,
fontFamilyCode,
fontSize: fontSizeBase,
fontSizeLG,
fontSizeSM,
fontSizeXL,
fontSizeHeading1: 28,
fontSizeHeading2: 24,
fontSizeHeading3: 20,
fontSizeHeading4: 18,
fontSizeHeading5: 16,
// 间距
padding: 16,
paddingLG: 24,
paddingSM: 12,
paddingXS: 8,
paddingXXS: 4,
// 链接
colorLink: colorPrimary,
colorLinkHover: colorPrimaryLight,
colorLinkActive: '#4F46E5',
};
// ============================================================
// 🌞 亮色主题配置
// ============================================================
export const lightTheme: ThemeConfig = {
algorithm: theme.defaultAlgorithm,
token: {
...sharedTokens,
// 文字色 — 亮色
colorText: colorTextBase,
colorTextSecondary,
colorTextTertiary: '#9CA3AF',
// 背景色 — 亮色
colorBgBase,
colorBgContainer,
colorBgElevated,
colorBgLayout,
// 边框 — 亮色
colorBorder,
colorBorderSecondary,
// 阴影
boxShadow: boxShadowBase,
boxShadowSecondary: boxShadowLG,
},
components: {
...sharedComponents,
Layout: {
siderBg: `linear-gradient(180deg, #1E1B4B 0%, #312E81 100%)`,
headerBg: colorBgBase,
bodyBg: colorBgLayout,
},
Table: {
headerBg: '#EEF2FF',
headerColor: colorTextBase,
rowHoverBg: '#F5F3FF',
borderColor: colorBorderSecondary,
},
},
};
// ============================================================
// 🌙 暗色主题配置
// ============================================================
export const darkTheme: ThemeConfig = {
algorithm: theme.darkAlgorithm,
token: {
...sharedTokens,
// 暗色模式下品牌色适当提亮以保证对比度
colorPrimary: colorPrimaryLight,
colorPrimaryHover: '#A5B4FC',
colorPrimaryActive: colorPrimary,
colorPrimaryBg: '#1E1B4B',
colorPrimaryBgHover: '#312E81',
colorPrimaryBorder: '#4338CA',
colorPrimaryBorderHover: colorPrimary,
colorPrimaryTextHover: '#A5B4FC',
colorPrimaryText: colorPrimaryLight,
colorPrimaryTextActive: colorPrimary,
// 链接
colorLink: colorPrimaryLight,
colorLinkHover: '#A5B4FC',
colorLinkActive: colorPrimary,
// 文字色 — 暗色
colorText: darkColorTextBase,
colorTextSecondary: darkColorTextSecondary,
colorTextTertiary: darkColorTextTertiary,
// 背景色 — 暗色
colorBgBase: darkColorBgBase,
colorBgContainer: darkColorBgContainer,
colorBgElevated: darkColorBgElevated,
colorBgLayout: darkColorBgLayout,
// 边框 — 暗色
colorBorder: darkColorBorder,
colorBorderSecondary: darkColorBorderSecondary,
// 阴影 — 暗色模式下用更深的投影
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px -1px rgba(0, 0, 0, 0.3)',
boxShadowSecondary: '0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -4px rgba(0, 0, 0, 0.4)',
},
components: {
...sharedComponents,
Button: {
...sharedComponents?.Button,
defaultBorderColor: darkColorBorder,
},
Menu: {
itemSelectedBg: '#312E81',
itemSelectedColor: colorPrimaryLight,
itemHoverBg: '#242040',
itemActiveBg: '#1E1B4B',
},
Layout: {
siderBg: `linear-gradient(180deg, #0B0A15 0%, #1A1730 100%)`,
headerBg: darkColorBgContainer,
bodyBg: darkColorBgLayout,
},
Table: {
headerBg: '#1E1B4B',
headerColor: darkColorTextBase,
rowHoverBg: '#242040',
borderColor: darkColorBorderSecondary,
},
Tag: {
defaultBg: '#1E1B4B',
defaultColor: colorPrimaryLight,
},
},
};
// ============================================================
// 辅助导出
// ============================================================
/** 根据 isDark 获取对应的主题配置 */
export function getThemeConfig(isDark: boolean): ThemeConfig {
return isDark ? darkTheme : lightTheme;
}
export { gradientPrimary };

257
src/theme/globals.css Normal file
View File

@@ -0,0 +1,257 @@
/* ============================================================
* 全局样式 —— Tailwind CSS v4 指令 + 蓝紫渐变主题扩展 + 重置样式
* Tailwind v4 使用 CSS @theme 指令替代 tailwind.config.ts
* 设计令牌需与 tokens.ts / antd-theme.ts 保持同步。
* ============================================================ */
@import 'tailwindcss';
/* ============================================================
* Tailwind CSS v4 主题扩展(用 @theme 指令)
* 此处定义的自定义令牌会自动生成对应的 Tailwind 工具类。
* 例如colors.primary → bg-primary, text-primary, border-primary 等。
* ============================================================ */
/* 品牌色 — 蓝紫渐变 */
@theme {
/* 主色系 */
--color-primary: #6366f1; /* Indigo-500 */
--color-primary-light: #818cf8; /* Indigo-400 */
--color-primary-dark: #4f46e5; /* Indigo-600 */
--color-primary-50: #eef2ff; /* Indigo-50 */
--color-primary-100: #e0e7ff; /* Indigo-100 */
--color-primary-200: #c7d2fe; /* Indigo-200 */
--color-primary-300: #a5b4fc; /* Indigo-300 */
--color-primary-400: #818cf8; /* Indigo-400 */
--color-primary-500: #6366f1; /* Indigo-500 */
--color-primary-600: #4f46e5; /* Indigo-600 */
--color-primary-700: #4338ca; /* Indigo-700 */
--color-primary-800: #3730a3; /* Indigo-800 */
--color-primary-900: #312e81; /* Indigo-900 */
/* 辅助色系 — Violet */
--color-secondary: #8b5cf6; /* Violet-500 */
--color-secondary-light: #a78bfa; /* Violet-400 */
--color-secondary-dark: #7c3aed; /* Violet-600 */
/* 功能色 */
--color-success: #10b981;
--color-success-light: #d1fae5;
--color-warning: #f59e0b;
--color-warning-light: #fef3c7;
--color-error: #ef4444;
--color-error-light: #fee2e2;
--color-info: #3b82f6;
--color-info-light: #dbeafe;
/* 圆角 */
--radius-xs: 4px;
--radius-sm: 6px;
--radius-base: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-2xl: 24px;
/* 字体 */
--font-family-base:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans',
'PingFang SC', 'Microsoft YaHei', sans-serif;
--font-family-mono:
'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, 'Courier New', monospace;
/* 动画 */
--transition-duration: 0.2s;
--transition-timing: cubic-bezier(0.4, 0, 0.2, 1);
}
/* ============================================================
* 🌞 亮色模式 CSS 变量(默认)
* 这些变量与 tokens.ts 中的亮色令牌同步
* ============================================================ */
:root,
[data-theme='light'] {
/* 中性色 — 亮色 */
--color-text-base: #1e1b4b;
--color-text-secondary: #6b7280;
--color-text-tertiary: #9ca3af;
--color-bg-base: #ffffff;
--color-bg-container: #f9fafb;
--color-bg-layout: #f5f3ff;
--color-bg-elevated: #ffffff;
--color-border-base: #e5e7eb;
--color-border-secondary: #f3f4f6;
/* 阴影 */
--shadow-base: 0 1px 3px 0 rgba(99, 102, 241, 0.08), 0 1px 2px -1px rgba(99, 102, 241, 0.08);
--shadow-lg: 0 10px 15px -3px rgba(99, 102, 241, 0.1), 0 4px 6px -4px rgba(99, 102, 241, 0.1);
}
/* ============================================================
* 🌙 暗色模式 CSS 变量
* 当 <html data-theme="dark"> 时自动切换
* 这些变量与 tokens.ts 中的暗色令牌同步
* ============================================================ */
[data-theme='dark'] {
/* 中性色 — 暗色 */
--color-text-base: #e8e6f0;
--color-text-secondary: #a5a0c9;
--color-text-tertiary: #6e6998;
--color-bg-base: #0f0d1a;
--color-bg-container: #1a1730;
--color-bg-layout: #0b0a15;
--color-bg-elevated: #242040;
--color-border-base: #2e2a4a;
--color-border-secondary: #1f1c38;
/* 暗色模式下品牌色提亮以保持对比度 */
--color-primary: #818cf8;
--color-primary-light: #a5b4fc;
--color-primary-dark: #6366f1;
/* 阴影 — 暗色下用深投影 */
--shadow-base: 0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px -1px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
}
/* ============================================================
* 蓝紫渐变工具类
* Tailwind v4 使用 @utility 定义自定义工具类
* ============================================================ */
@utility gradient-primary {
background: linear-gradient(135deg, #4f46e5, #7c3aed);
}
@utility gradient-primary-hover {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
}
@utility gradient-primary-text {
background: linear-gradient(135deg, #4f46e5, #7c3aed);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
@utility gradient-border {
border-image: linear-gradient(135deg, #4f46e5, #7c3aed) 1;
}
/* ============================================================
* 全局重置样式
* 仅放必要的重置,不堆积组件样式
* ============================================================ */
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
font-family: var(--font-family-base);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
/* 亮/暗切换过渡 */
color-scheme: light dark;
}
body {
margin: 0;
padding: 0;
color: var(--color-text-base);
background-color: var(--color-bg-base);
min-height: 100vh;
transition:
background-color 0.3s ease,
color 0.3s ease;
}
#root {
min-height: 100vh;
}
/* 滚动条美化 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #c7d2fe;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #a5b4fc;
}
/* 暗色模式滚动条 */
[data-theme='dark'] ::-webkit-scrollbar-thumb {
background: #4338ca;
}
[data-theme='dark'] ::-webkit-scrollbar-thumb:hover {
background: #4f46e5;
}
/* 聚焦环 — 使用品牌色 */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* 选中文本 — 使用品牌色 */
::selection {
background: rgba(99, 102, 241, 0.2);
color: var(--color-text-base);
}
[data-theme='dark'] ::selection {
background: rgba(129, 140, 248, 0.3);
color: var(--color-text-base);
}
/* 链接全局样式 */
a {
color: var(--color-primary);
text-decoration: none;
transition: color var(--transition-duration) var(--transition-timing);
}
a:hover {
color: var(--color-primary-light);
}
/* ============================================================
* 窗口拖拽区域(自定义导航栏)
*
* macOS hiddenInset 模式下,整个导航栏区域可拖拽移动窗口。
* .navbar-drag-region → 可拖拽(背景区域)
* .navbar-no-drag → 不可拖拽(按钮、链接等交互元素)
* ============================================================ */
.navbar-drag-region {
-webkit-app-region: drag;
user-select: none;
}
.navbar-no-drag {
-webkit-app-region: no-drag;
}
/* macOS 红绿灯按钮区域不遮挡内容 */
@supports (-webkit-app-region: drag) {
.navbar-drag-region {
-webkit-app-region: drag;
}
}

117
src/theme/tokens.ts Normal file
View File

@@ -0,0 +1,117 @@
// ============================================================
// 设计令牌Design Tokens—— 蓝紫渐变主题
// 此处定义的颜色、圆角、阴影等令牌,需同步到:
// 1. globals.cssTailwind CSS 扩展,通过 @theme 指令)
// 2. antd-theme.tsAnt Design ConfigProvider 主题配置)
// 修改时三处同步更新。
// ============================================================
// ---------- 品牌色(蓝紫渐变) ----------
/** 品牌主色 — Indigo-500 */
export const colorPrimary = '#6366F1';
/** 品牌主色浅色变体 */
export const colorPrimaryLight = '#818CF8';
/** 品牌主色深色变体 */
export const colorPrimaryDark = '#4F46E5';
/** 品牌辅助色 — Violet-500渐变另一端 */
export const colorSecondary = '#8B5CF6';
/** 品牌渐变色起始值 */
export const gradientStart = '#4F46E5'; // Indigo-600
/** 品牌渐变色结束值 */
export const gradientEnd = '#7C3AED'; // Violet-600
/** 品牌渐变色CSS 值,可直接用于 background */
export const gradientPrimary = `linear-gradient(135deg, ${gradientStart}, ${gradientEnd})`;
/** 品牌渐变悬停态 */
export const gradientPrimaryHover = `linear-gradient(135deg, ${colorPrimary}, ${colorSecondary})`;
// ---------- 功能色 ----------
export const colorSuccess = '#10B981';
export const colorWarning = '#F59E0B';
export const colorError = '#EF4444';
export const colorInfo = '#3B82F6';
// ---------- 中性色(亮色模式) ----------
export const colorTextBase = '#1E1B4B'; // 深蓝紫黑
export const colorTextSecondary = '#6B7280';
export const colorTextTertiary = '#9CA3AF';
export const colorBgBase = '#FFFFFF';
export const colorBgContainer = '#F9FAFB';
export const colorBgElevated = '#FFFFFF';
export const colorBgLayout = '#F5F3FF'; // Violet-50
export const colorBgSpotlight = '#F3F4F6';
export const colorBorder = '#E5E7EB';
export const colorBorderSecondary = '#F3F4F6';
// ---------- 中性色(暗色模式) ----------
/** 暗色模式 — 主文字 */
export const darkColorTextBase = '#E8E6F0';
/** 暗色模式 — 次要文字 */
export const darkColorTextSecondary = '#A5A0C9';
/** 暗色模式 — 辅助文字 */
export const darkColorTextTertiary = '#6E6998';
/** 暗色模式 — 基础背景 */
export const darkColorBgBase = '#0F0D1A'; // 深蓝紫黑
/** 暗色模式 — 容器背景 */
export const darkColorBgContainer = '#1A1730'; // 抬升一层
/** 暗色模式 — 悬浮背景 */
export const darkColorBgElevated = '#242040'; // 再抬升一层
/** 暗色模式 — 布局背景 */
export const darkColorBgLayout = '#0B0A15'; // 最深背景
/** 暗色模式 — 边框 */
export const darkColorBorder = '#2E2A4A';
/** 暗色模式 — 次要边框 */
export const darkColorBorderSecondary = '#1F1C38';
// ---------- 圆角 ----------
export const borderRadiusBase = 8;
export const borderRadiusLG = 12;
export const borderRadiusSM = 6;
export const borderRadiusXS = 4;
// ---------- 阴影 ----------
export const boxShadowBase =
'0 1px 3px 0 rgba(99, 102, 241, 0.08), 0 1px 2px -1px rgba(99, 102, 241, 0.08)';
export const boxShadowLG =
'0 10px 15px -3px rgba(99, 102, 241, 0.1), 0 4px 6px -4px rgba(99, 102, 241, 0.1)';
// ---------- 字体 ----------
export const fontFamily = `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', 'PingFang SC', 'Microsoft YaHei', sans-serif`;
export const fontFamilyCode = `'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, 'Courier New', monospace`;
export const fontSizeBase = 14;
export const fontSizeLG = 16;
export const fontSizeSM = 12;
export const fontSizeXL = 20;
// ---------- 间距 ----------
export const spacingUnit = 4;
export const spacingXS = 4;
export const spacingSM = 8;
export const spacingMD = 16;
export const spacingLG = 24;
export const spacingXL = 32;
export const spacingXXL = 48;
// ---------- 动画 ----------
export const transitionDuration = '0.2s';
export const transitionTimingFunction = 'cubic-bezier(0.4, 0, 0.2, 1)';

47
src/utils/event-bus.ts Normal file
View File

@@ -0,0 +1,47 @@
// ============================================================
// 事件总线 — 跨组件/跨模块事件通信
// 用于 axios 拦截器 → App 触发登录弹窗等解耦场景
// ============================================================
type Listener = (...args: unknown[]) => void;
const listeners = new Map<string, Set<Listener>>();
/** 监听事件 */
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 {
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',
} as const;

43
src/utils/ipc.ts Normal file
View File

@@ -0,0 +1,43 @@
// ============================================================
// 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);
}

53
src/utils/logo.ts Normal file
View File

@@ -0,0 +1,53 @@
// ============================================================
// 渲染进程 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/platform.ts Normal file
View File

@@ -0,0 +1,89 @@
// ============================================================
// 渲染进程平台判定工具
// 通过 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;
}

14
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
/// <reference types="vite/client" />
declare global {
interface Window {
ipcRenderer: {
on(channel: string, listener: (event: unknown, ...args: unknown[]) => void): unknown;
off(channel: string, listener: (...args: unknown[]) => void): unknown;
send(channel: string, ...args: unknown[]): void;
invoke(channel: string, ...args: unknown[]): Promise<unknown>;
};
}
}
export {};

31
tsconfig.json Normal file
View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2024",
"useDefineForClassFields": true,
"lib": ["ES2024", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client", "node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Path aliases TypeScript 6 paths tsconfig */
"paths": {
"@/*": ["./src/*"],
"@shared/*": ["./shared/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "shared"]
}

14
tsconfig.node.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"composite": true,
"esModuleInterop": true,
"module": "esnext",
"target": "es2024",
"moduleResolution": "bundler",
"skipLibCheck": true,
"strict": true,
"types": ["node"]
},
"include": ["vite.config.ts", "electron/**/*.ts", "shared"]
}

File diff suppressed because one or more lines are too long

47
vite.config.ts Normal file
View File

@@ -0,0 +1,47 @@
import { defineConfig } from 'vite';
import path from 'node:path';
import electron from 'vite-plugin-electron/simple';
import react from '@vitejs/plugin-react';
import renderer from 'vite-plugin-electron-renderer';
import tailwindcss from '@tailwindcss/vite';
import { visualizer } from 'rollup-plugin-visualizer';
const __dirname = import.meta.dirname;
export default defineConfig({
plugins: [
react(),
tailwindcss(),
electron({
main: {
entry: 'electron/main.ts',
},
preload: {
input: path.join(__dirname, 'electron/preload.ts'),
},
}),
renderer(),
// 打包体积分析(仅 ANALYZE=true 时生成 stats.html
process.env.ANALYZE &&
visualizer({
open: true,
gzipSize: true,
brotliSize: true,
filename: 'dist/stats.html',
}),
].filter(Boolean),
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
'@shared': path.resolve(__dirname, 'shared'),
},
},
build: {
rollupOptions: {
output: {},
},
},
preview: {
port: 5173,
},
});