feat: 忘记密码/修改密码 + 设置页结构重组 + barrel cleanup + LoginPage 版本统一
新增: - ForgotPasswordModal — 手机号+短信验证码重置密码 (AuthAPI.resetPassword) - ChangePasswordModal — 当前密码验证后修改密码 (AuthAPI.changePassword) - useSmsCooldown Hook — 通用短信发送冷却逻辑,供注册/忘记密码复用 - PasswordSetting — 设置页账号安全区块,双选项:修改密码 / 短信验证码重置 修改: - LoginForm \"忘记密码?\" 链接接入 ForgotPasswordModal(替换 TODO) - LoginPage 移除 edition 业务版本 UI 区分,登录/注册 Tab 全局统一 - 设置页 Section 组件移入 blocks/ 子目录(git mv 保留历史) - auth.ts sendSms URL 修正: /sms/send → /send-sms 清理: - settings/index.ts 移除 4 个未使用 re-export,精简为仅 SettingsPage - navbar/index.ts 移除未使用 re-export,精简为仅 NavBar 文档: - WORKLOG.md 新增 2026-06-08 工作日志 - ARCHITECTURE.md / PROJECT.md 更新设置页目录结构与新组件 - TODO.md 忘记密码流程标记已完成 + 短信人机验证列入后期优化
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"pid": 384720,
|
"pid": 568932,
|
||||||
"version": "0.9.9",
|
"version": "0.9.9",
|
||||||
"socketPath": "\\\\.\\pipe\\codegraph-aef859f0205bacea",
|
"socketPath": "\\\\.\\pipe\\codegraph-aef859f0205bacea",
|
||||||
"startedAt": 1780763058452
|
"startedAt": 1780880691721
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 船长 · HeiXiu — 前端架构文档
|
# 船长 · HeiXiu — 前端架构文档
|
||||||
|
|
||||||
> 最后更新:2026-06-07
|
> 最后更新:2026-06-08
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
@@ -17,12 +17,21 @@ src/
|
|||||||
│ ├── use-theme.ts # 主题切换
|
│ ├── use-theme.ts # 主题切换
|
||||||
│ └── use-updater.ts # 版本更新
|
│ └── use-updater.ts # 版本更新
|
||||||
├── pages/ # 页面
|
├── pages/ # 页面
|
||||||
│ └── login/ # 登录/注册
|
│ ├── login/ # 登录/注册/忘记密码/修改密码
|
||||||
│ ├── index.tsx # LoginPage — Modal 弹层
|
│ │ ├── index.tsx # LoginPage — Modal 弹层
|
||||||
│ ├── components/ # LoginForm / RegisterForm / FormField
|
│ │ ├── components/ # LoginForm / RegisterForm / ForgotPasswordModal / ChangePasswordModal / FormField
|
||||||
│ ├── config/ # 字段配置 / 校验规则
|
│ │ ├── config/ # 字段配置 / 校验规则
|
||||||
│ ├── hooks/ # use-auth-state(记住账号偏好)
|
│ │ ├── hooks/ # use-auth-state / use-sms-cooldown
|
||||||
│ └── types.ts # LoginFormValues / RegisterFormValues
|
│ │ └── types.ts # LoginFormValues / RegisterFormValues / FieldConfig
|
||||||
|
│ └── settings/ # 设置面板(主题 / 存储 / 账号安全 / 系统信息 / 更新)
|
||||||
|
│ ├── SettingsPage.tsx # 设置 Modal 主页面
|
||||||
|
│ ├── index.ts # barrel export
|
||||||
|
│ └── blocks/ # 设置子区块组件
|
||||||
|
│ ├── ThemeSetting.tsx
|
||||||
|
│ ├── StoragePathSetting.tsx
|
||||||
|
│ ├── SystemInfoSetting.tsx
|
||||||
|
│ ├── UpdateSetting.tsx
|
||||||
|
│ └── PasswordSetting.tsx
|
||||||
├── services/ # HTTP 层 + API 模块
|
├── services/ # HTTP 层 + API 模块
|
||||||
│ ├── request.ts # Axios 封装(通用 HTTP 层,不知晓 auth)
|
│ ├── request.ts # Axios 封装(通用 HTTP 层,不知晓 auth)
|
||||||
│ ├── auth-token.ts # Token 生命周期(refresh_token 存取 + 401 刷新回调)
|
│ ├── auth-token.ts # Token 生命周期(refresh_token 存取 + 401 刷新回调)
|
||||||
|
|||||||
176
PROJECT.md
176
PROJECT.md
@@ -36,78 +36,78 @@
|
|||||||
遵循 Provider → Context → Comsumer 单向流动。组件内部状态使用 useState/useReducer,不污染全局 Context。
|
遵循 Provider → Context → Comsumer 单向流动。组件内部状态使用 useState/useReducer,不污染全局 Context。
|
||||||
|
|
||||||
- 事件驱动(EDA):
|
- 事件驱动(EDA):
|
||||||
1. 主进程与渲染进程通信基于 Electron IPC(ipcMain/ipcRenderer),定义明确的事件通道与载荷类型。
|
1. 主进程与渲染进程通信基于 Electron IPC(ipcMain/ipcRenderer),定义明确的事件通道与载荷类型。
|
||||||
|
|
||||||
2. 渲染进程内跨组件通信优先使用 Context;需要解耦的场景使用自定义事件总线(src/utils/event-bus.ts),
|
2. 渲染进程内跨组件通信优先使用 Context;需要解耦的场景使用自定义事件总线(src/utils/event-bus.ts),
|
||||||
所有 emit 调用自动记录到日志文件用于审计追踪。
|
所有 emit 调用自动记录到日志文件用于审计追踪。
|
||||||
|
|
||||||
3. 自定义错误体系(RequestError / RefreshError)通过事件总线解耦:请求拦截器抛出自定义错误 →
|
3. 自定义错误体系(RequestError / RefreshError)通过事件总线解耦:请求拦截器抛出自定义错误 →
|
||||||
全局捕获 → emit 事件 → UI 层响应。
|
全局捕获 → emit 事件 → UI 层响应。
|
||||||
|
|
||||||
4. 窗口间通信统一通过主进程转发(或使用 MessagePort 等 Electron 支持的机制),保持窗口独立性。
|
4. 窗口间通信统一通过主进程转发(或使用 MessagePort 等 Electron 支持的机制),保持窗口独立性。
|
||||||
|
|
||||||
- 路由与导航:主窗口为单页应用,使用 React Router v7+ 管理页面切换。**遵循 QT 多窗口模型映射原则**:
|
- 路由与导航:主窗口为单页应用,使用 React Router v7+ 管理页面切换。**遵循 QT 多窗口模型映射原则**:
|
||||||
- 叠加层(设置、公告、素材库等)→ Modal/Drawer + 事件总线驱动,主页面不卸载
|
- 叠加层(设置、公告、素材库等)→ Modal/Drawer + 事件总线驱动,主页面不卸载
|
||||||
- 页面跳转(真正的视图切换)→ React Router,需跨页保持的状态放 Provider 或 localStorage
|
- 页面跳转(真正的视图切换)→ React Router,需跨页保持的状态放 Provider 或 localStorage
|
||||||
|
|
||||||
- 辅助窗口(设置、预览、导入导出等)以 Modal/Drawer 叠加在主窗口之上,不与主窗口共享 DOM,但保持在同一个 React 组件树中以确保状态一致性。
|
- 辅助窗口(设置、预览、导入导出等)以 Modal/Drawer 叠加在主窗口之上,不与主窗口共享 DOM,但保持在同一个 React 组件树中以确保状态一致性。
|
||||||
|
|
||||||
# 目录结构
|
# 目录结构
|
||||||
|
|
||||||
```md
|
```md
|
||||||
├── electron/ # Electron 主进程
|
├── electron/ # Electron 主进程
|
||||||
│ ├── main.ts # 入口:窗口创建 / IPC 注册 / 生命周期
|
│ ├── main.ts # 入口:窗口创建 / IPC 注册 / 生命周期
|
||||||
│ ├── preload.ts # 预加载脚本(contextBridge)
|
│ ├── preload.ts # 预加载脚本(contextBridge)
|
||||||
│ └── main/
|
│ └── main/
|
||||||
│ ├── updater.ts # 自定义更新(API 版本检查 + OSS 下载 + NSIS 安装)
|
│ ├── updater.ts # 自定义更新(API 版本检查 + OSS 下载 + NSIS 安装)
|
||||||
│ ├── logger.ts # 主进程日志核心(JSON Lines 写入 / 轮转 / 清理)
|
│ ├── logger.ts # 主进程日志核心(JSON Lines 写入 / 轮转 / 清理)
|
||||||
│ ├── log-ipc.ts # 渲染进程日志 IPC 通道接收
|
│ ├── log-ipc.ts # 渲染进程日志 IPC 通道接收
|
||||||
│ ├── net-request.ts # Electron net.request 的 axios 风格封装
|
│ ├── net-request.ts # Electron net.request 的 axios 风格封装
|
||||||
│ ├── menu/index.ts # 系统菜单
|
│ ├── menu/index.ts # 系统菜单
|
||||||
│ └── utils/ # 平台判断 / 图标路径
|
│ └── utils/ # 平台判断 / 图标路径
|
||||||
├── src/ # 渲染进程(React 19 SPA)
|
├── src/ # 渲染进程(React 19 SPA)
|
||||||
│ ├── main.tsx # React 挂载点 + 全局错误捕获
|
│ ├── main.tsx # React 挂载点 + 全局错误捕获
|
||||||
│ ├── App.tsx # 根组件(路由 + 登录弹窗)
|
│ ├── App.tsx # 根组件(路由 + 登录弹窗)
|
||||||
│ ├── components/ # 通用 UI 组件 + Provider
|
│ ├── components/ # 通用 UI 组件 + Provider
|
||||||
│ │ ├── AppProvider.tsx # 全局状态(platform / edition / login)
|
│ │ ├── AppProvider.tsx # 全局状态(platform / edition / login)
|
||||||
│ │ ├── ThemeProvider.tsx # 主题状态(light / dark)
|
│ │ ├── ThemeProvider.tsx # 主题状态(light / dark)
|
||||||
│ │ ├── SettingsProvider.tsx # 设置状态(存储路径等)
|
│ │ ├── SettingsProvider.tsx # 设置状态(存储路径等)
|
||||||
│ │ ├── Layout.tsx # 页面壳(NavBar + Outlet)
|
│ │ ├── Layout.tsx # 页面壳(NavBar + Outlet)
|
||||||
│ │ └── navbar/ # 导航栏 + 公告抽屉
|
│ │ └── navbar/ # 导航栏 + 公告抽屉
|
||||||
│ ├── contexts/ # Context 定义(app / settings / theme)
|
│ ├── contexts/ # Context 定义(app / settings / theme)
|
||||||
│ ├── hooks/ # 自定义 Hooks(useUpdater / useTheme)
|
│ ├── hooks/ # 自定义 Hooks(useUpdater / useTheme)
|
||||||
│ ├── pages/ # 页面组件
|
│ ├── pages/ # 页面组件
|
||||||
│ │ ├── home/ # 首页仪表盘
|
│ │ ├── home/ # 首页仪表盘
|
||||||
│ │ ├── login/ # 登录 / 注册页
|
│ │ ├── login/ # 登录 / 注册页
|
||||||
│ │ └── settings/ # 设置面板(主题 / 存储 / 更新)
|
│ │ └── settings/ # 设置面板(主题 / 存储 / 账号安全 / 系统信息 / 更新)
|
||||||
│ ├── router/index.tsx # HashRouter 路由配置
|
│ ├── router/index.tsx # HashRouter 路由配置
|
||||||
│ ├── services/ # HTTP 请求封装
|
│ ├── services/ # HTTP 请求封装
|
||||||
│ │ ├── request.ts # Axios 封装 + 拦截器 + Token 刷新
|
│ │ ├── request.ts # Axios 封装 + 拦截器 + Token 刷新
|
||||||
│ │ ├── types.ts # 自定义错误类型(RequestError / RefreshError)
|
│ │ ├── types.ts # 自定义错误类型(RequestError / RefreshError)
|
||||||
│ │ ├── auth-token.ts # refresh_token 管理
|
│ │ ├── auth-token.ts # refresh_token 管理
|
||||||
│ │ └── modules/ # API 模块(auth / announcement)
|
│ │ └── modules/ # API 模块(auth / announcement)
|
||||||
│ ├── theme/ # 主题系统
|
│ ├── theme/ # 主题系统
|
||||||
│ │ ├── tokens.ts # 设计令牌
|
│ │ ├── tokens.ts # 设计令牌
|
||||||
│ │ └── globals.css # Tailwind + 全局样式
|
│ │ └── globals.css # Tailwind + 全局样式
|
||||||
│ └── utils/ # 纯函数工具
|
│ └── utils/ # 纯函数工具
|
||||||
│ ├── event-bus.ts # 事件总线(on / off / emit + 日志钩子)
|
│ ├── event-bus.ts # 事件总线(on / off / emit + 日志钩子)
|
||||||
│ ├── logger.ts # 渲染进程日志器(IPC 转发 / DEV 控制台)
|
│ ├── logger.ts # 渲染进程日志器(IPC 转发 / DEV 控制台)
|
||||||
│ ├── ipc.ts # 安全 IPC 守卫(safeIpcOn / send / invoke)
|
│ ├── ipc.ts # 安全 IPC 守卫(safeIpcOn / send / invoke)
|
||||||
│ ├── platform.ts # 平台判定
|
│ ├── platform.ts # 平台判定
|
||||||
│ └── device.ts # 设备 ID 生成
|
│ └── device.ts # 设备 ID 生成
|
||||||
├── shared/ # 主进程 & 渲染进程共享
|
├── shared/ # 主进程 & 渲染进程共享
|
||||||
│ ├── types/ # 类型定义(API / IPC / update / logging)
|
│ ├── types/ # 类型定义(API / IPC / update / logging)
|
||||||
│ ├── constants/ # 常量(IPC 通道 / version / app)
|
│ ├── constants/ # 常量(IPC 通道 / version / app)
|
||||||
│ └── utils/ # 共享工具
|
│ └── utils/ # 共享工具
|
||||||
│ ├── index.ts # debounce / throttle / compareVersions
|
│ ├── index.ts # debounce / throttle / compareVersions
|
||||||
│ └── sanitize.ts # 日志脱敏工具(ID / 邮箱 / 手机 / 名称)
|
│ └── sanitize.ts # 日志脱敏工具(ID / 邮箱 / 手机 / 名称)
|
||||||
├── scripts/ # 构建脚本
|
├── scripts/ # 构建脚本
|
||||||
│ ├── build.mjs # 构建总管
|
│ ├── build.mjs # 构建总管
|
||||||
│ ├── clean.mjs # 清理产物
|
│ ├── clean.mjs # 清理产物
|
||||||
│ ├── upload-oss.mjs # OSS 上传
|
│ ├── upload-oss.mjs # OSS 上传
|
||||||
│ ├── publish-release.mjs # 发布到 API
|
│ ├── publish-release.mjs # 发布到 API
|
||||||
│ └── generate-update-info.mjs # 生成更新元信息
|
│ └── generate-update-info.mjs # 生成更新元信息
|
||||||
├── package.json # 含 electron-builder 打包配置
|
├── package.json # 含 electron-builder 打包配置
|
||||||
├── vite.config.ts
|
├── vite.config.ts
|
||||||
├── tsconfig.json
|
├── tsconfig.json
|
||||||
└── tailwind.config.ts
|
└── tailwind.config.ts
|
||||||
@@ -181,14 +181,14 @@
|
|||||||
## Token 加密存储
|
## Token 加密存储
|
||||||
|
|
||||||
- `access_token` 和 `refresh_token` 通过 Electron `safeStorage` API 加密存储,不落明文:
|
- `access_token` 和 `refresh_token` 通过 Electron `safeStorage` API 加密存储,不落明文:
|
||||||
- macOS → Keychain
|
- macOS → Keychain
|
||||||
- Windows → DPAPI
|
- Windows → DPAPI
|
||||||
- Linux → libsecret
|
- Linux → libsecret
|
||||||
- 实现文件:
|
- 实现文件:
|
||||||
- `electron/main/safe-storage-ipc.ts` — 主进程加密/解密 IPC 处理(`BIDIRECTIONAL.SAFESTORAGE_ENCRYPT / DECRYPT`)
|
- `electron/main/safe-storage-ipc.ts` — 主进程加密/解密 IPC 处理(`BIDIRECTIONAL.SAFESTORAGE_ENCRYPT / DECRYPT`)
|
||||||
- `src/utils/safe-storage.ts` — 渲染进程桥接层(`initSafeStore / getSafeStore / setSafeStore / clearSafeStore`)
|
- `src/utils/safe-storage.ts` — 渲染进程桥接层(`initSafeStore / getSafeStore / setSafeStore / clearSafeStore`)
|
||||||
- `src/services/request.ts` — token 存取通过 `getSafeStore / setSafeStore`,无需感知加密细节
|
- `src/services/request.ts` — token 存取通过 `getSafeStore / setSafeStore`,无需感知加密细节
|
||||||
- `src/services/auth-token.ts` — refresh_token 同理通过 safeStorage 存取
|
- `src/services/auth-token.ts` — refresh_token 同理通过 safeStorage 存取
|
||||||
|
|
||||||
## 双 Token 机制
|
## 双 Token 机制
|
||||||
|
|
||||||
@@ -261,25 +261,27 @@
|
|||||||
└─ 下载完成 → 用户点击"安装更新" → NSIS/DMG/AppImage 执行
|
└─ 下载完成 → 用户点击"安装更新" → NSIS/DMG/AppImage 执行
|
||||||
```
|
```
|
||||||
|
|
||||||
| 模块 | 位置 | 职责 |
|
| 模块 | 位置 | 职责 |
|
||||||
|------|------|------|
|
|-------------------|-----------------------------------------------|------------------------------------------|
|
||||||
| `HeiXiuProvider` | `electron/main/updater.ts` | 自定义 Provider,调用自有 API,无更新返回 APP_VERSION |
|
| `HeiXiuProvider` | `electron/main/updater.ts` | 自定义 Provider,调用自有 API,无更新返回 APP_VERSION |
|
||||||
| `checkForUpdates` | `electron/main/updater.ts` | 预检版本 → 无更新直发 IPC / 有更新交 electron-updater |
|
| `checkForUpdates` | `electron/main/updater.ts` | 预检版本 → 无更新直发 IPC / 有更新交 electron-updater |
|
||||||
| `downloadUpdate` | `electron/main/updater.ts` | 用户确认后触发下载(从 checkForUpdates 分离) |
|
| `downloadUpdate` | `electron/main/updater.ts` | 用户确认后触发下载(从 checkForUpdates 分离) |
|
||||||
| `netRequest` | `electron/main/net-request.ts` | Electron net.request 的 axios 风格封装 |
|
| `netRequest` | `electron/main/net-request.ts` | Electron net.request 的 axios 风格封装 |
|
||||||
| `useUpdater` | `src/hooks/use-updater.ts` | 渲染进程更新状态 Hook(单例 IPC 监听) |
|
| `useUpdater` | `src/hooks/use-updater.ts` | 渲染进程更新状态 Hook(单例 IPC 监听) |
|
||||||
| `UpdateSetting` | `src/pages/settings/UpdateSetting.tsx` | 设置页更新区块(含"查看详情"弹窗) |
|
| `UpdateSetting` | `src/pages/settings/blocks/UpdateSetting.tsx` | 设置页更新区块(含"查看详情"弹窗) |
|
||||||
| `server.py` | `test-server/server.py` | 本地测试服务器,扫描 release/ 目录,语义版本排序 |
|
| `server.py` | `test-server/server.py` | 本地测试服务器,扫描 release/ 目录,语义版本排序 |
|
||||||
|
|
||||||
## 增量更新(差分下载)
|
## 增量更新(差分下载)
|
||||||
|
|
||||||
- `package.json` 配置 `"differentialPackage": true`,electron-builder 打包时自动生成 `.exe.blockmap` 文件
|
- `package.json` 配置 `"differentialPackage": true`,electron-builder 打包时自动生成 `.exe.blockmap` 文件
|
||||||
- 自定义 Provider 模式:`HeiXiuNsisUpdater extends NsisUpdater` / `HeiXiuMacUpdater extends MacUpdater` / `HeiXiuAppImageUpdater extends AppImageUpdater`
|
- 自定义 Provider 模式:`HeiXiuNsisUpdater extends NsisUpdater` / `HeiXiuMacUpdater extends MacUpdater` /
|
||||||
- 三平台 Updater 均覆盖 `getUpdateInfoAndProvider()` 注入 `HeiXiuProvider`(自定义 API 版本检查),差分下载由 electron-updater 父类原生处理
|
`HeiXiuAppImageUpdater extends AppImageUpdater`
|
||||||
|
- 三平台 Updater 均覆盖 `getUpdateInfoAndProvider()` 注入 `HeiXiuProvider`(自定义 API 版本检查),差分下载由
|
||||||
|
electron-updater 父类原生处理
|
||||||
- blockmap 文件流程:
|
- blockmap 文件流程:
|
||||||
- `scripts/generate-update-info.mjs` — 扫描 blockmap 文件,写 `blockmap.url` + `blockmap.size` 到 `update-info.json`
|
- `scripts/generate-update-info.mjs` — 扫描 blockmap 文件,写 `blockmap.url` + `blockmap.size` 到 `update-info.json`
|
||||||
- `scripts/upload-oss.mjs` — 安装包 + blockmap 文件一并上传 OSS(支持 Node.js 直传和 ossutil 两种模式)
|
- `scripts/upload-oss.mjs` — 安装包 + blockmap 文件一并上传 OSS(支持 Node.js 直传和 ossutil 两种模式)
|
||||||
- `scripts/publish-release.mjs` — 通过 `--blockmap-url` 参数将 blockmap 信息发布到后端 API
|
- `scripts/publish-release.mjs` — 通过 `--blockmap-url` 参数将 blockmap 信息发布到后端 API
|
||||||
- 效果:日常更新仅下载变更区块,预计节省 60%~90% 流量
|
- 效果:日常更新仅下载变更区块,预计节省 60%~90% 流量
|
||||||
|
|
||||||
## 环境变量加载机制(主进程)
|
## 环境变量加载机制(主进程)
|
||||||
@@ -293,11 +295,11 @@
|
|||||||
④ updater.ts 硬编码默认值(https://www.heixiu.com)
|
④ updater.ts 硬编码默认值(https://www.heixiu.com)
|
||||||
```
|
```
|
||||||
|
|
||||||
| 文件 | 何时生效 | 进 git | 用途 |
|
| 文件 | 何时生效 | 进 git | 用途 |
|
||||||
|------|---------|:------:|------|
|
|--------------------|---------------------|:-----:|--------------------|
|
||||||
| `.env.development` | `npm run dev` | ✅ | 本地开发(API → 测试服务器) |
|
| `.env.development` | `npm run dev` | ✅ | 本地开发(API → 测试服务器) |
|
||||||
| `.env.production` | `npm run build:xxx` | ❌ | 构建时参考(API → 生产服务器) |
|
| `.env.production` | `npm run build:xxx` | ❌ | 构建时参考(API → 生产服务器) |
|
||||||
| `.env.local` | 始终(后加载) | ❌ | 个人特殊配置(打包后连本地测试) |
|
| `.env.local` | 始终(后加载) | ❌ | 个人特殊配置(打包后连本地测试) |
|
||||||
|
|
||||||
> `.env.production` 和 `.env.local` 不打包进 ASAR。打包后无这些文件时自动使用代码默认值。
|
> `.env.production` 和 `.env.local` 不打包进 ASAR。打包后无这些文件时自动使用代码默认值。
|
||||||
|
|
||||||
|
|||||||
56
README.md
56
README.md
@@ -6,13 +6,13 @@
|
|||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
| 层 | 技术 |
|
| 层 | 技术 |
|
||||||
| ---- | ------------------------------ |
|
|----|--------------------------------|
|
||||||
| 框架 | Electron 42 |
|
| 框架 | Electron 42 |
|
||||||
| 前端 | React 19 + TypeScript + Vite 8 |
|
| 前端 | React 19 + TypeScript + Vite 8 |
|
||||||
| UI | Ant Design 6 + Tailwind CSS 4 |
|
| UI | Ant Design 6 + Tailwind CSS 4 |
|
||||||
| 主题 | 蓝紫渐变(亮色 / 暗色) |
|
| 主题 | 蓝紫渐变(亮色 / 暗色) |
|
||||||
| 更新 | 自定义 API + 阿里云 OSS |
|
| 更新 | 自定义 API + 阿里云 OSS |
|
||||||
| 打包 | electron-builder + NSIS |
|
| 打包 | electron-builder + NSIS |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -67,14 +67,14 @@ node build.mjs --win --personal --sign --cert ./cert.pfx --cert-password xxx
|
|||||||
node build.mjs --win --personal --clean --build
|
node build.mjs --win --personal --clean --build
|
||||||
```
|
```
|
||||||
|
|
||||||
| 操作 | 说明 |
|
| 操作 | 说明 |
|
||||||
| ----------- | -------------------------------------------------------------- |
|
|-------------|---------------------------------------------------------|
|
||||||
| `--build` | 构建(默认:tsc + vite + electron-builder + update-info.json) |
|
| `--build` | 构建(默认:tsc + vite + electron-builder + update-info.json) |
|
||||||
| `--clean` | 构建前清理 release/ dist/ dist-electron/ |
|
| `--clean` | 构建前清理 release/ dist/ dist-electron/ |
|
||||||
| `--sign` | 代码签名(需配合 `--cert` 或 `CSC_LINK` 环境变量) |
|
| `--sign` | 代码签名(需配合 `--cert` 或 `CSC_LINK` 环境变量) |
|
||||||
| `--upload` | 上传安装包到 OSS |
|
| `--upload` | 上传安装包到 OSS |
|
||||||
| `--publish` | POST 版本信息到后端 API |
|
| `--publish` | POST 版本信息到后端 API |
|
||||||
| `--all` | 全流程 |
|
| `--all` | 全流程 |
|
||||||
|
|
||||||
### 工具命令
|
### 工具命令
|
||||||
|
|
||||||
@@ -143,18 +143,18 @@ ele-heixiu/
|
|||||||
|
|
||||||
主进程加载顺序:`.env.{mode}` → `.env.local` → OS 环境变量 → 代码默认值。
|
主进程加载顺序:`.env.{mode}` → `.env.local` → OS 环境变量 → 代码默认值。
|
||||||
|
|
||||||
| 变量 | 默认值 | 用途 |
|
| 变量 | 默认值 | 用途 |
|
||||||
| ----------------------- | ------------------------------------------------------ | --------------- |
|
|-------------------------|--------------------------------------------------------|---------------|
|
||||||
| `UPDATE_API_URL` | `https://www.heixiu.com` | 版本检查 API |
|
| `UPDATE_API_URL` | `https://www.heixiu.com` | 版本检查 API |
|
||||||
| `UPDATE_API_KEY` | — | 发布 API 鉴权 |
|
| `UPDATE_API_KEY` | — | 发布 API 鉴权 |
|
||||||
| `UPDATE_DOWNLOAD_BASE` | `https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases` | OSS 下载基地址 |
|
| `UPDATE_DOWNLOAD_BASE` | `https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases` | OSS 下载基地址 |
|
||||||
| `OSS_ENDPOINT` | — | OSS endpoint |
|
| `OSS_ENDPOINT` | — | OSS endpoint |
|
||||||
| `OSS_BUCKET` | — | OSS bucket |
|
| `OSS_BUCKET` | — | OSS bucket |
|
||||||
| `OSS_ACCESS_KEY_ID` | — | OSS AccessKey |
|
| `OSS_ACCESS_KEY_ID` | — | OSS AccessKey |
|
||||||
| `OSS_ACCESS_KEY_SECRET` | — | OSS Secret |
|
| `OSS_ACCESS_KEY_SECRET` | — | OSS Secret |
|
||||||
| `CSC_LINK` | — | 签名证书路径 |
|
| `CSC_LINK` | — | 签名证书路径 |
|
||||||
| `CSC_KEY_PASSWORD` | — | 签名证书密码 |
|
| `CSC_KEY_PASSWORD` | — | 签名证书密码 |
|
||||||
| `VITE_API_BASE_URL` | `http://8.160.179.64:8000`(dev) | 业务 API 基地址 |
|
| `VITE_API_BASE_URL` | `http://8.160.179.64:8000`(dev) | 业务 API 基地址 |
|
||||||
|
|
||||||
> **`.env.local`**(不进 git)用于个人特殊配置,如打包后覆盖 `UPDATE_API_URL` 指向本地测试服务器。
|
> **`.env.local`**(不进 git)用于个人特殊配置,如打包后覆盖 `UPDATE_API_URL` 指向本地测试服务器。
|
||||||
|
|
||||||
@@ -162,8 +162,8 @@ ele-heixiu/
|
|||||||
|
|
||||||
## 相关文档
|
## 相关文档
|
||||||
|
|
||||||
| 文档 | 面向 | 内容 |
|
| 文档 | 面向 | 内容 |
|
||||||
|------|------|------|
|
|--------------------------------------------------|-------|-----------------------|
|
||||||
| [PROJECT.md](./PROJECT.md) | 开发者 | 技术架构、更新系统、环境变量架构、测试流程 |
|
| [PROJECT.md](./PROJECT.md) | 开发者 | 技术架构、更新系统、环境变量架构、测试流程 |
|
||||||
| [UpdateA.md](./UpdateA.md) | 前端发版 | 构建命令、发版流程、代码签名 |
|
| [UpdateA.md](./UpdateA.md) | 前端发版 | 构建命令、发版流程、代码签名 |
|
||||||
| [test-server/README.md](./test-server/README.md) | 后端程序员 | API 规格、数据库表结构、实现清单 |
|
| [test-server/README.md](./test-server/README.md) | 后端程序员 | API 规格、数据库表结构、实现清单 |
|
||||||
|
|||||||
23
TODO.md
23
TODO.md
@@ -1,6 +1,6 @@
|
|||||||
# TODO — 船长·HeiXiu 待办与优化清单
|
# TODO — 船长·HeiXiu 待办与优化清单
|
||||||
|
|
||||||
> 最后更新:2026-06-07
|
> 最后更新:2026-06-08
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -17,12 +17,10 @@
|
|||||||
- [ ] 合规素材库页面(`/compliance-assets`)
|
- [ ] 合规素材库页面(`/compliance-assets`)
|
||||||
- [ ] 项目管理页面(`/projects`,企业版)
|
- [ ] 项目管理页面(`/projects`,企业版)
|
||||||
- [ ] 账户/会员/充值/消费记录/订单明细页面
|
- [ ] 账户/会员/充值/消费记录/订单明细页面
|
||||||
- [ ] 忘记密码流程
|
|
||||||
- [ ] 各页面暗色主题适配
|
- [ ] 各页面暗色主题适配
|
||||||
|
|
||||||
### 导航栏
|
### 导航栏
|
||||||
|
|
||||||
- [ ] 移除调试用的 `console.log`
|
|
||||||
- [ ] 导航配置中的 `action: 'external'` 统一管理(白名单校验)
|
- [ ] 导航配置中的 `action: 'external'` 统一管理(白名单校验)
|
||||||
- [ ] 未登录点击需登录项 → 登录成功后自动跳转到目标页
|
- [ ] 未登录点击需登录项 → 登录成功后自动跳转到目标页
|
||||||
|
|
||||||
@@ -61,14 +59,12 @@
|
|||||||
|
|
||||||
## ⚠️ 已知技术债务
|
## ⚠️ 已知技术债务
|
||||||
|
|
||||||
| 问题 | 说明 | 影响 |
|
| 问题 | 说明 | 影响 |
|
||||||
|-----------------|---------------------------------------|------------------|
|
|-----------------|----------------------------------------------------------------------------------------------------------------------|----------------|
|
||||||
| localStorage 依赖 | 所有持久化数据依赖 localStorage | 清理浏览器数据会丢失所有配置 |
|
| localStorage 依赖 | 所有持久化数据依赖 localStorage | 清理浏览器数据会丢失所有配置 |
|
||||||
| IPC 监听器 | useUpdater 已改为单例,其他 IPC hook 可能也有类似问题 | 长期运行可能内存泄漏 |
|
| IPC 监听器 | useUpdater 已改为单例,其他 IPC hook 可能也有类似问题 | 长期运行可能内存泄漏 |
|
||||||
| ~~主题切换状态重置~~ | ✅ 已修复 — 根因:设置页 `navigate('/settings')` 导致路由 `/*` 仅匹配 index,HomePage 被卸载重挂载。修复:SettingsPage 改为事件总线驱动的 Modal 居中叠加,不走路由 | ~~用户体验差~~ |
|
| `test.py` | 仓库根目录残留测试文件 | 无实际作用,应清理 |
|
||||||
| ~~三栏布局拖拽溢出~~ | ✅ 已修复 — 拖拽分隔条增加最大宽度约束(`maxLeft = containerWidth - 36 - 300 - rightWidth`)+ 视口缩小 ResizeObserver 自动收窄 | ~~布局可用性~~ |
|
| `null` 文件 | 仓库根目录名为 null 的文件 | 构建产物或误操作残留 |
|
||||||
| `test.py` | 仓库根目录残留测试文件 | 无实际作用,应清理 |
|
|
||||||
| `null` 文件 | 仓库根目录名为 null 的文件 | 构建产物或误操作残留 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -76,5 +72,6 @@
|
|||||||
|
|
||||||
1. **合规素材库页面**(功能待完善 — 按 QT 原版设计,以 Modal/Drawer 叠加而非路由跳转实现)
|
1. **合规素材库页面**(功能待完善 — 按 QT 原版设计,以 Modal/Drawer 叠加而非路由跳转实现)
|
||||||
2. **各页面 nav 导航补齐**(后续页面优先使用叠加模式,遵循 QT 多窗口模型 → 见 WORKLOG 2026-06-07)
|
2. **各页面 nav 导航补齐**(后续页面优先使用叠加模式,遵循 QT 多窗口模型 → 见 WORKLOG 2026-06-07)
|
||||||
3. **leftWidth / rightWidth / taskPage 加 localStorage 持久化**(UX 优化:刷新页面后保持用户偏好)
|
3. **leftWidth / rightWidth / taskPage 加 localStorage 持久化**(UX 优化:刷新页面后保持用户偏好;已通过路由去路由化解决组件卸载问题,但 localStorage 持久层仍未实现)
|
||||||
4. **轮播图关闭按钮状态优化**(当前为 session 级别 useState,可考虑加 localStorage 记录关闭时间戳,超过 N 天再显示)
|
4. **轮播图关闭按钮 localStorage 持久化**(关闭按钮已实现,待做:localStorage 记录关闭时间戳,超过 N 天再显示)
|
||||||
|
5. **短信发送接入人机验证**(后期优化,需后端配合:`sendSms` 接口增加 `captcha_token` 参数 + 前端滑块/图形验证组件。当前仅靠 60s 冷却按钮 + 后端限流,桌面端 ASAR 可解包 + DevTools 网络面板 + 代理抓包均能直接暴露 API,纯前端限制对脚本攻击无效)
|
||||||
|
|||||||
@@ -3,202 +3,201 @@
|
|||||||
// 数据来源:GET /api/v1/announcements
|
// 数据来源:GET /api/v1/announcements
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import {useEffect, useState, useCallback} from 'react';
|
||||||
import { Drawer, Card, Typography, Empty, Tag, Spin, Modal, Tooltip } from 'antd';
|
import {Drawer, Card, Typography, Empty, Tag, Spin, Modal, Tooltip} from 'antd';
|
||||||
import { NotificationOutlined } from '@ant-design/icons';
|
import {NotificationOutlined} from '@ant-design/icons';
|
||||||
|
|
||||||
import { fetchAnnouncements, type Announcement } from '@/services/modules';
|
import {fetchAnnouncements, type Announcement, AnnouncementListResponse} from '@/services/modules';
|
||||||
|
|
||||||
const { Text, Paragraph } = Typography;
|
const {Text, Paragraph} = Typography;
|
||||||
|
|
||||||
// ---------- 辅助 ----------
|
// ---------- 辅助 ----------
|
||||||
|
|
||||||
/** 公告类型 → Tag 颜色映射 */
|
/** 公告类型 → Tag 颜色映射 */
|
||||||
const typeColorMap: Record<string, string> = {
|
const typeColorMap: Record<string, string> = {
|
||||||
system: 'blue',
|
system: 'blue',
|
||||||
feature: 'purple',
|
feature: 'purple',
|
||||||
activity: 'orange',
|
activity: 'orange',
|
||||||
maintenance: 'red',
|
maintenance: 'red',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 公告类型 → 中文标签 */
|
/** 公告类型 → 中文标签 */
|
||||||
const typeLabelMap: Record<string, string> = {
|
const typeLabelMap: Record<string, string> = {
|
||||||
system: '系统',
|
system: '系统',
|
||||||
feature: '功能',
|
feature: '功能',
|
||||||
activity: '活动',
|
activity: '活动',
|
||||||
maintenance: '维护',
|
maintenance: '维护',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 格式化日期(YYYY-MM-DD HH:mm) */
|
/** 格式化日期(YYYY-MM-DD HH:mm) */
|
||||||
function formatDate(iso: string): string {
|
function formatDate(iso: string): string {
|
||||||
try {
|
try {
|
||||||
return new Date(iso).toLocaleDateString('zh-CN', {
|
return new Date(iso).toLocaleDateString('zh-CN', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return iso;
|
return iso;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 组件 ----------
|
// ---------- 组件 ----------
|
||||||
|
|
||||||
interface AnnouncementDrawerProps {
|
interface AnnouncementDrawerProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AnnouncementDrawer({ open, onClose }: AnnouncementDrawerProps) {
|
export function AnnouncementDrawer({open, onClose}: AnnouncementDrawerProps) {
|
||||||
const [list, setList] = useState<Announcement[]>([]);
|
const [list, setList] = useState<Announcement[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// 详情 Modal
|
// 详情 Modal
|
||||||
const [detail, setDetail] = useState<Announcement | null>(null);
|
const [detail, setDetail] = useState<Announcement | null>(null);
|
||||||
|
|
||||||
// 打开抽屉时请求公告列表
|
// 打开抽屉时请求公告列表
|
||||||
const loadAnnouncements = useCallback(async () => {
|
const loadAnnouncements = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const data = await fetchAnnouncements();
|
const data: AnnouncementListResponse = await fetchAnnouncements();
|
||||||
// 仅展示有效公告
|
setList(data.filter((item: Announcement) => item.is_active));
|
||||||
setList(data.filter((item) => item.is_active));
|
} catch (err) {
|
||||||
} catch (err) {
|
setError((err as Error).message || '加载公告失败');
|
||||||
setError((err as Error).message || '加载公告失败');
|
setList([]);
|
||||||
setList([]);
|
} finally {
|
||||||
} finally {
|
setLoading(false);
|
||||||
setLoading(false);
|
}
|
||||||
}
|
}, []);
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
loadAnnouncements();
|
loadAnnouncements();
|
||||||
}
|
}
|
||||||
}, [open, loadAnnouncements]);
|
}, [open, loadAnnouncements]);
|
||||||
|
|
||||||
// ---- 渲染 ----
|
// ---- 渲染 ----
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Drawer
|
<Drawer
|
||||||
title={
|
title={
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<NotificationOutlined />
|
<NotificationOutlined />
|
||||||
公告
|
公告
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
placement="right"
|
placement="right"
|
||||||
size={380}
|
size={380}
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
styles={{ body: { padding: 0 } }}
|
styles={{body: {padding: 0}}}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<Spin description="加载中..." />
|
<Spin description="加载中..." />
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<Empty description={error} />
|
<Empty description={error} />
|
||||||
</div>
|
</div>
|
||||||
) : list.length === 0 ? (
|
) : list.length === 0 ? (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<Empty description="暂无公告" />
|
<Empty description="暂无公告" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-3 p-3">
|
<div className="flex flex-col gap-3 p-3">
|
||||||
{list.map((item) => (
|
{list.map((item:Announcement) => (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
key={item.id}
|
key={item.id}
|
||||||
title={
|
title={
|
||||||
item.content.length > 100
|
item.content.length > 100
|
||||||
? item.content.slice(0, 200) + '…'
|
? item.content.slice(0, 200) + '…'
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
placement="left"
|
placement="left"
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
size="small"
|
size="small"
|
||||||
className="rounded-md! border-0! shadow-sm! cursor-pointer hover:shadow-md! transition-shadow"
|
className="rounded-md! border-0! shadow-sm! cursor-pointer hover:shadow-md! transition-shadow"
|
||||||
title={
|
title={
|
||||||
<Text strong className="text-sm" ellipsis>
|
<Text strong className="text-sm" ellipsis>
|
||||||
{item.pinned && '📌 '}
|
{item.pinned && '📌 '}
|
||||||
{item.title}
|
{item.title}
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
extra={
|
extra={
|
||||||
<Tag
|
<Tag
|
||||||
color={typeColorMap[item.type] || 'default'}
|
color={typeColorMap[item.type] || 'default'}
|
||||||
className="m-0! text-xs"
|
className="m-0! text-xs"
|
||||||
>
|
>
|
||||||
{typeLabelMap[item.type] || item.type}
|
{typeLabelMap[item.type] || item.type}
|
||||||
</Tag>
|
</Tag>
|
||||||
}
|
}
|
||||||
onClick={() => setDetail(item)}
|
onClick={() => setDetail(item)}
|
||||||
>
|
>
|
||||||
<Paragraph
|
<Paragraph
|
||||||
className="mb-2! text-xs"
|
className="mb-2! text-xs"
|
||||||
type="secondary"
|
type="secondary"
|
||||||
ellipsis={{ rows: 2 }}
|
ellipsis={{rows: 2}}
|
||||||
>
|
>
|
||||||
{item.content}
|
{item.content}
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
<Text type="secondary" className="text-xs">
|
<Text type="secondary" className="text-xs">
|
||||||
{formatDate(item.created_at)}
|
{formatDate(item.created_at)}
|
||||||
</Text>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
{/* ======== 公告详情 Modal ======== */}
|
{/* ======== 公告详情 Modal ======== */}
|
||||||
<Modal
|
<Modal
|
||||||
open={!!detail}
|
open={!!detail}
|
||||||
onCancel={() => setDetail(null)}
|
onCancel={() => setDetail(null)}
|
||||||
footer={null}
|
footer={null}
|
||||||
width={480}
|
width={480}
|
||||||
centered
|
centered
|
||||||
title={
|
title={
|
||||||
detail && (
|
detail && (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
{detail.pinned && '📌 '}
|
{detail.pinned && '📌 '}
|
||||||
{detail.title}
|
{detail.title}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{detail && (
|
{detail && (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Tag
|
<Tag
|
||||||
color={typeColorMap[detail.type] || 'default'}
|
color={typeColorMap[detail.type] || 'default'}
|
||||||
className="m-0! text-xs"
|
className="m-0! text-xs"
|
||||||
>
|
>
|
||||||
{typeLabelMap[detail.type] || detail.type}
|
{typeLabelMap[detail.type] || detail.type}
|
||||||
</Tag>
|
</Tag>
|
||||||
<Text type="secondary" className="text-xs">
|
<Text type="secondary" className="text-xs">
|
||||||
{formatDate(detail.created_at)}
|
{formatDate(detail.created_at)}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-1 py-2 rounded-md"
|
<div className="px-1 py-2 rounded-md"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--color-fill-tertiary, rgba(0,0,0,0.04))',
|
background: 'var(--color-fill-tertiary, rgba(0,0,0,0.04))',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Paragraph className="mb-0! text-sm leading-relaxed whitespace-pre-wrap">
|
<Paragraph className="mb-0! text-sm leading-relaxed whitespace-pre-wrap">
|
||||||
{detail.content}
|
{detail.content}
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,133 +7,133 @@
|
|||||||
// - 登录/注册 与 退出登录 仍按登录状态互斥显示
|
// - 登录/注册 与 退出登录 仍按登录状态互斥显示
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useState, useCallback, useMemo } from 'react';
|
import {useState, useCallback, useMemo} from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import {useNavigate} from 'react-router-dom';
|
||||||
import { App } from 'antd';
|
import {App} from 'antd';
|
||||||
|
|
||||||
import { useAppContext } from '@/contexts/app-context';
|
import {useAppContext} from '@/contexts/app-context';
|
||||||
import { useTheme } from '@/hooks/use-theme';
|
import {useTheme} from '@/hooks/use-theme';
|
||||||
import { emit, EVENTS } from '@/utils/event-bus.ts';
|
import {emit, EVENTS} from '@/utils/event-bus.ts';
|
||||||
import { NavItem } from './NavItem';
|
import {NavItem} from './NavItem';
|
||||||
import { AnnouncementDrawer } from './AnnouncementDrawer';
|
import {AnnouncementDrawer} from './AnnouncementDrawer';
|
||||||
import { personalLeft, personalRight, enterpriseLeft, enterpriseRight } from './nav-config';
|
import {personalLeft, personalRight, enterpriseLeft, enterpriseRight} from './nav-config';
|
||||||
import type { NavItemConfig } from './types';
|
import type {NavItemConfig} from './types';
|
||||||
|
|
||||||
const NAVBAR_HEIGHT = 56;
|
const NAVBAR_HEIGHT = 56;
|
||||||
|
|
||||||
export function NavBar() {
|
export function NavBar() {
|
||||||
const { platform, edition, isLoggedIn, logout } = useAppContext();
|
const {platform, edition, isLoggedIn, logout} = useAppContext();
|
||||||
const { isDark } = useTheme();
|
const {isDark} = useTheme();
|
||||||
const { message } = App.useApp();
|
const {message} = App.useApp();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
|
||||||
// 根据版本选择导航项
|
// 根据版本选择导航项
|
||||||
const { leftItems, rightItems } = useMemo(() => {
|
const {leftItems, rightItems} = useMemo(() => {
|
||||||
const isEnterprise = edition === 'enterprise';
|
const isEnterprise = edition === 'enterprise';
|
||||||
return {
|
return {
|
||||||
leftItems: isEnterprise ? enterpriseLeft : personalLeft,
|
leftItems: isEnterprise ? enterpriseLeft : personalLeft,
|
||||||
rightItems: isEnterprise ? enterpriseRight : personalRight,
|
rightItems: isEnterprise ? enterpriseRight : personalRight,
|
||||||
};
|
};
|
||||||
}, [edition]);
|
}, [edition]);
|
||||||
|
|
||||||
// 过滤规则:
|
// 过滤规则:
|
||||||
// requireGuest → 仅未登录时可见(登录/注册)
|
// requireGuest → 仅未登录时可见(登录/注册)
|
||||||
// "退出登录" → 仅已登录时可见
|
// "退出登录" → 仅已登录时可见
|
||||||
// 其他所有项 → 始终可见,未登录点击时弹"请先登录"
|
// 其他所有项 → 始终可见,未登录点击时弹"请先登录"
|
||||||
const visibleLeft = useMemo(
|
const visibleLeft = useMemo(
|
||||||
() =>
|
() =>
|
||||||
leftItems.filter((item) => {
|
leftItems.filter((item) => {
|
||||||
if (item.requireGuest && isLoggedIn) return false;
|
if (item.requireGuest && isLoggedIn) return false;
|
||||||
return !(item.key === 'logout' && !isLoggedIn);
|
return !(item.key === 'logout' && !isLoggedIn);
|
||||||
}),
|
}),
|
||||||
[leftItems, isLoggedIn],
|
[leftItems, isLoggedIn],
|
||||||
);
|
);
|
||||||
const visibleRight = useMemo(
|
const visibleRight: NavItemConfig[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
rightItems.filter((item) => {
|
rightItems.filter((item) => {
|
||||||
if (item.requireGuest && isLoggedIn) return false;
|
if (item.requireGuest && isLoggedIn) return false;
|
||||||
return !(item.key === 'logout' && !isLoggedIn);
|
return !(item.key === 'logout' && !isLoggedIn);
|
||||||
}),
|
}),
|
||||||
[rightItems, isLoggedIn],
|
[rightItems, isLoggedIn],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 点击导航项
|
// 点击导航项
|
||||||
const handleNavClick = useCallback(
|
const handleNavClick = useCallback(
|
||||||
(item: NavItemConfig) => {
|
(item: NavItemConfig) => {
|
||||||
// 需要登录但未登录 → 提示并弹出登录 Modal
|
// 需要登录但未登录 → 提示并弹出登录 Modal
|
||||||
if (item.requireLogin && !isLoggedIn) {
|
if (item.requireLogin && !isLoggedIn) {
|
||||||
message.warning('请先登录');
|
message.warning('请先登录');
|
||||||
emit(EVENTS.SHOW_LOGIN);
|
emit(EVENTS.SHOW_LOGIN);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (item.action) {
|
switch (item.action) {
|
||||||
case 'drawer':
|
case 'drawer':
|
||||||
if (item.key === 'announcements') {
|
if (item.key === 'announcements') {
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'button':
|
case 'button':
|
||||||
if (item.key === 'logout') {
|
if (item.key === 'logout') {
|
||||||
logout();
|
logout();
|
||||||
} else if (item.key === 'settings') {
|
} else if (item.key === 'settings') {
|
||||||
emit(EVENTS.OPEN_SETTINGS);
|
emit(EVENTS.OPEN_SETTINGS);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'external':
|
case 'external':
|
||||||
if (item.target) {
|
if (item.target) {
|
||||||
window.open(item.target, '_blank');
|
window.open(item.target, '_blank');
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'navigate':
|
case 'navigate':
|
||||||
case 'spa':
|
case 'spa':
|
||||||
if (item.key === 'auth') {
|
if (item.key === 'auth') {
|
||||||
emit(EVENTS.SHOW_LOGIN);
|
emit(EVENTS.SHOW_LOGIN);
|
||||||
} else if (item.target) {
|
} else if (item.target) {
|
||||||
navigate(item.target);
|
navigate(item.target);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isLoggedIn, logout, message],
|
[isLoggedIn, logout, message],
|
||||||
);
|
);
|
||||||
|
|
||||||
const macPaddingLeft = platform === 'darwin' ? 80 : 12;
|
const macPaddingLeft = platform === 'darwin' ? 80 : 12;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="navbar-drag-region flex items-center justify-between select-none"
|
className="navbar-drag-region flex items-center justify-between select-none"
|
||||||
style={{
|
style={{
|
||||||
height: NAVBAR_HEIGHT,
|
height: NAVBAR_HEIGHT,
|
||||||
paddingLeft: macPaddingLeft,
|
paddingLeft: macPaddingLeft,
|
||||||
paddingRight: 12,
|
paddingRight: 12,
|
||||||
background: isDark
|
background: isDark
|
||||||
? 'linear-gradient(180deg, #1E1B4B 0%, #1A1730 100%)'
|
? 'linear-gradient(180deg, #1E1B4B 0%, #1A1730 100%)'
|
||||||
: 'linear-gradient(180deg, #FFFFFF 0%, #F5F3FF 100%)',
|
: 'linear-gradient(180deg, #FFFFFF 0%, #F5F3FF 100%)',
|
||||||
borderBottom: `1px solid ${isDark ? '#2E2A4A' : '#E5E7EB'}`,
|
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)',
|
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">
|
<div className="navbar-no-drag flex items-center gap-0.5">
|
||||||
{visibleLeft.map((item) => (
|
{visibleLeft.map((item) => (
|
||||||
<NavItem key={item.key} item={item} onClick={handleNavClick} />
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="navbar-no-drag flex items-center gap-0.5">
|
<AnnouncementDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
|
||||||
{visibleRight.map((item) => (
|
</>
|
||||||
<NavItem key={item.key} item={item} onClick={handleNavClick} />
|
);
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnnouncementDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1 @@
|
|||||||
export { NavBar } from './NavBar';
|
export { NavBar } from './NavBar';
|
||||||
export { AnnouncementDrawer } from './AnnouncementDrawer';
|
|
||||||
export type { NavItemConfig, NavBarConfig, NavAction } from './types';
|
|
||||||
|
|||||||
199
src/pages/login/components/ChangePasswordModal.tsx
Normal file
199
src/pages/login/components/ChangePasswordModal.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ChangePasswordModal — 修改密码弹窗(已登录用户)
|
||||||
|
//
|
||||||
|
// 场景:已登录用户通过旧密码验证后设置新密码
|
||||||
|
// 复用:可在设置页"修改密码"入口使用,也可在其他位置独立调用
|
||||||
|
//
|
||||||
|
// API:
|
||||||
|
// - AuthAPI.changePassword({ current_password, new_password })
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { Modal, Input, Button, Typography, App } from 'antd';
|
||||||
|
import { LockOutlined, KeyOutlined, SafetyOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { useTheme } from '@/hooks/use-theme.ts';
|
||||||
|
import { AuthAPI } from '@/services/modules';
|
||||||
|
import { validatePassword, validateConfirmPassword } from '../config/validators';
|
||||||
|
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
|
interface ChangePasswordModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChangePasswordModal({ open, onClose }: ChangePasswordModalProps) {
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
|
||||||
|
// ---- 表单字段 ----
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
|
||||||
|
// ---- 错误信息 ----
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// ---- 提交状态 ----
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// ---- 清除单个字段错误 ----
|
||||||
|
const clearError = useCallback((name: string) => {
|
||||||
|
setErrors((p) => {
|
||||||
|
if (!(name in p)) return p;
|
||||||
|
const n = { ...p };
|
||||||
|
delete n[name];
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ---- 提交 ----
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!currentPassword) {
|
||||||
|
newErrors.currentPassword = '请输入当前密码';
|
||||||
|
}
|
||||||
|
const pwdErr = validatePassword(newPassword);
|
||||||
|
if (pwdErr) newErrors.newPassword = pwdErr;
|
||||||
|
const confirmErr = validateConfirmPassword(confirmPassword, newPassword);
|
||||||
|
if (confirmErr) newErrors.confirmPassword = confirmErr;
|
||||||
|
|
||||||
|
if (Object.keys(newErrors).length > 0) {
|
||||||
|
setErrors(newErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors({});
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await AuthAPI.changePassword({
|
||||||
|
current_password: currentPassword,
|
||||||
|
new_password: newPassword,
|
||||||
|
});
|
||||||
|
message.success('密码修改成功');
|
||||||
|
// 重置表单
|
||||||
|
setCurrentPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
message.error((err as Error).message || '密码修改失败');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [currentPassword, newPassword, confirmPassword, onClose, message]);
|
||||||
|
|
||||||
|
// ---- 关闭时重置错误 ----
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setErrors({});
|
||||||
|
onClose();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onCancel={handleClose}
|
||||||
|
footer={null}
|
||||||
|
width={440}
|
||||||
|
centered
|
||||||
|
destroyOnHidden={true}
|
||||||
|
mask={{ closable: false }}
|
||||||
|
styles={{
|
||||||
|
body: {
|
||||||
|
padding: '24px 32px 16px',
|
||||||
|
background: isDark ? '#1A1730' : '#FFFFFF',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 标题 */}
|
||||||
|
<div className="text-center mb-5">
|
||||||
|
<Title level={5} className="!mb-1 gradient-primary-text">
|
||||||
|
修改密码
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" className="text-xs">
|
||||||
|
请输入当前密码并设置新密码
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 表单 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 当前密码 */}
|
||||||
|
<div>
|
||||||
|
<Input.Password
|
||||||
|
size="large"
|
||||||
|
prefix={<SafetyOutlined className="opacity-40" />}
|
||||||
|
placeholder="当前密码"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCurrentPassword(e.target.value);
|
||||||
|
clearError('currentPassword');
|
||||||
|
}}
|
||||||
|
status={errors.currentPassword ? 'error' : undefined}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
{errors.currentPassword && (
|
||||||
|
<Text type="danger" className="text-xs ml-1">
|
||||||
|
{errors.currentPassword}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 新密码 */}
|
||||||
|
<div>
|
||||||
|
<Input.Password
|
||||||
|
size="large"
|
||||||
|
prefix={<LockOutlined className="opacity-40" />}
|
||||||
|
placeholder="新密码(6~128位)"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNewPassword(e.target.value);
|
||||||
|
clearError('newPassword');
|
||||||
|
}}
|
||||||
|
status={errors.newPassword ? 'error' : undefined}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
{errors.newPassword && (
|
||||||
|
<Text type="danger" className="text-xs ml-1">
|
||||||
|
{errors.newPassword}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 确认新密码 */}
|
||||||
|
<div>
|
||||||
|
<Input.Password
|
||||||
|
size="large"
|
||||||
|
prefix={<KeyOutlined className="opacity-40" />}
|
||||||
|
placeholder="请再次输入新密码"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => {
|
||||||
|
setConfirmPassword(e.target.value);
|
||||||
|
clearError('confirmPassword');
|
||||||
|
}}
|
||||||
|
status={errors.confirmPassword ? 'error' : undefined}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
{errors.confirmPassword && (
|
||||||
|
<Text type="danger" className="text-xs ml-1">
|
||||||
|
{errors.confirmPassword}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 提交按钮 */}
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
size="large"
|
||||||
|
loading={submitting}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="h-11! text-base! font-medium! mt-2!"
|
||||||
|
>
|
||||||
|
确认修改
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
265
src/pages/login/components/ForgotPasswordModal.tsx
Normal file
265
src/pages/login/components/ForgotPasswordModal.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ForgotPasswordModal — 忘记密码弹窗
|
||||||
|
//
|
||||||
|
// 场景:未登录用户通过手机号 + 短信验证码重置密码
|
||||||
|
// 复用:可在登录页"忘记密码?"入口使用,也可在其他位置独立调用
|
||||||
|
//
|
||||||
|
// API:
|
||||||
|
// - AuthAPI.sendSms({ phone }) 发送短信验证码
|
||||||
|
// - AuthAPI.resetPassword({ phone, sms_code, new_password }) 重置密码
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { Modal, Input, Button, Typography, App } from 'antd';
|
||||||
|
import {
|
||||||
|
PhoneOutlined,
|
||||||
|
SafetyCertificateOutlined,
|
||||||
|
LockOutlined,
|
||||||
|
KeyOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { useTheme } from '@/hooks/use-theme.ts';
|
||||||
|
import { AuthAPI } from '@/services/modules';
|
||||||
|
import {
|
||||||
|
validatePhone,
|
||||||
|
validateSmsCode,
|
||||||
|
validatePassword,
|
||||||
|
validateConfirmPassword,
|
||||||
|
} from '../config/validators';
|
||||||
|
import { useSmsCooldown } from '../hooks/use-sms-cooldown';
|
||||||
|
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
|
interface ForgotPasswordModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ForgotPasswordModal({ open, onClose }: ForgotPasswordModalProps) {
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
|
||||||
|
// ---- 表单字段 ----
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [smsCode, setSmsCode] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
|
||||||
|
// ---- 错误信息 ----
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// ---- 提交状态 ----
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// ---- 短信冷却 ----
|
||||||
|
const { cooldown, secondsLeft, startCooldown } = useSmsCooldown(60);
|
||||||
|
|
||||||
|
// ---- 清除单个字段错误 ----
|
||||||
|
const clearError = useCallback((name: string) => {
|
||||||
|
setErrors((p) => {
|
||||||
|
if (!(name in p)) return p;
|
||||||
|
const n = { ...p };
|
||||||
|
delete n[name];
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ---- 发送验证码 ----
|
||||||
|
const handleSendSms = useCallback(() => {
|
||||||
|
const err = validatePhone(phone);
|
||||||
|
if (err) {
|
||||||
|
setErrors((p) => ({ ...p, phone: err }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearError('phone');
|
||||||
|
startCooldown();
|
||||||
|
AuthAPI.sendSms({ phone })
|
||||||
|
.then(() => message.success('验证码已发送'))
|
||||||
|
.catch((e) => message.error((e as Error).message || '验证码发送失败'));
|
||||||
|
}, [phone, startCooldown, clearError, message]);
|
||||||
|
|
||||||
|
// ---- 提交重置 ----
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
// 逐字段校验
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
const phoneErr = validatePhone(phone);
|
||||||
|
if (phoneErr) newErrors.phone = phoneErr;
|
||||||
|
const smsErr = validateSmsCode(smsCode);
|
||||||
|
if (smsErr) newErrors.smsCode = smsErr;
|
||||||
|
const pwdErr = validatePassword(newPassword);
|
||||||
|
if (pwdErr) newErrors.newPassword = pwdErr;
|
||||||
|
const confirmErr = validateConfirmPassword(confirmPassword, newPassword);
|
||||||
|
if (confirmErr) newErrors.confirmPassword = confirmErr;
|
||||||
|
|
||||||
|
if (Object.keys(newErrors).length > 0) {
|
||||||
|
setErrors(newErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors({});
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await AuthAPI.resetPassword({
|
||||||
|
phone,
|
||||||
|
sms_code: smsCode,
|
||||||
|
new_password: newPassword,
|
||||||
|
});
|
||||||
|
message.success('密码重置成功,请使用新密码登录');
|
||||||
|
// 重置表单状态
|
||||||
|
setPhone('');
|
||||||
|
setSmsCode('');
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
message.error((err as Error).message || '密码重置失败');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [phone, smsCode, newPassword, confirmPassword, onClose, message]);
|
||||||
|
|
||||||
|
// ---- 关闭时重置 ----
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setErrors({});
|
||||||
|
onClose();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onCancel={handleClose}
|
||||||
|
footer={null}
|
||||||
|
width={440}
|
||||||
|
centered
|
||||||
|
destroyOnHidden={true}
|
||||||
|
mask={{ closable: false }}
|
||||||
|
styles={{
|
||||||
|
body: {
|
||||||
|
padding: '24px 32px 16px',
|
||||||
|
background: isDark ? '#1A1730' : '#FFFFFF',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 标题 */}
|
||||||
|
<div className="text-center mb-5">
|
||||||
|
<Title level={5} className="!mb-1 gradient-primary-text">
|
||||||
|
重置密码
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" className="text-xs">
|
||||||
|
通过手机短信验证码重置您的密码
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 表单 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 手机号 */}
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
size="large"
|
||||||
|
prefix={<PhoneOutlined className="opacity-40" />}
|
||||||
|
placeholder="请输入手机号"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPhone(e.target.value);
|
||||||
|
clearError('phone');
|
||||||
|
}}
|
||||||
|
status={errors.phone ? 'error' : undefined}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
{errors.phone && (
|
||||||
|
<Text type="danger" className="text-xs ml-1">
|
||||||
|
{errors.phone}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 短信验证码 */}
|
||||||
|
<div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
size="large"
|
||||||
|
prefix={<SafetyCertificateOutlined className="opacity-40" />}
|
||||||
|
placeholder="请输入验证码"
|
||||||
|
value={smsCode}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSmsCode(e.target.value);
|
||||||
|
clearError('smsCode');
|
||||||
|
}}
|
||||||
|
status={errors.smsCode ? 'error' : undefined}
|
||||||
|
className="flex-1"
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="large"
|
||||||
|
type="primary"
|
||||||
|
disabled={cooldown}
|
||||||
|
onClick={handleSendSms}
|
||||||
|
className="text-xs! shrink-0 px-3!"
|
||||||
|
>
|
||||||
|
{cooldown ? `${secondsLeft}s` : '发送验证码'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{errors.smsCode && (
|
||||||
|
<Text type="danger" className="text-xs ml-1">
|
||||||
|
{errors.smsCode}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 新密码 */}
|
||||||
|
<div>
|
||||||
|
<Input.Password
|
||||||
|
size="large"
|
||||||
|
prefix={<LockOutlined className="opacity-40" />}
|
||||||
|
placeholder="新密码(6~128位)"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNewPassword(e.target.value);
|
||||||
|
clearError('newPassword');
|
||||||
|
}}
|
||||||
|
status={errors.newPassword ? 'error' : undefined}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
{errors.newPassword && (
|
||||||
|
<Text type="danger" className="text-xs ml-1">
|
||||||
|
{errors.newPassword}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 确认新密码 */}
|
||||||
|
<div>
|
||||||
|
<Input.Password
|
||||||
|
size="large"
|
||||||
|
prefix={<KeyOutlined className="opacity-40" />}
|
||||||
|
placeholder="请再次输入新密码"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => {
|
||||||
|
setConfirmPassword(e.target.value);
|
||||||
|
clearError('confirmPassword');
|
||||||
|
}}
|
||||||
|
status={errors.confirmPassword ? 'error' : undefined}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
{errors.confirmPassword && (
|
||||||
|
<Text type="danger" className="text-xs ml-1">
|
||||||
|
{errors.confirmPassword}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 提交按钮 */}
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
size="large"
|
||||||
|
loading={submitting}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="h-11! text-base! font-medium! mt-2!"
|
||||||
|
>
|
||||||
|
重置密码
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ interface LoginFormProps {
|
|||||||
onSubmit: (values: LoginFormValues) => void;
|
onSubmit: (values: LoginFormValues) => void;
|
||||||
onRememberChange: (checked: boolean) => void;
|
onRememberChange: (checked: boolean) => void;
|
||||||
onAutoLoginChange: (checked: boolean) => void;
|
onAutoLoginChange: (checked: boolean) => void;
|
||||||
|
onForgotPassword?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LoginForm({
|
export function LoginForm({
|
||||||
@@ -33,6 +34,7 @@ export function LoginForm({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
onRememberChange,
|
onRememberChange,
|
||||||
onAutoLoginChange,
|
onAutoLoginChange,
|
||||||
|
onForgotPassword,
|
||||||
}: LoginFormProps) {
|
}: LoginFormProps) {
|
||||||
const [username, setUsername] = useState(initialUsername || '');
|
const [username, setUsername] = useState(initialUsername || '');
|
||||||
const [password, setPassword] = useState(initialPassword || '');
|
const [password, setPassword] = useState(initialPassword || '');
|
||||||
@@ -147,12 +149,7 @@ export function LoginForm({
|
|||||||
自动登录
|
自动登录
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a className="text-xs" onClick={() => onForgotPassword?.()}>
|
||||||
className="text-xs"
|
|
||||||
onClick={() => {
|
|
||||||
/* TODO: 忘记密码流程 */
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
忘记密码?
|
忘记密码?
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
57
src/pages/login/hooks/use-sms-cooldown.ts
Normal file
57
src/pages/login/hooks/use-sms-cooldown.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// ============================================================
|
||||||
|
// useSmsCooldown — 短信验证码发送冷却 Hook
|
||||||
|
//
|
||||||
|
// 从 RegisterForm 提取,供所有需要短信验证码的场景复用:
|
||||||
|
// - 注册(RegisterForm)
|
||||||
|
// - 忘记密码(ForgotPasswordModal)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
export interface SmsCooldownState {
|
||||||
|
/** 是否在冷却中 */
|
||||||
|
cooldown: boolean;
|
||||||
|
/** 冷却剩余秒数 */
|
||||||
|
secondsLeft: number;
|
||||||
|
/** 开始冷却 */
|
||||||
|
startCooldown: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSmsCooldown(cooldownSeconds: number = 60): SmsCooldownState {
|
||||||
|
const [cooldown, setCooldown] = useState(false);
|
||||||
|
const [secondsLeft, setSecondsLeft] = useState(0);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
const startCooldown = useCallback(() => {
|
||||||
|
// 清理之前的计时器(防止重复点击导致多个计时器叠加)
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
}
|
||||||
|
setCooldown(true);
|
||||||
|
setSecondsLeft(cooldownSeconds);
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
setSecondsLeft((prev) => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
setCooldown(false);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
}, [cooldownSeconds]);
|
||||||
|
|
||||||
|
// 组件卸载时清理计时器
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { cooldown, secondsLeft, startCooldown };
|
||||||
|
}
|
||||||
@@ -14,8 +14,9 @@ import { useAppContext } from '@/contexts/app-context.ts';
|
|||||||
import { useTheme } from '@/hooks/use-theme.ts';
|
import { useTheme } from '@/hooks/use-theme.ts';
|
||||||
import { LoginForm } from './components/LoginForm';
|
import { LoginForm } from './components/LoginForm';
|
||||||
import { RegisterForm } from './components/RegisterForm';
|
import { RegisterForm } from './components/RegisterForm';
|
||||||
|
import { ForgotPasswordModal } from './components/ForgotPasswordModal';
|
||||||
import { useAuthState } from './hooks/use-auth-state';
|
import { useAuthState } from './hooks/use-auth-state';
|
||||||
import {AuthAPI, LoginResponseBody} from '@/services/modules';
|
import { AuthAPI, LoginResponseBody } from '@/services/modules';
|
||||||
import { getDeviceId } from '@/utils/device';
|
import { getDeviceId } from '@/utils/device';
|
||||||
import type { LoginFormValues, RegisterFormValues } from './types';
|
import type { LoginFormValues, RegisterFormValues } from './types';
|
||||||
|
|
||||||
@@ -27,13 +28,13 @@ interface LoginPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LoginPage({ open, onClose }: LoginPageProps) {
|
export function LoginPage({ open, onClose }: LoginPageProps) {
|
||||||
const { edition, login } = useAppContext();
|
const { login } = useAppContext();
|
||||||
const { isDark } = useTheme();
|
const { isDark } = useTheme();
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
|
|
||||||
const isEnterprise = edition === 'enterprise';
|
|
||||||
const [activeTab, setActiveTab] = useState<string>('login');
|
const [activeTab, setActiveTab] = useState<string>('login');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [forgotOpen, setForgotOpen] = useState(false);
|
||||||
|
|
||||||
const authState = useAuthState();
|
const authState = useAuthState();
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
|
|||||||
async (values: LoginFormValues) => {
|
async (values: LoginFormValues) => {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const auth:LoginResponseBody = await AuthAPI.login({
|
const auth: LoginResponseBody = await AuthAPI.login({
|
||||||
username: values.username,
|
username: values.username,
|
||||||
password: values.password,
|
password: values.password,
|
||||||
device_id: getDeviceId(),
|
device_id: getDeviceId(),
|
||||||
@@ -117,6 +118,7 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
|
|||||||
onSubmit={handleLogin}
|
onSubmit={handleLogin}
|
||||||
onRememberChange={authState.handleRememberChange}
|
onRememberChange={authState.handleRememberChange}
|
||||||
onAutoLoginChange={authState.handleAutoLoginChange}
|
onAutoLoginChange={authState.handleAutoLoginChange}
|
||||||
|
onForgotPassword={() => setForgotOpen(true)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -125,37 +127,34 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<>
|
||||||
open={open}
|
<Modal
|
||||||
onCancel={onClose}
|
open={open}
|
||||||
footer={null}
|
onCancel={onClose}
|
||||||
width={440}
|
footer={null}
|
||||||
centered
|
width={440}
|
||||||
destroyOnHidden={false}
|
centered
|
||||||
mask={{
|
destroyOnHidden={false}
|
||||||
closable: false,
|
mask={{
|
||||||
}}
|
closable: false,
|
||||||
styles={{
|
}}
|
||||||
body: {
|
styles={{
|
||||||
padding: '24px 32px 16px',
|
body: {
|
||||||
background: isDark ? '#1A1730' : '#FFFFFF',
|
padding: '24px 32px 16px',
|
||||||
},
|
background: isDark ? '#1A1730' : '#FFFFFF',
|
||||||
}}
|
},
|
||||||
>
|
}}
|
||||||
{/* 标题 */}
|
>
|
||||||
<div className="text-center mb-5">
|
{/* 标题 */}
|
||||||
<Text strong className="text-lg gradient-primary-text block">
|
<div className="text-center mb-5">
|
||||||
船长 · HeiXiu
|
<Text strong className="text-lg gradient-primary-text block">
|
||||||
</Text>
|
船长 · HeiXiu
|
||||||
<Text type="secondary" className="text-xs">
|
</Text>
|
||||||
{isEnterprise ? '企业版 — 员工账号登录' : '个人版'}
|
<Text type="secondary" className="text-xs">
|
||||||
</Text>
|
欢迎使用船长·HeiXiu
|
||||||
</div>
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 个人版 Tab | 企业版仅登录 */}
|
|
||||||
{isEnterprise ? (
|
|
||||||
loginTab
|
|
||||||
) : (
|
|
||||||
<Tabs
|
<Tabs
|
||||||
activeKey={activeTab}
|
activeKey={activeTab}
|
||||||
onChange={setActiveTab}
|
onChange={setActiveTab}
|
||||||
@@ -166,7 +165,10 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
|
|||||||
{ key: 'register', label: '注册', children: registerTab },
|
{ key: 'register', label: '注册', children: registerTab },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
</Modal>
|
||||||
</Modal>
|
|
||||||
|
{/* 忘记密码弹窗 */}
|
||||||
|
<ForgotPasswordModal open={forgotOpen} onClose={() => setForgotOpen(false)} />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,11 @@
|
|||||||
import { Modal } from 'antd';
|
import { Modal } from 'antd';
|
||||||
import { SettingOutlined } from '@ant-design/icons';
|
import { SettingOutlined } from '@ant-design/icons';
|
||||||
import { useAppContext } from '@/contexts/app-context';
|
import { useAppContext } from '@/contexts/app-context';
|
||||||
import { ThemeSetting } from './ThemeSetting';
|
import { ThemeSetting } from './blocks/ThemeSetting';
|
||||||
import { StoragePathSetting } from './StoragePathSetting';
|
import { StoragePathSetting } from './blocks/StoragePathSetting';
|
||||||
import { SystemInfoSetting } from './SystemInfoSetting';
|
import { SystemInfoSetting } from './blocks/SystemInfoSetting';
|
||||||
import { UpdateSetting } from './UpdateSetting';
|
import { UpdateSetting } from './blocks/UpdateSetting';
|
||||||
|
import { PasswordSetting } from './blocks/PasswordSetting';
|
||||||
|
|
||||||
interface SettingsPageProps {
|
interface SettingsPageProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -24,31 +25,32 @@ interface SettingsPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsPage({ open, onClose }: SettingsPageProps) {
|
export function SettingsPage({ open, onClose }: SettingsPageProps) {
|
||||||
const { edition } = useAppContext();
|
const { edition } = useAppContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
width={480}
|
width={480}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
destroyOnHidden={true}
|
destroyOnHidden={true}
|
||||||
mask={{ closable: false }}
|
mask={{ closable: false }}
|
||||||
keyboard={false}
|
keyboard={false}
|
||||||
footer={null}
|
footer={null}
|
||||||
title={
|
title={
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<SettingOutlined />
|
<SettingOutlined />
|
||||||
设置
|
设置
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
styles={{ body: { padding: 0, maxHeight: '70vh', overflow: 'auto' } }}
|
styles={{ body: { padding: 0, maxHeight: '70vh', overflow: 'auto' } }}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4 p-4">
|
<div className="flex flex-col gap-4 p-4">
|
||||||
<ThemeSetting />
|
<ThemeSetting />
|
||||||
<StoragePathSetting edition={edition} />
|
<StoragePathSetting edition={edition} />
|
||||||
<SystemInfoSetting />
|
<SystemInfoSetting />
|
||||||
<UpdateSetting />
|
<PasswordSetting />
|
||||||
</div>
|
<UpdateSetting />
|
||||||
</Modal>
|
</div>
|
||||||
);
|
</Modal>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
52
src/pages/settings/blocks/PasswordSetting.tsx
Normal file
52
src/pages/settings/blocks/PasswordSetting.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// ============================================================
|
||||||
|
// PasswordSetting — 账号安全区块(密码操作入口)
|
||||||
|
//
|
||||||
|
// 提供两种密码操作,用户自选:
|
||||||
|
// 1. 修改密码 — 通过当前密码验证后设置新密码
|
||||||
|
// 2. 忘记密码 — 通过手机短信验证码重置密码
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, Button, Typography } from 'antd';
|
||||||
|
import { LockOutlined, PhoneOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { ChangePasswordModal } from '@/pages/login/components/ChangePasswordModal';
|
||||||
|
import { ForgotPasswordModal } from '@/pages/login/components/ForgotPasswordModal';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
export function PasswordSetting() {
|
||||||
|
const [changePwdOpen, setChangePwdOpen] = useState(false);
|
||||||
|
const [forgotPwdOpen, setForgotPwdOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card size="small" title="账号安全" className="rounded-md!">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{/* 修改密码:通过旧密码 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Text type="secondary" className="text-sm">
|
||||||
|
通过当前密码修改
|
||||||
|
</Text>
|
||||||
|
<Button icon={<LockOutlined />} onClick={() => setChangePwdOpen(true)}>
|
||||||
|
修改密码
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 忘记密码:通过手机验证码重置 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Text type="secondary" className="text-sm">
|
||||||
|
忘记密码?通过手机验证码重置
|
||||||
|
</Text>
|
||||||
|
<Button icon={<PhoneOutlined />} onClick={() => setForgotPwdOpen(true)}>
|
||||||
|
短信验证码重置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ChangePasswordModal open={changePwdOpen} onClose={() => setChangePwdOpen(false)} />
|
||||||
|
<ForgotPasswordModal open={forgotPwdOpen} onClose={() => setForgotPwdOpen(false)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -159,8 +159,14 @@ function PathRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function StoragePathSetting({ edition }: StoragePathSettingProps) {
|
export function StoragePathSetting({ edition }: StoragePathSettingProps) {
|
||||||
const { outputPath, teamRepoPath, setOutputPath, setTeamRepoPath, clearOutputPath, clearTeamRepoPath } =
|
const {
|
||||||
useSettings();
|
outputPath,
|
||||||
|
teamRepoPath,
|
||||||
|
setOutputPath,
|
||||||
|
setTeamRepoPath,
|
||||||
|
clearOutputPath,
|
||||||
|
clearTeamRepoPath,
|
||||||
|
} = useSettings();
|
||||||
|
|
||||||
const isEnterprise = edition === 'enterprise';
|
const isEnterprise = edition === 'enterprise';
|
||||||
|
|
||||||
@@ -65,15 +65,8 @@ function renderChangelog(markdown: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function UpdateSetting() {
|
export function UpdateSetting() {
|
||||||
const {
|
const { status, info, progress, error, checkForUpdates, downloadUpdate, installUpdate } =
|
||||||
status,
|
useUpdater();
|
||||||
info,
|
|
||||||
progress,
|
|
||||||
error,
|
|
||||||
checkForUpdates,
|
|
||||||
downloadUpdate,
|
|
||||||
installUpdate,
|
|
||||||
} = useUpdater();
|
|
||||||
|
|
||||||
const [detailOpen, setDetailOpen] = useState(false);
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
|
||||||
@@ -210,15 +203,15 @@ export function UpdateSetting() {
|
|||||||
title={`更新详情 — v${info?.version || ''}`}
|
title={`更新详情 — v${info?.version || ''}`}
|
||||||
open={detailOpen}
|
open={detailOpen}
|
||||||
onCancel={() => setDetailOpen(false)}
|
onCancel={() => setDetailOpen(false)}
|
||||||
footer={
|
footer={<Button onClick={() => setDetailOpen(false)}>关闭</Button>}
|
||||||
<Button onClick={() => setDetailOpen(false)}>关闭</Button>
|
|
||||||
}
|
|
||||||
width={520}
|
width={520}
|
||||||
>
|
>
|
||||||
<div className="max-h-96 overflow-y-auto py-2">
|
<div className="max-h-96 overflow-y-auto py-2">
|
||||||
{info?.releaseNotes
|
{info?.releaseNotes ? (
|
||||||
? renderChangelog(info.releaseNotes)
|
renderChangelog(info.releaseNotes)
|
||||||
: <Text type="secondary">暂无更新详情</Text>}
|
) : (
|
||||||
|
<Text type="secondary">暂无更新详情</Text>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -3,7 +3,3 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
export { SettingsPage } from './SettingsPage';
|
export { SettingsPage } from './SettingsPage';
|
||||||
export { ThemeSetting } from './ThemeSetting';
|
|
||||||
export { StoragePathSetting } from './StoragePathSetting';
|
|
||||||
export { SystemInfoSetting } from './SystemInfoSetting';
|
|
||||||
export { UpdateSetting } from './UpdateSetting';
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {post, get} from '../request';
|
|||||||
const AuthUrlObj = {
|
const AuthUrlObj = {
|
||||||
login: '/api/v1/auth/login',
|
login: '/api/v1/auth/login',
|
||||||
register: '/api/v1/auth/register',
|
register: '/api/v1/auth/register',
|
||||||
sendSms: '/api/v1/auth/sms/send',
|
sendSms: '/api/v1/auth/send-sms',
|
||||||
refreshToken: '/api/v1/auth/refresh',
|
refreshToken: '/api/v1/auth/refresh',
|
||||||
userInfo: '/api/v1/auth/me',
|
userInfo: '/api/v1/auth/me',
|
||||||
changePassword: '/api/v1/auth/change-password',
|
changePassword: '/api/v1/auth/change-password',
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ const ModelsUrlObj = {
|
|||||||
// ---------- 类型 ----------
|
// ---------- 类型 ----------
|
||||||
|
|
||||||
/** 模型类别 */
|
/** 模型类别 */
|
||||||
export type ModelCategory = 'img2img' | 'txt2img' | 'img2video' | 'txt2audio';
|
export type ModelCategory = 'image_to_image' | 'text_to_image' | 'image_to_video' | 'text_to_audio';
|
||||||
|
|
||||||
/** 模型类别中文映射 */
|
/** 模型类别中文映射 */
|
||||||
export const MODEL_CATEGORY_LABELS: Record<ModelCategory, string> = {
|
export const MODEL_CATEGORY_LABELS: Record<ModelCategory, string> = {
|
||||||
img2img: '图生图',
|
image_to_image: '图生图',
|
||||||
txt2img: '文生图',
|
text_to_image: '文生图',
|
||||||
img2video: '图生视频',
|
image_to_video: '图生视频',
|
||||||
txt2audio: '文生音频',
|
text_to_audio: '文生音频',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user