diff --git a/.codegraph/daemon.pid b/.codegraph/daemon.pid index 82cfe9b..a65585c 100644 --- a/.codegraph/daemon.pid +++ b/.codegraph/daemon.pid @@ -1,6 +1,6 @@ { - "pid": 384720, + "pid": 568932, "version": "0.9.9", "socketPath": "\\\\.\\pipe\\codegraph-aef859f0205bacea", - "startedAt": 1780763058452 + "startedAt": 1780880691721 } diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d3eb3f1..58733c3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # 船长 · HeiXiu — 前端架构文档 -> 最后更新:2026-06-07 +> 最后更新:2026-06-08 ## 目录结构 @@ -17,12 +17,21 @@ src/ │ ├── use-theme.ts # 主题切换 │ └── use-updater.ts # 版本更新 ├── pages/ # 页面 -│ └── login/ # 登录/注册 -│ ├── index.tsx # LoginPage — Modal 弹层 -│ ├── components/ # LoginForm / RegisterForm / FormField -│ ├── config/ # 字段配置 / 校验规则 -│ ├── hooks/ # use-auth-state(记住账号偏好) -│ └── types.ts # LoginFormValues / RegisterFormValues +│ ├── login/ # 登录/注册/忘记密码/修改密码 +│ │ ├── index.tsx # LoginPage — Modal 弹层 +│ │ ├── components/ # LoginForm / RegisterForm / ForgotPasswordModal / ChangePasswordModal / FormField +│ │ ├── config/ # 字段配置 / 校验规则 +│ │ ├── hooks/ # use-auth-state / use-sms-cooldown +│ │ └── 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 模块 │ ├── request.ts # Axios 封装(通用 HTTP 层,不知晓 auth) │ ├── auth-token.ts # Token 生命周期(refresh_token 存取 + 401 刷新回调) diff --git a/PROJECT.md b/PROJECT.md index a3a53d4..adfae33 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -36,78 +36,78 @@ 遵循 Provider → Context → Comsumer 单向流动。组件内部状态使用 useState/useReducer,不污染全局 Context。 - 事件驱动(EDA): - 1. 主进程与渲染进程通信基于 Electron IPC(ipcMain/ipcRenderer),定义明确的事件通道与载荷类型。 + 1. 主进程与渲染进程通信基于 Electron IPC(ipcMain/ipcRenderer),定义明确的事件通道与载荷类型。 - 2. 渲染进程内跨组件通信优先使用 Context;需要解耦的场景使用自定义事件总线(src/utils/event-bus.ts), - 所有 emit 调用自动记录到日志文件用于审计追踪。 + 2. 渲染进程内跨组件通信优先使用 Context;需要解耦的场景使用自定义事件总线(src/utils/event-bus.ts), + 所有 emit 调用自动记录到日志文件用于审计追踪。 - 3. 自定义错误体系(RequestError / RefreshError)通过事件总线解耦:请求拦截器抛出自定义错误 → - 全局捕获 → emit 事件 → UI 层响应。 + 3. 自定义错误体系(RequestError / RefreshError)通过事件总线解耦:请求拦截器抛出自定义错误 → + 全局捕获 → emit 事件 → UI 层响应。 - 4. 窗口间通信统一通过主进程转发(或使用 MessagePort 等 Electron 支持的机制),保持窗口独立性。 + 4. 窗口间通信统一通过主进程转发(或使用 MessagePort 等 Electron 支持的机制),保持窗口独立性。 - 路由与导航:主窗口为单页应用,使用 React Router v7+ 管理页面切换。**遵循 QT 多窗口模型映射原则**: - - 叠加层(设置、公告、素材库等)→ Modal/Drawer + 事件总线驱动,主页面不卸载 - - 页面跳转(真正的视图切换)→ React Router,需跨页保持的状态放 Provider 或 localStorage + - 叠加层(设置、公告、素材库等)→ Modal/Drawer + 事件总线驱动,主页面不卸载 + - 页面跳转(真正的视图切换)→ React Router,需跨页保持的状态放 Provider 或 localStorage - 辅助窗口(设置、预览、导入导出等)以 Modal/Drawer 叠加在主窗口之上,不与主窗口共享 DOM,但保持在同一个 React 组件树中以确保状态一致性。 # 目录结构 ```md -├── electron/ # Electron 主进程 -│ ├── main.ts # 入口:窗口创建 / IPC 注册 / 生命周期 -│ ├── preload.ts # 预加载脚本(contextBridge) -│ └── main/ -│ ├── updater.ts # 自定义更新(API 版本检查 + OSS 下载 + NSIS 安装) -│ ├── logger.ts # 主进程日志核心(JSON Lines 写入 / 轮转 / 清理) -│ ├── log-ipc.ts # 渲染进程日志 IPC 通道接收 -│ ├── net-request.ts # Electron net.request 的 axios 风格封装 -│ ├── menu/index.ts # 系统菜单 -│ └── utils/ # 平台判断 / 图标路径 -├── src/ # 渲染进程(React 19 SPA) -│ ├── main.tsx # React 挂载点 + 全局错误捕获 -│ ├── App.tsx # 根组件(路由 + 登录弹窗) -│ ├── components/ # 通用 UI 组件 + Provider -│ │ ├── AppProvider.tsx # 全局状态(platform / edition / login) -│ │ ├── ThemeProvider.tsx # 主题状态(light / dark) -│ │ ├── SettingsProvider.tsx # 设置状态(存储路径等) -│ │ ├── Layout.tsx # 页面壳(NavBar + Outlet) -│ │ └── navbar/ # 导航栏 + 公告抽屉 -│ ├── contexts/ # Context 定义(app / settings / theme) -│ ├── hooks/ # 自定义 Hooks(useUpdater / useTheme) -│ ├── pages/ # 页面组件 -│ │ ├── home/ # 首页仪表盘 -│ │ ├── login/ # 登录 / 注册页 -│ │ └── settings/ # 设置面板(主题 / 存储 / 更新) -│ ├── router/index.tsx # HashRouter 路由配置 -│ ├── services/ # HTTP 请求封装 -│ │ ├── request.ts # Axios 封装 + 拦截器 + Token 刷新 -│ │ ├── types.ts # 自定义错误类型(RequestError / RefreshError) -│ │ ├── auth-token.ts # refresh_token 管理 -│ │ └── modules/ # API 模块(auth / announcement) -│ ├── theme/ # 主题系统 -│ │ ├── tokens.ts # 设计令牌 -│ │ └── globals.css # Tailwind + 全局样式 -│ └── utils/ # 纯函数工具 -│ ├── event-bus.ts # 事件总线(on / off / emit + 日志钩子) -│ ├── logger.ts # 渲染进程日志器(IPC 转发 / DEV 控制台) -│ ├── ipc.ts # 安全 IPC 守卫(safeIpcOn / send / invoke) -│ ├── platform.ts # 平台判定 -│ └── device.ts # 设备 ID 生成 -├── shared/ # 主进程 & 渲染进程共享 -│ ├── types/ # 类型定义(API / IPC / update / logging) -│ ├── constants/ # 常量(IPC 通道 / version / app) -│ └── utils/ # 共享工具 -│ ├── index.ts # debounce / throttle / compareVersions -│ └── sanitize.ts # 日志脱敏工具(ID / 邮箱 / 手机 / 名称) -├── scripts/ # 构建脚本 -│ ├── build.mjs # 构建总管 -│ ├── clean.mjs # 清理产物 -│ ├── upload-oss.mjs # OSS 上传 -│ ├── publish-release.mjs # 发布到 API -│ └── generate-update-info.mjs # 生成更新元信息 -├── package.json # 含 electron-builder 打包配置 +├── electron/ # Electron 主进程 +│ ├── main.ts # 入口:窗口创建 / IPC 注册 / 生命周期 +│ ├── preload.ts # 预加载脚本(contextBridge) +│ └── main/ +│ ├── updater.ts # 自定义更新(API 版本检查 + OSS 下载 + NSIS 安装) +│ ├── logger.ts # 主进程日志核心(JSON Lines 写入 / 轮转 / 清理) +│ ├── log-ipc.ts # 渲染进程日志 IPC 通道接收 +│ ├── net-request.ts # Electron net.request 的 axios 风格封装 +│ ├── menu/index.ts # 系统菜单 +│ └── utils/ # 平台判断 / 图标路径 +├── src/ # 渲染进程(React 19 SPA) +│ ├── main.tsx # React 挂载点 + 全局错误捕获 +│ ├── App.tsx # 根组件(路由 + 登录弹窗) +│ ├── components/ # 通用 UI 组件 + Provider +│ │ ├── AppProvider.tsx # 全局状态(platform / edition / login) +│ │ ├── ThemeProvider.tsx # 主题状态(light / dark) +│ │ ├── SettingsProvider.tsx # 设置状态(存储路径等) +│ │ ├── Layout.tsx # 页面壳(NavBar + Outlet) +│ │ └── navbar/ # 导航栏 + 公告抽屉 +│ ├── contexts/ # Context 定义(app / settings / theme) +│ ├── hooks/ # 自定义 Hooks(useUpdater / useTheme) +│ ├── pages/ # 页面组件 +│ │ ├── home/ # 首页仪表盘 +│ │ ├── login/ # 登录 / 注册页 +│ │ └── settings/ # 设置面板(主题 / 存储 / 账号安全 / 系统信息 / 更新) +│ ├── router/index.tsx # HashRouter 路由配置 +│ ├── services/ # HTTP 请求封装 +│ │ ├── request.ts # Axios 封装 + 拦截器 + Token 刷新 +│ │ ├── types.ts # 自定义错误类型(RequestError / RefreshError) +│ │ ├── auth-token.ts # refresh_token 管理 +│ │ └── modules/ # API 模块(auth / announcement) +│ ├── theme/ # 主题系统 +│ │ ├── tokens.ts # 设计令牌 +│ │ └── globals.css # Tailwind + 全局样式 +│ └── utils/ # 纯函数工具 +│ ├── event-bus.ts # 事件总线(on / off / emit + 日志钩子) +│ ├── logger.ts # 渲染进程日志器(IPC 转发 / DEV 控制台) +│ ├── ipc.ts # 安全 IPC 守卫(safeIpcOn / send / invoke) +│ ├── platform.ts # 平台判定 +│ └── device.ts # 设备 ID 生成 +├── shared/ # 主进程 & 渲染进程共享 +│ ├── types/ # 类型定义(API / IPC / update / logging) +│ ├── constants/ # 常量(IPC 通道 / version / app) +│ └── utils/ # 共享工具 +│ ├── index.ts # debounce / throttle / compareVersions +│ └── sanitize.ts # 日志脱敏工具(ID / 邮箱 / 手机 / 名称) +├── scripts/ # 构建脚本 +│ ├── build.mjs # 构建总管 +│ ├── clean.mjs # 清理产物 +│ ├── upload-oss.mjs # OSS 上传 +│ ├── publish-release.mjs # 发布到 API +│ └── generate-update-info.mjs # 生成更新元信息 +├── package.json # 含 electron-builder 打包配置 ├── vite.config.ts ├── tsconfig.json └── tailwind.config.ts @@ -181,14 +181,14 @@ ## Token 加密存储 - `access_token` 和 `refresh_token` 通过 Electron `safeStorage` API 加密存储,不落明文: - - macOS → Keychain - - Windows → DPAPI - - Linux → libsecret + - macOS → Keychain + - Windows → DPAPI + - Linux → libsecret - 实现文件: - - `electron/main/safe-storage-ipc.ts` — 主进程加密/解密 IPC 处理(`BIDIRECTIONAL.SAFESTORAGE_ENCRYPT / DECRYPT`) - - `src/utils/safe-storage.ts` — 渲染进程桥接层(`initSafeStore / getSafeStore / setSafeStore / clearSafeStore`) - - `src/services/request.ts` — token 存取通过 `getSafeStore / setSafeStore`,无需感知加密细节 - - `src/services/auth-token.ts` — refresh_token 同理通过 safeStorage 存取 + - `electron/main/safe-storage-ipc.ts` — 主进程加密/解密 IPC 处理(`BIDIRECTIONAL.SAFESTORAGE_ENCRYPT / DECRYPT`) + - `src/utils/safe-storage.ts` — 渲染进程桥接层(`initSafeStore / getSafeStore / setSafeStore / clearSafeStore`) + - `src/services/request.ts` — token 存取通过 `getSafeStore / setSafeStore`,无需感知加密细节 + - `src/services/auth-token.ts` — refresh_token 同理通过 safeStorage 存取 ## 双 Token 机制 @@ -261,25 +261,27 @@ └─ 下载完成 → 用户点击"安装更新" → NSIS/DMG/AppImage 执行 ``` -| 模块 | 位置 | 职责 | -|------|------|------| -| `HeiXiuProvider` | `electron/main/updater.ts` | 自定义 Provider,调用自有 API,无更新返回 APP_VERSION | -| `checkForUpdates` | `electron/main/updater.ts` | 预检版本 → 无更新直发 IPC / 有更新交 electron-updater | -| `downloadUpdate` | `electron/main/updater.ts` | 用户确认后触发下载(从 checkForUpdates 分离) | -| `netRequest` | `electron/main/net-request.ts` | Electron net.request 的 axios 风格封装 | -| `useUpdater` | `src/hooks/use-updater.ts` | 渲染进程更新状态 Hook(单例 IPC 监听) | -| `UpdateSetting` | `src/pages/settings/UpdateSetting.tsx` | 设置页更新区块(含"查看详情"弹窗) | -| `server.py` | `test-server/server.py` | 本地测试服务器,扫描 release/ 目录,语义版本排序 | +| 模块 | 位置 | 职责 | +|-------------------|-----------------------------------------------|------------------------------------------| +| `HeiXiuProvider` | `electron/main/updater.ts` | 自定义 Provider,调用自有 API,无更新返回 APP_VERSION | +| `checkForUpdates` | `electron/main/updater.ts` | 预检版本 → 无更新直发 IPC / 有更新交 electron-updater | +| `downloadUpdate` | `electron/main/updater.ts` | 用户确认后触发下载(从 checkForUpdates 分离) | +| `netRequest` | `electron/main/net-request.ts` | Electron net.request 的 axios 风格封装 | +| `useUpdater` | `src/hooks/use-updater.ts` | 渲染进程更新状态 Hook(单例 IPC 监听) | +| `UpdateSetting` | `src/pages/settings/blocks/UpdateSetting.tsx` | 设置页更新区块(含"查看详情"弹窗) | +| `server.py` | `test-server/server.py` | 本地测试服务器,扫描 release/ 目录,语义版本排序 | ## 增量更新(差分下载) - `package.json` 配置 `"differentialPackage": true`,electron-builder 打包时自动生成 `.exe.blockmap` 文件 -- 自定义 Provider 模式:`HeiXiuNsisUpdater extends NsisUpdater` / `HeiXiuMacUpdater extends MacUpdater` / `HeiXiuAppImageUpdater extends AppImageUpdater` -- 三平台 Updater 均覆盖 `getUpdateInfoAndProvider()` 注入 `HeiXiuProvider`(自定义 API 版本检查),差分下载由 electron-updater 父类原生处理 +- 自定义 Provider 模式:`HeiXiuNsisUpdater extends NsisUpdater` / `HeiXiuMacUpdater extends MacUpdater` / + `HeiXiuAppImageUpdater extends AppImageUpdater` +- 三平台 Updater 均覆盖 `getUpdateInfoAndProvider()` 注入 `HeiXiuProvider`(自定义 API 版本检查),差分下载由 + electron-updater 父类原生处理 - blockmap 文件流程: - - `scripts/generate-update-info.mjs` — 扫描 blockmap 文件,写 `blockmap.url` + `blockmap.size` 到 `update-info.json` - - `scripts/upload-oss.mjs` — 安装包 + blockmap 文件一并上传 OSS(支持 Node.js 直传和 ossutil 两种模式) - - `scripts/publish-release.mjs` — 通过 `--blockmap-url` 参数将 blockmap 信息发布到后端 API + - `scripts/generate-update-info.mjs` — 扫描 blockmap 文件,写 `blockmap.url` + `blockmap.size` 到 `update-info.json` + - `scripts/upload-oss.mjs` — 安装包 + blockmap 文件一并上传 OSS(支持 Node.js 直传和 ossutil 两种模式) + - `scripts/publish-release.mjs` — 通过 `--blockmap-url` 参数将 blockmap 信息发布到后端 API - 效果:日常更新仅下载变更区块,预计节省 60%~90% 流量 ## 环境变量加载机制(主进程) @@ -293,11 +295,11 @@ ④ updater.ts 硬编码默认值(https://www.heixiu.com) ``` -| 文件 | 何时生效 | 进 git | 用途 | -|------|---------|:------:|------| -| `.env.development` | `npm run dev` | ✅ | 本地开发(API → 测试服务器) | -| `.env.production` | `npm run build:xxx` | ❌ | 构建时参考(API → 生产服务器) | -| `.env.local` | 始终(后加载) | ❌ | 个人特殊配置(打包后连本地测试) | +| 文件 | 何时生效 | 进 git | 用途 | +|--------------------|---------------------|:-----:|--------------------| +| `.env.development` | `npm run dev` | ✅ | 本地开发(API → 测试服务器) | +| `.env.production` | `npm run build:xxx` | ❌ | 构建时参考(API → 生产服务器) | +| `.env.local` | 始终(后加载) | ❌ | 个人特殊配置(打包后连本地测试) | > `.env.production` 和 `.env.local` 不打包进 ASAR。打包后无这些文件时自动使用代码默认值。 diff --git a/README.md b/README.md index c727cf5..87293e9 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,13 @@ ## 技术栈 -| 层 | 技术 | -| ---- | ------------------------------ | +| 层 | 技术 | +|----|--------------------------------| | 框架 | Electron 42 | | 前端 | React 19 + TypeScript + Vite 8 | -| UI | Ant Design 6 + Tailwind CSS 4 | -| 主题 | 蓝紫渐变(亮色 / 暗色) | -| 更新 | 自定义 API + 阿里云 OSS | +| UI | Ant Design 6 + Tailwind CSS 4 | +| 主题 | 蓝紫渐变(亮色 / 暗色) | +| 更新 | 自定义 API + 阿里云 OSS | | 打包 | 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 ``` -| 操作 | 说明 | -| ----------- | -------------------------------------------------------------- | +| 操作 | 说明 | +|-------------|---------------------------------------------------------| | `--build` | 构建(默认:tsc + vite + electron-builder + update-info.json) | -| `--clean` | 构建前清理 release/ dist/ dist-electron/ | -| `--sign` | 代码签名(需配合 `--cert` 或 `CSC_LINK` 环境变量) | -| `--upload` | 上传安装包到 OSS | +| `--clean` | 构建前清理 release/ dist/ dist-electron/ | +| `--sign` | 代码签名(需配合 `--cert` 或 `CSC_LINK` 环境变量) | +| `--upload` | 上传安装包到 OSS | | `--publish` | POST 版本信息到后端 API | -| `--all` | 全流程 | +| `--all` | 全流程 | ### 工具命令 @@ -143,18 +143,18 @@ ele-heixiu/ 主进程加载顺序:`.env.{mode}` → `.env.local` → OS 环境变量 → 代码默认值。 -| 变量 | 默认值 | 用途 | -| ----------------------- | ------------------------------------------------------ | --------------- | -| `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 基地址 | +| 变量 | 默认值 | 用途 | +|-------------------------|--------------------------------------------------------|---------------| +| `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 基地址 | > **`.env.local`**(不进 git)用于个人特殊配置,如打包后覆盖 `UPDATE_API_URL` 指向本地测试服务器。 @@ -162,8 +162,8 @@ ele-heixiu/ ## 相关文档 -| 文档 | 面向 | 内容 | -|------|------|------| -| [PROJECT.md](./PROJECT.md) | 开发者 | 技术架构、更新系统、环境变量架构、测试流程 | -| [UpdateA.md](./UpdateA.md) | 前端发版 | 构建命令、发版流程、代码签名 | -| [test-server/README.md](./test-server/README.md) | 后端程序员 | API 规格、数据库表结构、实现清单 | +| 文档 | 面向 | 内容 | +|--------------------------------------------------|-------|-----------------------| +| [PROJECT.md](./PROJECT.md) | 开发者 | 技术架构、更新系统、环境变量架构、测试流程 | +| [UpdateA.md](./UpdateA.md) | 前端发版 | 构建命令、发版流程、代码签名 | +| [test-server/README.md](./test-server/README.md) | 后端程序员 | API 规格、数据库表结构、实现清单 | diff --git a/TODO.md b/TODO.md index 42a1de1..b9b9537 100644 --- a/TODO.md +++ b/TODO.md @@ -1,6 +1,6 @@ # TODO — 船长·HeiXiu 待办与优化清单 -> 最后更新:2026-06-07 +> 最后更新:2026-06-08 --- @@ -17,12 +17,10 @@ - [ ] 合规素材库页面(`/compliance-assets`) - [ ] 项目管理页面(`/projects`,企业版) - [ ] 账户/会员/充值/消费记录/订单明细页面 -- [ ] 忘记密码流程 - [ ] 各页面暗色主题适配 ### 导航栏 -- [ ] 移除调试用的 `console.log` - [ ] 导航配置中的 `action: 'external'` 统一管理(白名单校验) - [ ] 未登录点击需登录项 → 登录成功后自动跳转到目标页 @@ -61,14 +59,12 @@ ## ⚠️ 已知技术债务 -| 问题 | 说明 | 影响 | -|-----------------|---------------------------------------|------------------| -| localStorage 依赖 | 所有持久化数据依赖 localStorage | 清理浏览器数据会丢失所有配置 | -| IPC 监听器 | useUpdater 已改为单例,其他 IPC hook 可能也有类似问题 | 长期运行可能内存泄漏 | -| ~~主题切换状态重置~~ | ✅ 已修复 — 根因:设置页 `navigate('/settings')` 导致路由 `/*` 仅匹配 index,HomePage 被卸载重挂载。修复:SettingsPage 改为事件总线驱动的 Modal 居中叠加,不走路由 | ~~用户体验差~~ | -| ~~三栏布局拖拽溢出~~ | ✅ 已修复 — 拖拽分隔条增加最大宽度约束(`maxLeft = containerWidth - 36 - 300 - rightWidth`)+ 视口缩小 ResizeObserver 自动收窄 | ~~布局可用性~~ | -| `test.py` | 仓库根目录残留测试文件 | 无实际作用,应清理 | -| `null` 文件 | 仓库根目录名为 null 的文件 | 构建产物或误操作残留 | +| 问题 | 说明 | 影响 | +|-----------------|----------------------------------------------------------------------------------------------------------------------|----------------| +| localStorage 依赖 | 所有持久化数据依赖 localStorage | 清理浏览器数据会丢失所有配置 | +| IPC 监听器 | useUpdater 已改为单例,其他 IPC hook 可能也有类似问题 | 长期运行可能内存泄漏 | +| `test.py` | 仓库根目录残留测试文件 | 无实际作用,应清理 | +| `null` 文件 | 仓库根目录名为 null 的文件 | 构建产物或误操作残留 | --- @@ -76,5 +72,6 @@ 1. **合规素材库页面**(功能待完善 — 按 QT 原版设计,以 Modal/Drawer 叠加而非路由跳转实现) 2. **各页面 nav 导航补齐**(后续页面优先使用叠加模式,遵循 QT 多窗口模型 → 见 WORKLOG 2026-06-07) -3. **leftWidth / rightWidth / taskPage 加 localStorage 持久化**(UX 优化:刷新页面后保持用户偏好) -4. **轮播图关闭按钮状态优化**(当前为 session 级别 useState,可考虑加 localStorage 记录关闭时间戳,超过 N 天再显示) +3. **leftWidth / rightWidth / taskPage 加 localStorage 持久化**(UX 优化:刷新页面后保持用户偏好;已通过路由去路由化解决组件卸载问题,但 localStorage 持久层仍未实现) +4. **轮播图关闭按钮 localStorage 持久化**(关闭按钮已实现,待做:localStorage 记录关闭时间戳,超过 N 天再显示) +5. **短信发送接入人机验证**(后期优化,需后端配合:`sendSms` 接口增加 `captcha_token` 参数 + 前端滑块/图形验证组件。当前仅靠 60s 冷却按钮 + 后端限流,桌面端 ASAR 可解包 + DevTools 网络面板 + 代理抓包均能直接暴露 API,纯前端限制对脚本攻击无效) diff --git a/src/components/navbar/AnnouncementDrawer.tsx b/src/components/navbar/AnnouncementDrawer.tsx index e9e3424..85074aa 100644 --- a/src/components/navbar/AnnouncementDrawer.tsx +++ b/src/components/navbar/AnnouncementDrawer.tsx @@ -3,202 +3,201 @@ // 数据来源:GET /api/v1/announcements // ============================================================ -import { useEffect, useState, useCallback } from 'react'; -import { Drawer, Card, Typography, Empty, Tag, Spin, Modal, Tooltip } from 'antd'; -import { NotificationOutlined } from '@ant-design/icons'; +import {useEffect, useState, useCallback} from 'react'; +import {Drawer, Card, Typography, Empty, Tag, Spin, Modal, Tooltip} from 'antd'; +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 颜色映射 */ const typeColorMap: Record = { - system: 'blue', - feature: 'purple', - activity: 'orange', - maintenance: 'red', + system: 'blue', + feature: 'purple', + activity: 'orange', + maintenance: 'red', }; /** 公告类型 → 中文标签 */ const typeLabelMap: Record = { - system: '系统', - feature: '功能', - activity: '活动', - maintenance: '维护', + system: '系统', + feature: '功能', + activity: '活动', + maintenance: '维护', }; /** 格式化日期(YYYY-MM-DD HH:mm) */ function formatDate(iso: string): string { - try { - return new Date(iso).toLocaleDateString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - }); - } catch { - return iso; - } + try { + return new Date(iso).toLocaleDateString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return iso; + } } // ---------- 组件 ---------- interface AnnouncementDrawerProps { - open: boolean; - onClose: () => void; + open: boolean; + onClose: () => void; } -export function AnnouncementDrawer({ open, onClose }: AnnouncementDrawerProps) { - const [list, setList] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); +export function AnnouncementDrawer({open, onClose}: AnnouncementDrawerProps) { + const [list, setList] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); - // 详情 Modal - const [detail, setDetail] = useState(null); + // 详情 Modal + const [detail, setDetail] = useState(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); - } - }, []); + // 打开抽屉时请求公告列表 + const loadAnnouncements = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data: AnnouncementListResponse = await fetchAnnouncements(); + setList(data.filter((item: Announcement) => item.is_active)); + } catch (err) { + setError((err as Error).message || '加载公告失败'); + setList([]); + } finally { + setLoading(false); + } + }, []); - useEffect(() => { - if (open) { - loadAnnouncements(); - } - }, [open, loadAnnouncements]); + useEffect(() => { + if (open) { + loadAnnouncements(); + } + }, [open, loadAnnouncements]); - // ---- 渲染 ---- + // ---- 渲染 ---- - return ( - <> - + return ( + <> + 公告 - } - placement="right" - size={380} - open={open} - onClose={onClose} - styles={{ body: { padding: 0 } }} - > - {loading ? ( -
- -
- ) : error ? ( -
- -
- ) : list.length === 0 ? ( -
- -
- ) : ( -
- {list.map((item) => ( - 100 - ? item.content.slice(0, 200) + '…' - : undefined - } - placement="left" - > - - {item.pinned && '📌 '} - {item.title} - - } - extra={ - - {typeLabelMap[item.type] || item.type} - - } - onClick={() => setDetail(item)} - > - - {item.content} - - - {formatDate(item.created_at)} - - - - ))} -
- )} -
+ } + placement="right" + size={380} + open={open} + onClose={onClose} + styles={{body: {padding: 0}}} + > + {loading ? ( +
+ +
+ ) : error ? ( +
+ +
+ ) : list.length === 0 ? ( +
+ +
+ ) : ( +
+ {list.map((item:Announcement) => ( + 100 + ? item.content.slice(0, 200) + '…' + : undefined + } + placement="left" + > + + {item.pinned && '📌 '} + {item.title} + + } + extra={ + + {typeLabelMap[item.type] || item.type} + + } + onClick={() => setDetail(item)} + > + + {item.content} + + + {formatDate(item.created_at)} + + + + ))} +
+ )} +
- {/* ======== 公告详情 Modal ======== */} - setDetail(null)} - footer={null} - width={480} - centered - title={ - detail && ( - + {/* ======== 公告详情 Modal ======== */} + setDetail(null)} + footer={null} + width={480} + centered + title={ + detail && ( + {detail.pinned && '📌 '} - {detail.title} + {detail.title} - ) - } - > - {detail && ( -
-
- - {typeLabelMap[detail.type] || detail.type} - - - {formatDate(detail.created_at)} - -
-
- - {detail.content} - -
-
- )} -
- - ); + ) + } + > + {detail && ( +
+
+ + {typeLabelMap[detail.type] || detail.type} + + + {formatDate(detail.created_at)} + +
+
+ + {detail.content} + +
+
+ )} +
+ + ); } diff --git a/src/components/navbar/NavBar.tsx b/src/components/navbar/NavBar.tsx index efb6caa..889299c 100644 --- a/src/components/navbar/NavBar.tsx +++ b/src/components/navbar/NavBar.tsx @@ -7,133 +7,133 @@ // - 登录/注册 与 退出登录 仍按登录状态互斥显示 // ============================================================ -import { useState, useCallback, useMemo } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { App } from 'antd'; +import {useState, useCallback, useMemo} from 'react'; +import {useNavigate} from 'react-router-dom'; +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'; +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 navigate = useNavigate(); + const {platform, edition, isLoggedIn, logout} = useAppContext(); + const {isDark} = useTheme(); + const {message} = App.useApp(); + const navigate = useNavigate(); - const [drawerOpen, setDrawerOpen] = useState(false); + const [drawerOpen, setDrawerOpen] = useState(false); - // 根据版本选择导航项 - const { leftItems, rightItems } = useMemo(() => { - const isEnterprise = edition === 'enterprise'; - return { - leftItems: isEnterprise ? enterpriseLeft : personalLeft, - rightItems: isEnterprise ? enterpriseRight : personalRight, - }; - }, [edition]); + // 根据版本选择导航项 + 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], - ); + // 过滤规则: + // requireGuest → 仅未登录时可见(登录/注册) + // "退出登录" → 仅已登录时可见 + // 其他所有项 → 始终可见,未登录点击时弹"请先登录" + const visibleLeft = useMemo( + () => + leftItems.filter((item) => { + if (item.requireGuest && isLoggedIn) return false; + return !(item.key === 'logout' && !isLoggedIn); + }), + [leftItems, isLoggedIn], + ); + const visibleRight: NavItemConfig[] = 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; - } + // 点击导航项 + 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; + switch (item.action) { + case 'drawer': + if (item.key === 'announcements') { + setDrawerOpen(true); + } + break; - case 'button': - if (item.key === 'logout') { - logout(); - } else if (item.key === 'settings') { - emit(EVENTS.OPEN_SETTINGS); - } - break; + case 'button': + if (item.key === 'logout') { + logout(); + } else if (item.key === 'settings') { + emit(EVENTS.OPEN_SETTINGS); + } + break; - case 'external': - if (item.target) { - window.open(item.target, '_blank'); - } - 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 if (item.target) { - navigate(item.target); - } - break; - } - }, - [isLoggedIn, logout, message], - ); + case 'navigate': + case 'spa': + if (item.key === 'auth') { + emit(EVENTS.SHOW_LOGIN); + } else if (item.target) { + navigate(item.target); + } + break; + } + }, + [isLoggedIn, logout, message], + ); - const macPaddingLeft = platform === 'darwin' ? 80 : 12; + const macPaddingLeft = platform === 'darwin' ? 80 : 12; - return ( - <> -
-
- {visibleLeft.map((item) => ( - - ))} + return ( + <> +
+
+ {visibleLeft.map((item) => ( + + ))} +
+ +
+ {visibleRight.map((item) => ( + + ))} +
-
- {visibleRight.map((item) => ( - - ))} -
-
- - setDrawerOpen(false)} /> - - ); + setDrawerOpen(false)} /> + + ); } diff --git a/src/components/navbar/index.ts b/src/components/navbar/index.ts index 858c68e..766d96c 100644 --- a/src/components/navbar/index.ts +++ b/src/components/navbar/index.ts @@ -1,3 +1 @@ export { NavBar } from './NavBar'; -export { AnnouncementDrawer } from './AnnouncementDrawer'; -export type { NavItemConfig, NavBarConfig, NavAction } from './types'; diff --git a/src/pages/login/components/ChangePasswordModal.tsx b/src/pages/login/components/ChangePasswordModal.tsx new file mode 100644 index 0000000..c566c2b --- /dev/null +++ b/src/pages/login/components/ChangePasswordModal.tsx @@ -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>({}); + + // ---- 提交状态 ---- + 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 = {}; + + 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 ( + + {/* 标题 */} +
+ + 修改密码 + + + 请输入当前密码并设置新密码 + +
+ + {/* 表单 */} +
+ {/* 当前密码 */} +
+ } + placeholder="当前密码" + value={currentPassword} + onChange={(e) => { + setCurrentPassword(e.target.value); + clearError('currentPassword'); + }} + status={errors.currentPassword ? 'error' : undefined} + allowClear + /> + {errors.currentPassword && ( + + {errors.currentPassword} + + )} +
+ + {/* 新密码 */} +
+ } + placeholder="新密码(6~128位)" + value={newPassword} + onChange={(e) => { + setNewPassword(e.target.value); + clearError('newPassword'); + }} + status={errors.newPassword ? 'error' : undefined} + allowClear + /> + {errors.newPassword && ( + + {errors.newPassword} + + )} +
+ + {/* 确认新密码 */} +
+ } + placeholder="请再次输入新密码" + value={confirmPassword} + onChange={(e) => { + setConfirmPassword(e.target.value); + clearError('confirmPassword'); + }} + status={errors.confirmPassword ? 'error' : undefined} + allowClear + /> + {errors.confirmPassword && ( + + {errors.confirmPassword} + + )} +
+ + {/* 提交按钮 */} + +
+
+ ); +} diff --git a/src/pages/login/components/ForgotPasswordModal.tsx b/src/pages/login/components/ForgotPasswordModal.tsx new file mode 100644 index 0000000..acafb7a --- /dev/null +++ b/src/pages/login/components/ForgotPasswordModal.tsx @@ -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>({}); + + // ---- 提交状态 ---- + 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 = {}; + 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 ( + + {/* 标题 */} +
+ + 重置密码 + + + 通过手机短信验证码重置您的密码 + +
+ + {/* 表单 */} +
+ {/* 手机号 */} +
+ } + placeholder="请输入手机号" + value={phone} + onChange={(e) => { + setPhone(e.target.value); + clearError('phone'); + }} + status={errors.phone ? 'error' : undefined} + allowClear + /> + {errors.phone && ( + + {errors.phone} + + )} +
+ + {/* 短信验证码 */} +
+
+ } + placeholder="请输入验证码" + value={smsCode} + onChange={(e) => { + setSmsCode(e.target.value); + clearError('smsCode'); + }} + status={errors.smsCode ? 'error' : undefined} + className="flex-1" + allowClear + /> + +
+ {errors.smsCode && ( + + {errors.smsCode} + + )} +
+ + {/* 新密码 */} +
+ } + placeholder="新密码(6~128位)" + value={newPassword} + onChange={(e) => { + setNewPassword(e.target.value); + clearError('newPassword'); + }} + status={errors.newPassword ? 'error' : undefined} + allowClear + /> + {errors.newPassword && ( + + {errors.newPassword} + + )} +
+ + {/* 确认新密码 */} +
+ } + placeholder="请再次输入新密码" + value={confirmPassword} + onChange={(e) => { + setConfirmPassword(e.target.value); + clearError('confirmPassword'); + }} + status={errors.confirmPassword ? 'error' : undefined} + allowClear + /> + {errors.confirmPassword && ( + + {errors.confirmPassword} + + )} +
+ + {/* 提交按钮 */} + +
+
+ ); +} diff --git a/src/pages/login/components/LoginForm.tsx b/src/pages/login/components/LoginForm.tsx index a2de23f..bc479b6 100644 --- a/src/pages/login/components/LoginForm.tsx +++ b/src/pages/login/components/LoginForm.tsx @@ -22,6 +22,7 @@ interface LoginFormProps { onSubmit: (values: LoginFormValues) => void; onRememberChange: (checked: boolean) => void; onAutoLoginChange: (checked: boolean) => void; + onForgotPassword?: () => void; } export function LoginForm({ @@ -33,6 +34,7 @@ export function LoginForm({ onSubmit, onRememberChange, onAutoLoginChange, + onForgotPassword, }: LoginFormProps) { const [username, setUsername] = useState(initialUsername || ''); const [password, setPassword] = useState(initialPassword || ''); @@ -147,12 +149,7 @@ export function LoginForm({ 自动登录
- { - /* TODO: 忘记密码流程 */ - }} - > + onForgotPassword?.()}> 忘记密码? diff --git a/src/pages/login/hooks/use-sms-cooldown.ts b/src/pages/login/hooks/use-sms-cooldown.ts new file mode 100644 index 0000000..692dffd --- /dev/null +++ b/src/pages/login/hooks/use-sms-cooldown.ts @@ -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 | 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 }; +} diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx index 72b3183..26e53d9 100644 --- a/src/pages/login/index.tsx +++ b/src/pages/login/index.tsx @@ -14,8 +14,9 @@ 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 { ForgotPasswordModal } from './components/ForgotPasswordModal'; 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 type { LoginFormValues, RegisterFormValues } from './types'; @@ -27,13 +28,13 @@ interface LoginPageProps { } export function LoginPage({ open, onClose }: LoginPageProps) { - const { edition, login } = useAppContext(); + const { login } = useAppContext(); const { isDark } = useTheme(); const { message } = App.useApp(); - const isEnterprise = edition === 'enterprise'; const [activeTab, setActiveTab] = useState('login'); const [submitting, setSubmitting] = useState(false); + const [forgotOpen, setForgotOpen] = useState(false); const authState = useAuthState(); @@ -43,7 +44,7 @@ export function LoginPage({ open, onClose }: LoginPageProps) { async (values: LoginFormValues) => { setSubmitting(true); try { - const auth:LoginResponseBody = await AuthAPI.login({ + const auth: LoginResponseBody = await AuthAPI.login({ username: values.username, password: values.password, device_id: getDeviceId(), @@ -117,6 +118,7 @@ export function LoginPage({ open, onClose }: LoginPageProps) { onSubmit={handleLogin} onRememberChange={authState.handleRememberChange} onAutoLoginChange={authState.handleAutoLoginChange} + onForgotPassword={() => setForgotOpen(true)} /> ); @@ -125,37 +127,34 @@ export function LoginPage({ open, onClose }: LoginPageProps) { ); return ( - - {/* 标题 */} -
- - 船长 · HeiXiu - - - {isEnterprise ? '企业版 — 员工账号登录' : '个人版'} - -
+ <> + + {/* 标题 */} +
+ + 船长 · HeiXiu + + + 欢迎使用船长·HeiXiu + +
- {/* 个人版 Tab | 企业版仅登录 */} - {isEnterprise ? ( - loginTab - ) : ( - )} -
+
+ + {/* 忘记密码弹窗 */} + setForgotOpen(false)} /> + ); } diff --git a/src/pages/settings/SettingsPage.tsx b/src/pages/settings/SettingsPage.tsx index f6cd65e..768da72 100644 --- a/src/pages/settings/SettingsPage.tsx +++ b/src/pages/settings/SettingsPage.tsx @@ -13,10 +13,11 @@ import { Modal } from 'antd'; import { SettingOutlined } from '@ant-design/icons'; import { useAppContext } from '@/contexts/app-context'; -import { ThemeSetting } from './ThemeSetting'; -import { StoragePathSetting } from './StoragePathSetting'; -import { SystemInfoSetting } from './SystemInfoSetting'; -import { UpdateSetting } from './UpdateSetting'; +import { ThemeSetting } from './blocks/ThemeSetting'; +import { StoragePathSetting } from './blocks/StoragePathSetting'; +import { SystemInfoSetting } from './blocks/SystemInfoSetting'; +import { UpdateSetting } from './blocks/UpdateSetting'; +import { PasswordSetting } from './blocks/PasswordSetting'; interface SettingsPageProps { open: boolean; @@ -24,31 +25,32 @@ interface SettingsPageProps { } export function SettingsPage({ open, onClose }: SettingsPageProps) { - const { edition } = useAppContext(); + const { edition } = useAppContext(); - return ( - - - 设置 - - } - styles={{ body: { padding: 0, maxHeight: '70vh', overflow: 'auto' } }} - > -
- - - - -
-
- ); + return ( + + + 设置 + + } + styles={{ body: { padding: 0, maxHeight: '70vh', overflow: 'auto' } }} + > +
+ + + + + +
+
+ ); } diff --git a/src/pages/settings/blocks/PasswordSetting.tsx b/src/pages/settings/blocks/PasswordSetting.tsx new file mode 100644 index 0000000..0104843 --- /dev/null +++ b/src/pages/settings/blocks/PasswordSetting.tsx @@ -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 ( + <> + +
+ {/* 修改密码:通过旧密码 */} +
+ + 通过当前密码修改 + + +
+ + {/* 忘记密码:通过手机验证码重置 */} +
+ + 忘记密码?通过手机验证码重置 + + +
+
+
+ + setChangePwdOpen(false)} /> + setForgotPwdOpen(false)} /> + + ); +} diff --git a/src/pages/settings/StoragePathSetting.tsx b/src/pages/settings/blocks/StoragePathSetting.tsx similarity index 97% rename from src/pages/settings/StoragePathSetting.tsx rename to src/pages/settings/blocks/StoragePathSetting.tsx index c71ef05..c322f12 100644 --- a/src/pages/settings/StoragePathSetting.tsx +++ b/src/pages/settings/blocks/StoragePathSetting.tsx @@ -159,8 +159,14 @@ function PathRow({ } export function StoragePathSetting({ edition }: StoragePathSettingProps) { - const { outputPath, teamRepoPath, setOutputPath, setTeamRepoPath, clearOutputPath, clearTeamRepoPath } = - useSettings(); + const { + outputPath, + teamRepoPath, + setOutputPath, + setTeamRepoPath, + clearOutputPath, + clearTeamRepoPath, + } = useSettings(); const isEnterprise = edition === 'enterprise'; diff --git a/src/pages/settings/SystemInfoSetting.tsx b/src/pages/settings/blocks/SystemInfoSetting.tsx similarity index 100% rename from src/pages/settings/SystemInfoSetting.tsx rename to src/pages/settings/blocks/SystemInfoSetting.tsx diff --git a/src/pages/settings/ThemeSetting.tsx b/src/pages/settings/blocks/ThemeSetting.tsx similarity index 100% rename from src/pages/settings/ThemeSetting.tsx rename to src/pages/settings/blocks/ThemeSetting.tsx diff --git a/src/pages/settings/UpdateSetting.tsx b/src/pages/settings/blocks/UpdateSetting.tsx similarity index 94% rename from src/pages/settings/UpdateSetting.tsx rename to src/pages/settings/blocks/UpdateSetting.tsx index ccfb570..02e0679 100644 --- a/src/pages/settings/UpdateSetting.tsx +++ b/src/pages/settings/blocks/UpdateSetting.tsx @@ -65,15 +65,8 @@ function renderChangelog(markdown: string) { } export function UpdateSetting() { - const { - status, - info, - progress, - error, - checkForUpdates, - downloadUpdate, - installUpdate, - } = useUpdater(); + const { status, info, progress, error, checkForUpdates, downloadUpdate, installUpdate } = + useUpdater(); const [detailOpen, setDetailOpen] = useState(false); @@ -210,15 +203,15 @@ export function UpdateSetting() { title={`更新详情 — v${info?.version || ''}`} open={detailOpen} onCancel={() => setDetailOpen(false)} - footer={ - - } + footer={} width={520} >
- {info?.releaseNotes - ? renderChangelog(info.releaseNotes) - : 暂无更新详情} + {info?.releaseNotes ? ( + renderChangelog(info.releaseNotes) + ) : ( + 暂无更新详情 + )}
diff --git a/src/pages/settings/index.ts b/src/pages/settings/index.ts index 4f90463..a54fff2 100644 --- a/src/pages/settings/index.ts +++ b/src/pages/settings/index.ts @@ -3,7 +3,3 @@ // ============================================================ export { SettingsPage } from './SettingsPage'; -export { ThemeSetting } from './ThemeSetting'; -export { StoragePathSetting } from './StoragePathSetting'; -export { SystemInfoSetting } from './SystemInfoSetting'; -export { UpdateSetting } from './UpdateSetting'; diff --git a/src/services/modules/auth.ts b/src/services/modules/auth.ts index 794981c..3e7bd08 100644 --- a/src/services/modules/auth.ts +++ b/src/services/modules/auth.ts @@ -8,7 +8,7 @@ import {post, get} from '../request'; const AuthUrlObj = { login: '/api/v1/auth/login', register: '/api/v1/auth/register', - sendSms: '/api/v1/auth/sms/send', + sendSms: '/api/v1/auth/send-sms', refreshToken: '/api/v1/auth/refresh', userInfo: '/api/v1/auth/me', changePassword: '/api/v1/auth/change-password', diff --git a/src/services/modules/models.ts b/src/services/modules/models.ts index 6b9ab41..8fda3a8 100644 --- a/src/services/modules/models.ts +++ b/src/services/modules/models.ts @@ -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 = { - img2img: '图生图', - txt2img: '文生图', - img2video: '图生视频', - txt2audio: '文生音频', + image_to_image: '图生图', + text_to_image: '文生图', + image_to_video: '图生视频', + text_to_audio: '文生音频', }; /**