Compare commits
33 Commits
main
...
e1c8eff946
| Author | SHA1 | Date | |
|---|---|---|---|
| e1c8eff946 | |||
| 72a0290693 | |||
| 06e6486aa3 | |||
| 4472d406d9 | |||
| 0e6d996718 | |||
| bc33b81121 | |||
| 54d12aaf26 | |||
| 9e8c5b7109 | |||
| 4237dcbaf6 | |||
| 95019316b2 | |||
| 848fdeca3d | |||
| d95d155ab5 | |||
| 1c27283b56 | |||
| c18d9c8404 | |||
| 0c7b33aac1 | |||
| 4261915679 | |||
| d5ba7e63cd | |||
| 76e66a37c8 | |||
| 7982b69219 | |||
| 53df926973 | |||
| eb12248a62 | |||
| 59059caae7 | |||
| a2741edf01 | |||
| ada915f298 | |||
| 0f137b21fa | |||
| 7032d1c3f2 | |||
| 952a723448 | |||
| a01b3f259c | |||
| 72ec1a5459 | |||
| a8ace232a4 | |||
| 0d3d4fcd6a | |||
| af85d31035 | |||
| 1da95eaef3 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"pid": 91784,
|
"pid": 122144,
|
||||||
"version": "0.9.9",
|
"version": "0.9.9",
|
||||||
"socketPath": "\\\\.\\pipe\\codegraph-aef859f0205bacea",
|
"socketPath": "\\\\.\\pipe\\codegraph-aef859f0205bacea",
|
||||||
"startedAt": 1780539931451
|
"startedAt": 1781578435571
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
# 渲染进程 API 地址(Vite 编译时注入,不可在主进程中使用)
|
# 渲染进程 API 地址(Vite 编译时注入,不可在主进程中使用)
|
||||||
VITE_API_BASE_URL=http://8.160.179.64:8000
|
# VITE_API_BASE_URL=http://8.160.179.64:8000
|
||||||
|
VITE_API_BASE_URL=https://www.heixiu.net
|
||||||
|
|
||||||
# 主进程 — 更新检查 API(指向本地测试服务器)
|
# 主进程 — 更新检查 API(指向本地测试服务器)
|
||||||
UPDATE_API_URL=http://127.0.0.1:8000
|
UPDATE_API_URL=http://127.0.0.1:8000
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -36,3 +36,6 @@ dist-ssr
|
|||||||
*.py
|
*.py
|
||||||
*.pyi
|
*.pyi
|
||||||
WORKLOG.md
|
WORKLOG.md
|
||||||
|
*open*.json
|
||||||
|
response_*.json
|
||||||
|
test-server/__pycache__/
|
||||||
|
|||||||
339
ARCHITECTURE.md
339
ARCHITECTURE.md
@@ -1,6 +1,6 @@
|
|||||||
# 船长 · HeiXiu — 前端架构文档
|
# 船长 · HeiXiu — 前端架构文档
|
||||||
|
|
||||||
> 最后更新:2026-06-03
|
> 最后更新:2026-06-12
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
@@ -14,15 +14,26 @@ src/
|
|||||||
├── contexts/ # React Context 定义 + Consumer Hook
|
├── contexts/ # React Context 定义 + Consumer Hook
|
||||||
│ └── app-context.ts # AppContextValue 类型 + useAppContext()
|
│ └── app-context.ts # AppContextValue 类型 + useAppContext()
|
||||||
├── hooks/ # 通用 Hook
|
├── hooks/ # 通用 Hook
|
||||||
|
│ ├── use-async.ts # 通用异步数据 Hook(useAsyncData / useAsyncMutation)
|
||||||
|
│ ├── use-api.ts # 业务 API Hook 集中管理(Banner/Model/Task/Auth/Billing)
|
||||||
│ ├── 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 刷新回调)
|
||||||
@@ -73,6 +84,72 @@ src/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 数据获取架构
|
||||||
|
|
||||||
|
### 三层模式:API 模块 → use-api Hook → 页面组件
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 1 — API 模块 Step 2 — use-api.ts Step 3 — 页面组件
|
||||||
|
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
|
||||||
|
│ BillingAPI │ → │ useBillingBalance │ → │ BillingInfo.tsx │
|
||||||
|
│ .getBalance(signal) │ │ useAsyncData( │ │ const {data,loading,│
|
||||||
|
│ .getLedger(param,s) │ │ (signal)=> │ │ error,refetch}= │
|
||||||
|
│ │ │ BillingAPI... │ │ useBillingBalance() │
|
||||||
|
│ signal?: AbortSignal │ │ ,[deps], │ │ // 纯渲染,不调 API │
|
||||||
|
│ 透传 axios config │ │ {enabled:isLoggedIn} │ │ │
|
||||||
|
│ │ │ ) │ │ │
|
||||||
|
└──────────────────────┘ └──────────────────────┘ └──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 原则
|
||||||
|
|
||||||
|
- **API 模块**(`services/modules/*.ts`):`signal?: AbortSignal` 可选参数,透传给 axios config,向后兼容
|
||||||
|
- **use-api.ts**:所有业务数据 Hook 集中于此,组件只 import Hook 不 import API 类
|
||||||
|
- **页面组件**:纯渲染,通过 `{ data, loading, error, errorMessage, refetch }` 消费数据,不手写 `useState`/`useEffect`/`useRef` 状态管理
|
||||||
|
- **`useAsyncData`**:自动处理竞态条件(raceId)、AbortController 取消、错误日志
|
||||||
|
|
||||||
|
### signal 参数规范
|
||||||
|
|
||||||
|
```
|
||||||
|
// API 层 — signal 在 config 位置(第三个参数),不是 params
|
||||||
|
static getBalance(signal?: AbortSignal): Promise<BalanceResponseBody> {
|
||||||
|
return get<BalanceResponseBody>(url, undefined, { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook 层 — signal 由 useAsyncData 自动注入
|
||||||
|
export function useBillingBalance() {
|
||||||
|
return useAsyncData<BalanceResponseBody>(
|
||||||
|
(signal) => BillingAPI.getBalance(signal),
|
||||||
|
[],
|
||||||
|
{ enabled: isLoggedIn, label: 'billing-balance' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### AbortController 生命周期
|
||||||
|
|
||||||
|
- 每次 effect 创建新 `AbortController`
|
||||||
|
- cleanup 时调用 `abortController.abort()` → 真正中断飞行中的 HTTP 请求(不仅仅是丢弃响应)
|
||||||
|
- `signal.aborted` 检查:取消的请求静默忽略,不设置 error 状态
|
||||||
|
- 解决 React StrictMode 开发模式下组件双重挂载导致的重复请求问题
|
||||||
|
|
||||||
|
### 新增 Hook 规范
|
||||||
|
|
||||||
|
在 `use-api.ts` 中添加新 Hook 只需三行:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function useXxx() {
|
||||||
|
const { isLoggedIn } = useAppContext();
|
||||||
|
return useAsyncData<XxxResponse>(
|
||||||
|
(signal) => XxxAPI.getXxx(signal),
|
||||||
|
[], // deps — 为空表示只请求一次,refetch 手动触发
|
||||||
|
{ enabled: isLoggedIn, label: 'xxx' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 核心流程
|
## 核心流程
|
||||||
|
|
||||||
### 1. 登录
|
### 1. 登录
|
||||||
@@ -102,6 +179,10 @@ App 启动 → AppProvider useEffect
|
|||||||
→ 否 → 跳过
|
→ 否 → 跳过
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **StrictMode 清理**:该 useEffect 持有 `aborted` 标志,cleanup 返回 `() => { aborted = true }`。
|
||||||
|
> 所有异步回调(`.then` / `.catch`)在操作 state 或发射事件前检查 `if (aborted) return`。
|
||||||
|
> 这防止 React StrictMode(开发模式)挂载→卸载→重新挂载时发起重复的 refreshToken / getUserInfo 请求。
|
||||||
|
|
||||||
### 3. 401 → 刷新 Token → 自动重试
|
### 3. 401 → 刷新 Token → 自动重试
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -121,6 +202,60 @@ App 启动 → AppProvider useEffect
|
|||||||
请求 A 收到 401 → 开始 refresh
|
请求 A 收到 401 → 开始 refresh
|
||||||
请求 B 同时收到 401 → 发现 isRefreshing=true → 加入队列等待
|
请求 B 同时收到 401 → 发现 isRefreshing=true → 加入队列等待
|
||||||
refresh 完成 → 新 token 广播给所有排队请求 → 全部重试
|
refresh 完成 → 新 token 广播给所有排队请求 → 全部重试
|
||||||
|
|
||||||
|
### 3.1 Token 刷新错误分类(auth-token.ts)
|
||||||
|
|
||||||
|
| 错误类型 | 行为 | 原因 |
|
||||||
|
|---------|------|------|
|
||||||
|
| `RefreshError`(token 过期) | 清除 refresh_token | 无法再续期 |
|
||||||
|
| `NETWORK` / `TIMEOUT` | **保留** refresh_token | 网络恢复后可重试 |
|
||||||
|
| HTTP 5xx / Business 错误 | 清除 refresh_token | 保守处理 |
|
||||||
|
|
||||||
|
> 网络错误时不清除 refresh_token 是关键——否则网络短暂中断会导致用户被迫手动重新登录。
|
||||||
|
> 只有 RefreshError(refresh 接口返回 401)才确认 token 真的过期。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 主动刷新定时器(v0.0.20)
|
||||||
|
|
||||||
|
除被动 401 刷新外,系统通过 JWT `exp` 声明主动续期,避免长时间空闲后 access_token 过期:
|
||||||
|
|
||||||
|
```
|
||||||
|
登录/刷新成功
|
||||||
|
→ scheduleProactiveRefresh(access_token)
|
||||||
|
→ 解码 JWT exp 声明
|
||||||
|
→ setTimeout(delay = max(60s, remaining * 0.75))
|
||||||
|
→ 到期触发 → getStoredRefreshToken()
|
||||||
|
→ AuthAPI.refreshToken()
|
||||||
|
✅ 成功 → 更新双 token → 递归安排下一次刷新
|
||||||
|
❌ 失败 → 静默(等下次 401 走被动刷新)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 主动刷新用 JWT 解码(`atob(token.split('.')[1])`)获取 `exp`,不依赖服务端 `expires_in`
|
||||||
|
- 在 75% 过期时间点触发,确保在被动 401 之前完成续期
|
||||||
|
- 至少间隔 60s(防止 token 极短时频繁刷新)
|
||||||
|
- 退出登录时 `clearProactiveRefresh()` 清除定时器
|
||||||
|
|
||||||
|
### 3.3 刷新失败分级处理(v0.0.20)
|
||||||
|
|
||||||
|
`refreshHandler` 通过 Promise 状态区分失败级别,`handle401` 据此决定是否登出:
|
||||||
|
|
||||||
|
| 失败类型 | refreshHandler 行为 | handle401 响应 |
|
||||||
|
|---------|-------------------|---------------|
|
||||||
|
| refresh_token 过期/无效 | **throw** `RefreshTokenExpiredError` | 清 token + emit AUTH_REQUIRED → 弹登录 |
|
||||||
|
| 网络超时/无连接 | **return null** | 仅拒绝当前请求,保留 token → 下次再试 |
|
||||||
|
|
||||||
|
**关键改进**:临时网络故障不再误判为"需要重新登录"。用户在网络恢复后重试操作即可,无需手动登录。
|
||||||
|
|
||||||
|
```
|
||||||
|
handle401 内部:
|
||||||
|
refreshHandler()
|
||||||
|
.then(newToken =>
|
||||||
|
newToken → ✅ 用新 token 重试
|
||||||
|
null → ⚠️ 临时失败,拒绝但不登出(processQueue 失败,但 clearToken 不调用)
|
||||||
|
)
|
||||||
|
.catch(_err →
|
||||||
|
❌ 永久失败 → clearToken + emit(AUTH_REQUIRED) + 弹登录
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. 退出登录
|
### 4. 退出登录
|
||||||
@@ -156,7 +291,56 @@ NavBar / 设置页 → AppProvider.logout()
|
|||||||
|
|
||||||
- 纯发布-订阅,不依赖任何框架
|
- 纯发布-订阅,不依赖任何框架
|
||||||
- 用于跨模块解耦通信(如 axios 拦截器无法直接用 React Context)
|
- 用于跨模块解耦通信(如 axios 拦截器无法直接用 React Context)
|
||||||
- 预定义事件:`AUTH_REQUIRED` / `SHOW_LOGIN` / `LOGIN_SUCCESS` / `LOGOUT`
|
- 预定义事件:`AUTH_REQUIRED` / `SHOW_LOGIN` / `LOGIN_SUCCESS` / `LOGOUT` / `TASK_SUBMITTED` / `OPEN_SETTINGS` / `OPEN_PAGE`
|
||||||
|
|
||||||
|
### 页面叠加层模式(QT 多窗口模型映射)
|
||||||
|
|
||||||
|
本项目是 Python QT 桌面应用的 Web 重构版。QT 应用以"新开窗口"为常态,原窗口不因新窗口打开而销毁。Web 端对应原则:
|
||||||
|
|
||||||
|
| QT 原版 | Web 对应 | 实现方式 |
|
||||||
|
|---------------|----------------|-------------------------------|
|
||||||
|
| 新开窗口叠加在主窗口之上 | Modal / Drawer | 事件总线驱动,主页面不卸载 |
|
||||||
|
| Tab 切换 / 视图替换 | React Router | 跨页状态放 Provider 或 localStorage |
|
||||||
|
|
||||||
|
**当前叠加层**:
|
||||||
|
|
||||||
|
- `PageDispatcher` — 统一页面调度器,监听 `OPEN_PAGE` 事件,根据 `modalType` 渲染 Modal/Drawer/FloatingPanel,全局互斥(同一时刻最多一个面板)
|
||||||
|
- LoginPage — Modal,事件总线 `SHOW_LOGIN` / `AUTH_REQUIRED` 触发
|
||||||
|
- SettingsPage — Modal,事件总线 `OPEN_SETTINGS` 触发
|
||||||
|
- AnnouncementDrawer — Drawer,NavBar 内部 state 控制
|
||||||
|
|
||||||
|
**PageDispatcher 页面注册**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const PAGE_MAP: Record<string, PageConfig> = {
|
||||||
|
'/account': {
|
||||||
|
component: AccountInfo, // 页面组件(懒加载用 React.lazy)
|
||||||
|
label: '账户', // 容器标题栏显示
|
||||||
|
width: () => Math.min(window.outerHeight * 0.65, 720), // 动态宽度
|
||||||
|
height: () => Math.min(window.outerHeight * 0.65, 680), // 动态高度
|
||||||
|
},
|
||||||
|
'/billing': {
|
||||||
|
component: BillingInfo, // 账单页面(余额卡片 + 账单表格)
|
||||||
|
label: '账单记录', // FloatingPanel 标题栏显示
|
||||||
|
width: 960, // 固定宽度(适配账单表格)
|
||||||
|
height: 680, // 固定高度
|
||||||
|
},
|
||||||
|
'/vip': { label: '开会员/激活' }, // 占位页:无 component → 自动渲染 "页面开发中"
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**容器类型选择**:
|
||||||
|
|
||||||
|
| 类型 | 组件 | 场景 |
|
||||||
|
|------|------|------|
|
||||||
|
| `modal` | antd Modal | 居中模态,阻断交互(登录、VIP 开通) |
|
||||||
|
| `drawer` | antd Drawer | 侧滑抽屉(设置、公告) |
|
||||||
|
| `floating` | FloatingPanel | 非模态浮动面板,可拖拽(账户、账单) |
|
||||||
|
|
||||||
|
- **全局互斥**:同一时刻只有一个面板打开,重复点击不替换
|
||||||
|
- **FloatingPanel** 自带 `padding: 16` 和标题栏,内部组件避免过度内边距叠加
|
||||||
|
|
||||||
|
**反模式警告**:叠加层绝不应通过路由跳转实现。`navigate('/xxx')` + `useLocation` 判断显隐会导致原有页面卸载,丢失所有组件状态。
|
||||||
|
|
||||||
### API 模块规范
|
### API 模块规范
|
||||||
|
|
||||||
@@ -221,3 +405,142 @@ export class AuthAPI {
|
|||||||
|------------------|-------------------------|--------------------------|--------------------------|
|
|------------------|-------------------------|--------------------------|--------------------------|
|
||||||
| `UPDATE_API_URL` | `http://127.0.0.1:8000` | `https://www.heixiu.com` | `https://www.heixiu.com` |
|
| `UPDATE_API_URL` | `http://127.0.0.1:8000` | `https://www.heixiu.com` | `https://www.heixiu.com` |
|
||||||
| `EDITION` | `personal` | `personal` | `personal` |
|
| `EDITION` | `personal` | `personal` | `personal` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 平台兼容
|
||||||
|
|
||||||
|
### 设备指纹(electron/preload/device.ts)
|
||||||
|
|
||||||
|
| 平台 | 采集方式 |
|
||||||
|
|------|---------|
|
||||||
|
| Windows | WMIC / PowerShell — UUID、CPU、HDD 序列号 |
|
||||||
|
| macOS | `ioreg -d2 -c IOPlatformExpertDevice`(UUID)、`sysctl -n hw.model`(CPU 型号)、`system_profiler`(HDD 三级回退) |
|
||||||
|
|
||||||
|
**Mac HDD 三级回退策略**:
|
||||||
|
|
||||||
|
1. `system_profiler SPNVMeDataType` → NVMe 磁盘序列号(Apple Silicon / 新款 Intel)
|
||||||
|
2. `system_profiler SPSerialATADataType` → SATA 磁盘序列号(老款 Intel Mac)
|
||||||
|
3. `system_profiler SPHardwareDataType` → 系统序列号(最终兜底)
|
||||||
|
|
||||||
|
- 通过 `platform() === 'darwin'` / `platform() === 'win32'` 区分平台
|
||||||
|
- Mac 命令执行:`spawnSync` + 5s 超时,`ioreg`/`sysctl` 始终可用
|
||||||
|
- 共享同一套 `SHA256(normalizeIdentifier(combined))` 管线
|
||||||
|
- `getMachineCode()` 跨平台统一接口,自动选择最优采集方式
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务状态轮询(v0.0.20)
|
||||||
|
|
||||||
|
### 架构:两层轮询 + Context 桥接
|
||||||
|
|
||||||
|
```
|
||||||
|
TaskHistory (列表页)
|
||||||
|
├─ useTaskPolling(tasks, onTerminal) ← 批量轮询 batchTaskStatus
|
||||||
|
│ └─ 仅轮询 POLLING_STATUSES 中的非终态任务
|
||||||
|
│ └─ 轮询结果合并到 polledTasks → dataSource
|
||||||
|
│ └─ 某任务变为终态 → onTerminal → setRefreshTick(+1) → 完整列表刷新
|
||||||
|
│
|
||||||
|
└─ useEffect: polledTasks 变化 → 同步 selectedTask 到 Context
|
||||||
|
|
||||||
|
OutputPreview (详情面板)
|
||||||
|
└─ useEffect: 监听 selectedTask.status 变化
|
||||||
|
└─ 非终态→终态 → useTaskDetail.refetch() ← 仅触发详情刷新,不独立轮询
|
||||||
|
```
|
||||||
|
|
||||||
|
**设计原则**:
|
||||||
|
- 批量轮询负责广度(列表视图状态更新),详情刷新负责深度(终态后获取 output_assets)
|
||||||
|
- OutputPreview **不独立调用** `getTaskStatus`,避免与批量轮询重复请求
|
||||||
|
- 两者通过 `HomeContext.selectedTask` 桥接,不直接通信
|
||||||
|
|
||||||
|
### 轮询实现细节
|
||||||
|
|
||||||
|
| 特性 | 实现 |
|
||||||
|
|------|------|
|
||||||
|
| 调度方式 | 递归 `setTimeout`(非 `setInterval`),避免请求堆积 |
|
||||||
|
| 间隔控制 | 服务端 `next_poll_ms`,本地 clamp 2s~30s |
|
||||||
|
| 错误重试 | 请求失败后 10s 重试 |
|
||||||
|
| 自动停止 | 所有非终态任务变为终态后停止轮询 |
|
||||||
|
| 闭包安全 | `mergedRef.current` 确保递归回调读到最新任务列表 |
|
||||||
|
| 非终态常量 | `POLLING_STATUSES = ['pending', 'submitted', 'processing']` 统一管理 |
|
||||||
|
|
||||||
|
### GET 数组参数序列化
|
||||||
|
|
||||||
|
axios 默认 `paramsSerializer` 对数组使用 `key[]=v1&key[]=v2`(PHP/Rack bracket 风格),后端不识别。修复方案:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ 错误:axios 默认序列化为 task_ids[]=v1&task_ids[]=v2
|
||||||
|
get(url, { task_ids: ['id1', 'id2'] })
|
||||||
|
|
||||||
|
// ✅ 正确:逗号拼接为 task_ids=id1,id2(跨框架兼容)
|
||||||
|
get(url, { task_ids: params.task_ids.join(',') })
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件上传(v0.0.20)
|
||||||
|
|
||||||
|
### customRequest 集成模式
|
||||||
|
|
||||||
|
antd Upload 组件通过 `action` 属性使用自有的 XMLHttpRequest,不走 axios 拦截器,导致 Token 缺失。解决方案:
|
||||||
|
|
||||||
|
```
|
||||||
|
useFileUpload() hook
|
||||||
|
└─ 返回 { customRequest } — 兼容 antd Upload.customRequest 签名
|
||||||
|
└─ 内部调用 request.upload(url, formData, onProgress)
|
||||||
|
└─ 复用 axios 实例:Token 注入、超时、错误处理、业务码校验
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
ImageInput / ImageListInput / AudioListInput
|
||||||
|
└─ const { customRequest } = useFileUpload();
|
||||||
|
└─ <Upload customRequest={customRequest} beforeUpload={...} />
|
||||||
|
└─ beforeUpload 校验通过 → 返回 true → 放行至 customRequest
|
||||||
|
└─ customRequest 调用 request.upload() → 上传到 /api/v1/upload
|
||||||
|
└─ 成功后 antd 将 response 写入 file.response
|
||||||
|
└─ ModelInputForm 提交时读取 f.response?.url
|
||||||
|
```
|
||||||
|
|
||||||
|
### beforeUpload 语义
|
||||||
|
|
||||||
|
| 返回值 | 含义 |
|
||||||
|
|-------|------|
|
||||||
|
| `true` | 校验通过,交由 customRequest 实际上传 |
|
||||||
|
| `false` | 阻止上传(文件仅加入列表,不发起请求) |
|
||||||
|
| `Upload.LIST_IGNORE` | 校验失败,文件不加入列表 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 表单提交防抖(v0.0.20)
|
||||||
|
|
||||||
|
### 两级防护
|
||||||
|
|
||||||
|
```
|
||||||
|
handleSubmit()
|
||||||
|
├─ 1. 防抖节流(2s)
|
||||||
|
│ └─ lastClickTimeRef: now - last < 2s → "你点击的太快了"
|
||||||
|
│
|
||||||
|
├─ 2. 二次确认
|
||||||
|
│ └─ submitCountRef > 0 → Modal.confirm("您已提交,确认要再次提交吗?")
|
||||||
|
│ └─ 确认 → lastClickTimeRef = 0(重置防抖)→ doSubmit()
|
||||||
|
│
|
||||||
|
└─ 3. 执行提交
|
||||||
|
└─ doSubmit(): form.validateFields() → submitTask() → 检查返回值
|
||||||
|
```
|
||||||
|
|
||||||
|
### useRef 而非 useState
|
||||||
|
|
||||||
|
`lastClickTimeRef` 和 `submitCountRef` 使用 `useRef` 而非 `useState`,避免每次点击触发不必要的组件重渲染。
|
||||||
|
|
||||||
|
### useAsyncMutation 返回值判空
|
||||||
|
|
||||||
|
`useAsyncMutation.execute()` 内部 catch 所有错误并返回 `undefined`(不 throw)。调用方应检查返回值而非 `try/catch`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const result = await submitTask({...});
|
||||||
|
if (result !== undefined) {
|
||||||
|
submitCountRef.current += 1; // 成功
|
||||||
|
} else {
|
||||||
|
message.error(errorMessageRef.current || '任务提交失败');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
244
CHANGELOG.md
244
CHANGELOG.md
@@ -1,6 +1,250 @@
|
|||||||
# 更新日志
|
# 更新日志
|
||||||
|
|
||||||
> 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。
|
> 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。
|
||||||
|
---
|
||||||
|
## 0.0.21(2026-06-15)
|
||||||
|
|
||||||
|
**文件结构重组:components/ pages/ 按职责分层**
|
||||||
|
|
||||||
|
### components/ → 三层拆分
|
||||||
|
|
||||||
|
- `providers/`(状态管理):AppProvider / ThemeProvider / SettingsProvider
|
||||||
|
- `shell/`(应用外壳):Layout / PageDispatcher / navbar/
|
||||||
|
- `ui/`(通用原子):FloatingPanel / LegalTextModal
|
||||||
|
|
||||||
|
### pages/ → 重命名消除歧义
|
||||||
|
|
||||||
|
- `headers/` → `panels/`(NavBar 面板页,非 HTTP headers)
|
||||||
|
- `login/` → `auth/`(含登录/注册/忘记密码/修改密码)
|
||||||
|
- `settings/blocks/` → `sections/`
|
||||||
|
- `home/components/useUI/` → `home/controls/`
|
||||||
|
|
||||||
|
### home/ → 四层子结构
|
||||||
|
|
||||||
|
- `panels/` — 布局壳(LeftPanel / CenterPanel / RightPanel)
|
||||||
|
- `features/` — 功能面板(Banner / ModelInput / ModelSelector / Output / TaskHistory)
|
||||||
|
- `controls/` — 表单控件库(12 个控件 + Widget 注册表)
|
||||||
|
- `hooks/` — 纯 Hook(useModelUI)
|
||||||
|
|
||||||
|
### 其他
|
||||||
|
|
||||||
|
- HomeContext 从 HomeContent.tsx 拆分独立文件(解决 react-refresh ESLint 警告)
|
||||||
|
- uploadValidation 内联到 ImageInput(controls/ 不再混工具函数)
|
||||||
|
- 删除空占位 Test3.tsx
|
||||||
|
|
||||||
|
### 支付系统 & 订单明细
|
||||||
|
|
||||||
|
- **RechargePage antd 6.x 适配**:`InputNumber.addonAfter` → `Space.Compact`、`Space direction` → `orientation`,消除弃用警告
|
||||||
|
- **OrdersPage 订单明细页面**(`/orders`,方案 B — 独立订单管理页):
|
||||||
|
- 状态筛选 Segmented(全部/待支付/已支付/已过期/支付失败)+ 分页 Table
|
||||||
|
- "继续支付"操作:打开支付 URL + `startPolling` 单订单轮询
|
||||||
|
- `pollingRef: Map<orderId, timer>` 管理多订单并发轮询,幂等启动(重复点击防重)
|
||||||
|
- 局部 state 更新 + 终态全量刷新(`fetchOrders`)
|
||||||
|
- `AbortController` 防竞态:切换筛选/分页时取消旧请求
|
||||||
|
- `columns` 使用 `useMemo` 稳定引用
|
||||||
|
- 错误态 Result + 重试按钮
|
||||||
|
- **PageDispatcher**:注册 `/orders` 路由(width: 820, height: 640),新增 `CLOSE_PAGE` / `PAGE_LOCK` 事件监听
|
||||||
|
- **payments.ts**:支付 API 模块类型体系(`CreateOrderResponseBody` → `OrderListResponseBody` / `OrderInfoResponseBody` 名义类型),`PaymentAPI.getOrdersListInfo` 支持 `AbortSignal`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0.0.20(2026-06-12)
|
||||||
|
|
||||||
|
**任务状态轮询 + Token 刷新容错增强 + 提交防抖 + 文件上传 Hook**
|
||||||
|
|
||||||
|
### 任务状态轮询
|
||||||
|
|
||||||
|
- 新增 `useTaskPolling` hook:批量轮询非终态任务状态,递归 `setTimeout` + `next_poll_ms` 自适应间隔
|
||||||
|
- `TaskHistory` 集成批量轮询,`OutputPreview` 改为监听终态转换(Context 桥接)触发 `refetch()`,消除重复请求
|
||||||
|
- `POLLING_STATUSES` 统一管理非终态状态,所有轮询逻辑引用此常量
|
||||||
|
- `batchTaskStatus` 使用 GET + 逗号拼接 `task_ids=a,b,c`,修复 axios 默认 bracket 序列化
|
||||||
|
|
||||||
|
### Token 刷新容错增强
|
||||||
|
|
||||||
|
- **主动刷新**:JWT `exp` 解码驱动定时器,在 75% 过期点通过 `setTimeout` 提前续期
|
||||||
|
- **失败分级**:`refreshHandler` 永久失败(refresh_token 过期)→ **throw** → 清 token 弹登录;临时失败(网络超时)→ **return null** → 仅拒绝当前请求不登出
|
||||||
|
- 新增 `RefreshTokenExpiredError` 类区分失败类型
|
||||||
|
- `AppProvider` 登录/自动登录/退出 启停主动刷新定时器
|
||||||
|
|
||||||
|
### 提交按钮防抖
|
||||||
|
|
||||||
|
- 两级防护:2s 节流防抖("你点击的太快了")+ 二次确认弹窗("您已提交,确认要再次提交吗?")
|
||||||
|
- `doSubmit` 抽取为 `useCallback`,`handleSubmit` 负责门控
|
||||||
|
- 修复 `useAsyncMutation.execute` 返回值判空(原 `try/catch` 为死代码)
|
||||||
|
|
||||||
|
### 文件上传 Hook
|
||||||
|
|
||||||
|
- 新增 `useFileUpload` hook:封装 `request.upload()` 为 antd Upload 兼容 `customRequest`,复用全局拦截器
|
||||||
|
- `ImageInput` / `ImageListInput` / `AudioListInput` 集成 `customRequest`
|
||||||
|
- `uploadValidation.beforeUpload` 返回值修正:`false` → `true`(校验通过后放行至 `customRequest`)
|
||||||
|
|
||||||
|
### 其他
|
||||||
|
|
||||||
|
- `AnnouncementDrawer` 接入公告 API + `EnumResolver`
|
||||||
|
- billing / announcement / banner 服务模块完善
|
||||||
|
- 文档:ARCHITECTURE.md 新增轮询/Token刷新/上传/防抖章节
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0.0.19(2026-06-11)
|
||||||
|
|
||||||
|
**数据获取架构重构 + Mac 指纹采集 + Token 刷新容错 + 账单页面**
|
||||||
|
|
||||||
|
### 数据获取架构重构
|
||||||
|
|
||||||
|
- `useAsyncData` 集成 AbortController:cleanup 时真正中断 HTTP 请求,不再仅丢弃响应(解决 StrictMode 双重请求)
|
||||||
|
- fetcher 签名变更:`() => Promise<T>` → `(signal: AbortSignal) => Promise<T>`
|
||||||
|
- API 模块统一 `signal?: AbortSignal` 可选参数,透传 axios config(banner/auth/models/task/vip)
|
||||||
|
- `use-api.ts` 成为数据 Hook 集中管理中心:
|
||||||
|
- 新增 `useAccountInfo` — `Promise.all` 并行获取用户信息 + VIP 等级
|
||||||
|
- 新增 `useBillingBalance` — 账户余额
|
||||||
|
- 新增 `useBillingLedger` — 账单明细(支持分页参数)
|
||||||
|
- `AccountInfo.tsx` 重构:~40 行手写状态管理(`useRef`+`useEffect`+`cancelled`+`retryTick`)→ 1 行 `useAccountInfo()`
|
||||||
|
- 共享工具 `src/utils/display.ts` — `centToYuan` / `formatDate`,消除 AccountInfo/BillingInfo 重复定义
|
||||||
|
- 移除 `modelFirstFetchDone` 模块级变量(StrictMode 不安全)
|
||||||
|
|
||||||
|
### Mac 硬件指纹采集
|
||||||
|
|
||||||
|
- `electron/preload/device.ts` Mac 完整实现:
|
||||||
|
- UUID:`ioreg -d2 -c IOPlatformExpertDevice` → `IOPlatformUUID`
|
||||||
|
- CPU:`sysctl -n hw.model` → 机型标识
|
||||||
|
- HDD:三级回退 `system_profiler SPNVMeDataType` → `SPSerialATADataType` → `SPHardwareDataType`
|
||||||
|
- `isMacOS()` / `runMacCommand()` / `getMacHddValue()` 辅助函数
|
||||||
|
- `getComponentId()` / `fingerprintSha256()` Mac 早退分支,与 Windows 共享 SHA256 管线
|
||||||
|
|
||||||
|
### 账单页面
|
||||||
|
|
||||||
|
- `BillingInfo.tsx` — 余额卡片(BalanceCard)+ 账单表格(LedgerTable)+ 分页
|
||||||
|
- `BillingAPI` — `getBalance` / `getLedger`,`signal` 参数透传
|
||||||
|
- `PageDispatcher` 注册 `/billing` 页面(FloatingPanel 960×680)
|
||||||
|
- 修复 `billing.ts` signal 参数位置 bug:`get(url, {signal})` → `get(url, undefined, {signal})`
|
||||||
|
- BillingInfo 标题精简:移除冗余页面 Title 和 Card title,仅保留 FloatingPanel 标题栏
|
||||||
|
|
||||||
|
### Bug 修复
|
||||||
|
|
||||||
|
- **Token 刷新容错**(`auth-token.ts`):网络错误/超时时保留 refresh_token,仅 RefreshError(token 过期)时清除。
|
||||||
|
解决网络短暂中断后被迫手动重新登录的问题
|
||||||
|
- **AppProvider 自动登录**:`email`/`admin_permissions` 等 nullable 字段默认值修复(`?? ''` / `?? []`)
|
||||||
|
- **antd Table align**:`align: 'left'` → `align: 'left' as const`,修复 TS2322 类型错误
|
||||||
|
- **分页器位置**:显式添加 `position: ['bottomRight']`
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
|
||||||
|
- CLAUDE.md 精简为 AI 行为规则,项目技术内容移入 ARCHITECTURE.md
|
||||||
|
- ARCHITECTURE.md 新增:数据获取架构、页面调度详情、Token 刷新错误分类、设备指纹
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0.0.18(2026-06-10)
|
||||||
|
|
||||||
|
**前后端联调 — 任务创建 + 账户信息 + 预估花费**
|
||||||
|
|
||||||
|
### 任务创建联调
|
||||||
|
|
||||||
|
- `CreatTaskRequestBody.device_id` 改为后端必填参数,API 层 `Omit` 封装,自动注入 `getDeviceId()`(UUID v4 持久化,与登录/注册统一)
|
||||||
|
- `useSubmitTask` 泛型同步改为 `Omit<CreatTaskRequestBody, 'device_id'>`,调用方无感
|
||||||
|
- `submitTask` 移除 `body.device_id || getDeviceId()` 兜底逻辑,始终以本地生成的设备 ID 为准
|
||||||
|
|
||||||
|
### 预估花费计算(与 QT/Python 后端对齐)
|
||||||
|
|
||||||
|
- `src/utils/pricing.ts` — 从 Python 后端 `calculate_cost` 完整移植六种定价模式:
|
||||||
|
- 服务端:`SIMPLE` · `MULTI_DIMENSION` · `BILLING_ADAPTER`(per_k_chars / rate + 条件附加费)
|
||||||
|
- 客户端:`fixed` · `per_k_chars` · `rate_matrix` · `matrix`
|
||||||
|
- `ModelInputForm.tsx` — 三层兜底预估消费展示:完整动态计价 → 配置文件兜底提取 → 模式标签
|
||||||
|
- **修复 6 个定价 Bug**:
|
||||||
|
- SIMPLE 浮点冗余 `(x/100)*100` 在 IEEE 754 下不恒等 → `Math.round(分子)/100`
|
||||||
|
- `rate_matrix` / `matrix` 内联维度匹配缺少 `.trim().toLowerCase()` + null 跳过 → 统一 `matchDimensions()`
|
||||||
|
- `lookupMappingValue` / `matchDimensions` 缺 null 守卫 → `Cannot use 'in' operator` 崩溃
|
||||||
|
- `rate_matrix` 错误应用 `unit_scale`(Python spec 无此行为)→ 移除乘法
|
||||||
|
- `matrix` 模式 `dimensions` 空值提前返 null(与 `MULTI_DIMENSION` 不一致)→ 对齐处理
|
||||||
|
- 矩阵维度提取 `row.raw` — API 实际在行顶层存储维度(如 `resolution`),`raw` 子对象不存在 → `matrixRowData()` 兼容两种格式
|
||||||
|
- `pricing_config` 类型修正:`Record<string, unknown>` → `Record<string, unknown> | null`(对齐 OpenAPI `anyOf`)
|
||||||
|
- 修复 React Hooks 顺序违例 — `estimatedCostDisplay` useMemo 移至提前返回之前
|
||||||
|
|
||||||
|
### 账户信息联调
|
||||||
|
|
||||||
|
- `AccountInfo.tsx` "当前套餐"从 VIP 等级列表匹配详细数据:
|
||||||
|
- `vip_level_id` → `VipLevelsItem.id` 映射查找
|
||||||
|
- 新增套餐说明、套餐周期字段,席位上限优先取 VIP 等级值
|
||||||
|
- 到期时间提取 `isExpired` 变量复用
|
||||||
|
- `VipAPI.getVipLevels()` 与 `AuthAPI.getUserInfo()` 并行加载
|
||||||
|
|
||||||
|
### 模型列表缓存
|
||||||
|
|
||||||
|
- 加密持久化缓存(safe-storage):启动时 `Promise.all` 初始化,缓存优先渲染消除 loading 闪烁
|
||||||
|
- `useModelList` Stale-while-revalidate:即时展示缓存 → 后台拉新 → 覆盖缓存
|
||||||
|
|
||||||
|
### 其他
|
||||||
|
|
||||||
|
- VIP API 模块(`src/services/modules/vip-api.ts`)— 激活码/等级列表/订单/模拟支付
|
||||||
|
- ESLint/TS 类型修复(未使用符号清理、分号补充)
|
||||||
|
- CLAUDE.md 更新系统兼容性说明
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0.0.17(2026-06-09)
|
||||||
|
|
||||||
|
**修复接口重复调用 — 请求量减半**
|
||||||
|
|
||||||
|
- AppProvider 自动登录 useEffect 增加 aborted 清理标志,修复 StrictMode 双重挂载导致 refreshToken/getUserInfo 各调两次
|
||||||
|
- useModelList 提升到 LeftPanel 单例调用,修复 ModelSelector 和 TaskHistory 各自独立请求全量模型列表
|
||||||
|
- 开发模式 DevTools 中"已停止"的请求即为 StrictMode 第一轮挂载被丢弃的请求
|
||||||
|
- 模型输入栏三层架构:useUI 组件体系(10 种 widget → antd 映射)+ useModelUI Hook(param_schema → UIFieldConfig[])
|
||||||
|
- constraints_config.requires 动态联动:enable_switch 开关控制依赖字段启用/禁用
|
||||||
|
- 修复模型默认值不加载(antd 外部 form 实例忽略 initialValues)+ DependentFormItem prop 透传断裂
|
||||||
|
- PageDispatcher 统一页面调度:Modal/Drawer/FloatingPanel 三容器,全局互斥,动态视口尺寸
|
||||||
|
- 账户信息页:AuthAPI.getUserInfo() 驱动,积分/套餐/统计三卡片布局
|
||||||
|
- 代码去重:runFieldChecks 校验运行器 + createImageBeforeUpload 上传校验工厂
|
||||||
|
- 主题切换性能优化 — View Transitions API + 0.15s 统一过渡 + 设置页增强(0.0.15 延续)
|
||||||
|
- 目录调整:wechat-work/ → headers/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0.0.16(2026-06-09)
|
||||||
|
|
||||||
|
**模型输入栏重构 + 统一页面调度 + 依赖联动**
|
||||||
|
|
||||||
|
- 模型输入栏三层架构:useUI 组件体系(10 种 widget → antd 映射)+ useModelUI Hook(param_schema → UIFieldConfig[])
|
||||||
|
- constraints_config.requires 动态联动:enable_switch 开关控制依赖字段启用/禁用
|
||||||
|
- 修复模型默认值不加载(antd 外部 form 实例忽略 initialValues)+ DependentFormItem prop 透传断裂
|
||||||
|
- PageDispatcher 统一页面调度:Modal/Drawer/FloatingPanel 三容器,全局互斥,动态视口尺寸
|
||||||
|
- 账户信息页:AuthAPI.getUserInfo() 驱动,积分/套餐/统计三卡片布局
|
||||||
|
- 代码去重:runFieldChecks 校验运行器 + createImageBeforeUpload 上传校验工厂
|
||||||
|
- 主题切换性能优化 — View Transitions API + 0.15s 统一过渡 + 设置页增强(0.0.15 延续)
|
||||||
|
- 目录调整:wechat-work/ → headers/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0.0.15(2026-06-09)
|
||||||
|
|
||||||
|
**主题切换性能优化 — 桌面端流畅度提升**
|
||||||
|
|
||||||
|
- View Transitions API 驱动主题切换:GPU 合成器单次 cross-fade 替代 80+ 个独立 CSS 过渡,消除 Electron 卡顿
|
||||||
|
- 主题更新时序统一:`applyHtmlTheme` 从 updater → `useLayoutEffect`,CSS 变量与 React 提交同帧
|
||||||
|
- 过渡参数对齐:全部 `0.2s ease` → `0.15s linear`,桌面应用更利落
|
||||||
|
- CSS 过渡精准覆盖:antd 容器组件(Layout/Card/Modal/Table/Input/Menu 等 ~80 个类)替代 `html *` 全局选择器
|
||||||
|
- 设置页 Esc 键关闭 + macOS 关闭按钮适配 + 关闭后焦点防跳转
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0.0.14 -- 🚀更新公告
|
||||||
|
|
||||||
|
**模型选择器焕新**
|
||||||
|
|
||||||
|
- 模型列表改为标签页分组展示,新增「全部模型」Tab
|
||||||
|
- 列表项新增悬停高亮效果,维护中模型自动置灰并提示
|
||||||
|
- 修复选择模型后参数表单重复 key 报错
|
||||||
|
- 类型体系重构,支持后端分类 slug 自动映射
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0.0.13(2026-06-07)
|
||||||
|
|
||||||
|
- 修复主题切换后首页状态重置问题:设置页去路由化,改为事件总线驱动的 Modal 叠加
|
||||||
|
- 首页三栏分隔条视觉对齐:移除右侧多余 margin,左右完全对称
|
||||||
|
- 轮播图新增关闭按钮:session 级别隐藏,每次启动重新展示
|
||||||
|
- 确立 QT 多窗口模型 → Web 架构映射原则:叠加层走 Modal/Drawer + 事件总线,不走路由
|
||||||
|
- 修复首页三栏布局溢出:拖拽分隔条增加最大宽度约束 + 视口缩小时自动收窄面板,防止右侧面板被挤出视口
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
28
CLAUDE.md
28
CLAUDE.md
@@ -1,4 +1,26 @@
|
|||||||
# 回答语言
|
# 基本交代
|
||||||
|
|
||||||
|
- 所有交互、解释、代码注释与生成内容均使用简体中文,专有技术名词可保留英文,但需附带中文说明;
|
||||||
|
- 有需要请自行调用MCP服务;
|
||||||
|
|
||||||
|
# 业务设计思路
|
||||||
|
|
||||||
|
- 进行任务时都需要考虑安全、性能、主题切换、业务自定义错误、事件总线驱动方式以及日志记录;
|
||||||
|
- 进行系统级别的操作时,需要考虑系统差异,并处理兼容性。如WINDOWS、MAC OS;
|
||||||
|
- 处理业务时,需要同时考虑兜底机制和业务自定义错误捕获;
|
||||||
|
- 所有设计思路、代码实现均需要考虑以上三个条件。
|
||||||
|
|
||||||
|
# 相关文档说明
|
||||||
|
|
||||||
|
- 根目录下的文档包括:
|
||||||
|
- README.md(git中的README文档),
|
||||||
|
- WORKLOG.md(工作日志文档),
|
||||||
|
- UpdateA.md(热更新相关的文档),
|
||||||
|
- TODO.md(未完善、未来要做的文档,或者是在开发中遇到需要优化的地方),
|
||||||
|
- PROJECT.md(项目文档),
|
||||||
|
- CHANGELOG.md(版本更新文档),
|
||||||
|
- CLAUDE.md
|
||||||
|
- 永远不要修改CLAUDE.md文档
|
||||||
|
- 在各个文档中的更新需要保持精简。且职责划分保持严谨,除了架构、项目文档,已完成的事需要从TODO.md文档中移除。
|
||||||
|
- 在没有得到修改指令时,不要修改文档文件。
|
||||||
|
|
||||||
- 所有交互、解释、代码注释与生成内容均使用简体中文,专有技术名词可保留英文,但需附带中文说明
|
|
||||||
- 进行任务时都需要考虑安全、性能、主题切换、业务自定义错误、事件总线驱动方式以及日志记录
|
|
||||||
|
|||||||
213
PROJECT.md
213
PROJECT.md
@@ -36,77 +36,76 @@
|
|||||||
遵循 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+ 管理页面切换,支持嵌套路由。
|
- 路由与导航:主窗口为单页应用,使用 React Router v7+ 管理页面切换。**遵循 QT 多窗口模型映射原则**:
|
||||||
|
- 叠加层(设置、公告、素材库等)→ Modal/Drawer + 事件总线驱动,主页面不卸载
|
||||||
|
- 页面跳转(真正的视图切换)→ React Router,需跨页保持的状态放 Provider 或 localStorage
|
||||||
|
|
||||||
- 辅助窗口(设置、预览、导入导出等)为独立 BrowserWindow,加载独立的 HTML 入口(可根据路由参数或 windowId 决定内部路由),不与主窗口共享
|
- 辅助窗口(设置、预览、导入导出等)以 Modal/Drawer 叠加在主窗口之上,不与主窗口共享 DOM,但保持在同一个 React 组件树中以确保状态一致性。
|
||||||
DOM。
|
|
||||||
|
|
||||||
# 目录结构
|
# 目录结构
|
||||||
|
|
||||||
```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/ # 可复用组件(按职责分三层)
|
||||||
│ │ ├── AppProvider.tsx # 全局状态(platform / edition / login)
|
│ │ ├── providers/ # 状态管理(App/Theme/SettingsProvider)
|
||||||
│ │ ├── ThemeProvider.tsx # 主题状态(light / dark)
|
│ │ ├── shell/ # 应用外壳(Layout/PageDispatcher/navbar/)
|
||||||
│ │ ├── SettingsProvider.tsx # 设置状态(存储路径等)
|
│ │ └── ui/ # 通用 UI 原子(FloatingPanel/LegalTextModal)
|
||||||
│ │ ├── Layout.tsx # 页面壳(NavBar + Outlet)
|
│ ├── hooks/ # 自定义 Hooks(业务逻辑封装)
|
||||||
│ │ └── navbar/ # 导航栏 + 公告抽屉
|
│ ├── pages/ # 业务页面(按功能模块组织)
|
||||||
│ ├── contexts/ # Context 定义(app / settings / theme)
|
│ │ ├── home/ # 首页(panels/ features/ controls/ hooks/)
|
||||||
│ ├── hooks/ # 自定义 Hooks(useUpdater / useTheme)
|
│ │ ├── auth/ # 认证模块(登录/注册/忘记密码/修改密码)
|
||||||
│ ├── pages/ # 页面组件
|
│ │ ├── settings/ # 设置面板(sections/)
|
||||||
│ │ ├── home/ # 首页仪表盘
|
│ │ └── panels/ # NavBar 触发的面板页(Account/Billing/WechatWork)
|
||||||
│ │ ├── login/ # 登录 / 注册页
|
│ ├── router/index.tsx # HashRouter 路由配置
|
||||||
│ │ └── settings/ # 设置面板(主题 / 存储 / 更新)
|
│ ├── services/ # HTTP 请求封装
|
||||||
│ ├── router/index.tsx # HashRouter 路由配置
|
│ │ ├── request.ts # Axios 封装 + 拦截器 + Token 刷新
|
||||||
│ ├── services/ # HTTP 请求封装
|
│ │ ├── types.ts # 自定义错误类型(RequestError / RefreshError)
|
||||||
│ │ ├── request.ts # Axios 封装 + 拦截器 + Token 刷新
|
│ │ ├── auth-token.ts # refresh_token 管理
|
||||||
│ │ ├── types.ts # 自定义错误类型(RequestError / RefreshError)
|
│ │ └── modules/ # API 模块(auth / announcement)
|
||||||
│ │ ├── auth-token.ts # refresh_token 管理
|
│ ├── theme/ # 主题系统
|
||||||
│ │ └── modules/ # API 模块(auth / announcement)
|
│ │ ├── tokens.ts # 设计令牌
|
||||||
│ ├── theme/ # 主题系统
|
│ │ └── globals.css # Tailwind + 全局样式
|
||||||
│ │ ├── tokens.ts # 设计令牌
|
│ └── utils/ # 纯函数工具
|
||||||
│ │ └── globals.css # Tailwind + 全局样式
|
│ ├── event-bus.ts # 事件总线(on / off / emit + 日志钩子)
|
||||||
│ └── utils/ # 纯函数工具
|
│ ├── logger.ts # 渲染进程日志器(IPC 转发 / DEV 控制台)
|
||||||
│ ├── event-bus.ts # 事件总线(on / off / emit + 日志钩子)
|
│ ├── ipc.ts # 安全 IPC 守卫(safeIpcOn / send / invoke)
|
||||||
│ ├── logger.ts # 渲染进程日志器(IPC 转发 / DEV 控制台)
|
│ ├── platform.ts # 平台判定
|
||||||
│ ├── ipc.ts # 安全 IPC 守卫(safeIpcOn / send / invoke)
|
│ └── device.ts # 设备 ID 生成
|
||||||
│ ├── platform.ts # 平台判定
|
├── shared/ # 主进程 & 渲染进程共享
|
||||||
│ └── device.ts # 设备 ID 生成
|
│ ├── types/ # 类型定义(API / IPC / update / logging)
|
||||||
├── shared/ # 主进程 & 渲染进程共享
|
│ ├── constants/ # 常量(IPC 通道 / version / app)
|
||||||
│ ├── types/ # 类型定义(API / IPC / update / logging)
|
│ └── utils/ # 共享工具
|
||||||
│ ├── constants/ # 常量(IPC 通道 / version / app)
|
│ ├── index.ts # debounce / throttle / compareVersions
|
||||||
│ └── utils/ # 共享工具
|
│ └── sanitize.ts # 日志脱敏工具(ID / 邮箱 / 手机 / 名称)
|
||||||
│ ├── index.ts # debounce / throttle / compareVersions
|
├── scripts/ # 构建脚本
|
||||||
│ └── sanitize.ts # 日志脱敏工具(ID / 邮箱 / 手机 / 名称)
|
│ ├── build.mjs # 构建总管
|
||||||
├── scripts/ # 构建脚本
|
│ ├── clean.mjs # 清理产物
|
||||||
│ ├── build.mjs # 构建总管
|
│ ├── upload-oss.mjs # OSS 上传
|
||||||
│ ├── clean.mjs # 清理产物
|
│ ├── publish-release.mjs # 发布到 API
|
||||||
│ ├── upload-oss.mjs # OSS 上传
|
│ └── generate-update-info.mjs # 生成更新元信息
|
||||||
│ ├── publish-release.mjs # 发布到 API
|
├── package.json # 含 electron-builder 打包配置
|
||||||
│ └── generate-update-info.mjs # 生成更新元信息
|
|
||||||
├── package.json # 含 electron-builder 打包配置
|
|
||||||
├── vite.config.ts
|
├── vite.config.ts
|
||||||
├── tsconfig.json
|
├── tsconfig.json
|
||||||
└── tailwind.config.ts
|
└── tailwind.config.ts
|
||||||
@@ -150,12 +149,14 @@
|
|||||||
## 状态管理
|
## 状态管理
|
||||||
|
|
||||||
- 使用 React Context + Provider 模式管理全局状态,按领域拆分:AppContext(平台/登录)、ThemeContext(主题)、
|
- 使用 React Context + Provider 模式管理全局状态,按领域拆分:AppContext(平台/登录)、ThemeContext(主题)、
|
||||||
SettingsContext(设置项)。避免引入 Redux 等重量级方案。
|
SettingsContext(设置项)。各 Context 定义与 Provider 合并在 `components/providers/` 目录下,避免引入 Redux 等重量级方案。
|
||||||
|
|
||||||
- Provider 组件在 main.tsx 中按层级嵌套挂载:
|
- Provider 在 main.tsx 中按层级嵌套挂载:
|
||||||
AppProvider → ThemeProvider → SettingsProvider → AntdApp → App
|
AppProvider → ThemeProvider → SettingsProvider → AntdApp → App
|
||||||
|
|
||||||
- 各模块通过自定义 Hook 消费(useAppContext / useTheme / useSettings),Hook 内部做 null 检查。
|
- 各模块通过自定义 Hook 消费(useAppContext / useTheme / useSettings),Hook 内部做 null 检查并抛出明确错误信息。
|
||||||
|
|
||||||
|
- 页面级状态(如首页的 selectedModel / selectedTask / 分页)放在 HomeContext 中,由 HomeContent 提供,遵循与全局 Provider 相同的 Context + Provider + Hook 模式。
|
||||||
|
|
||||||
## 日志系统
|
## 日志系统
|
||||||
|
|
||||||
@@ -169,15 +170,52 @@
|
|||||||
|
|
||||||
- 错误日志:request.ts 拦截器根据错误类型自动分级记录(AUTH_EXPIRED → debug,5xx → error,其余 → warn)。
|
- 错误日志:request.ts 拦截器根据错误类型自动分级记录(AUTH_EXPIRED → debug,5xx → error,其余 → warn)。
|
||||||
|
|
||||||
|
- 401 判断策略:HTTP 401 拦截处双重校验——同时检查 HTTP 状态码和响应体中的业务码 `data.code`。
|
||||||
|
仅 `status === 401 && data?.code === 401` 才走 token 刷新流程;其他业务码(如密码错误)穿透到正常错误处理,
|
||||||
|
透传后端的 `data.message`。这确保登录接口返回的"账号或密码错误"不被 RefreshError("登录已过期") 覆盖。
|
||||||
|
|
||||||
- 脱敏:shared/utils/sanitize.ts 在日志写入前自动处理 ID(哈希)、邮箱/手机/名称/Token 等字段。
|
- 脱敏:shared/utils/sanitize.ts 在日志写入前自动处理 ID(哈希)、邮箱/手机/名称/Token 等字段。
|
||||||
|
|
||||||
|
# 安全与认证
|
||||||
|
|
||||||
|
## Token 加密存储
|
||||||
|
|
||||||
|
- `access_token` 和 `refresh_token` 通过 Electron `safeStorage` API 加密存储,不落明文:
|
||||||
|
- 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 存取
|
||||||
|
|
||||||
|
## 双 Token 机制
|
||||||
|
|
||||||
|
- `access_token`(短效)+ `refresh_token`(长效),登录成功同时下发
|
||||||
|
- 请求拦截器自动附加 `Authorization: Bearer <access_token>`
|
||||||
|
- 401 响应 → 双重校验(HTTP `status` + 业务 `data.code`)→ 仅 `code === 401` 时触发刷新
|
||||||
|
- 刷新期间并发请求排队(`failedQueue`),刷新完成后批量重试
|
||||||
|
- refresh 接口自身 401 直接拒绝(防死循环),login 接口 401 穿透透传后端 `message`
|
||||||
|
- 启动时 `refresh_token` 静默续期(自动登录),失败仅清除标记不影响用户名回填
|
||||||
|
- 登录/退出通过事件总线解耦(`LOGIN_SUCCESS` / `LOGOUT` / `AUTH_REQUIRED`)
|
||||||
|
|
||||||
|
## 日志脱敏
|
||||||
|
|
||||||
|
- `shared/utils/sanitize.ts` 在日志写入前自动脱敏处理
|
||||||
|
- 覆盖字段:用户 ID(SHA256 哈希)、邮箱(`***@domain`)、手机号(掩码)、用户名称、Token(截断)
|
||||||
|
- 主进程 `electron/main/logger.ts` 和渲染进程 `src/utils/logger.ts` 均写入前调用 `sanitizeLogEntry()`
|
||||||
|
|
||||||
# 样式与主题
|
# 样式与主题
|
||||||
|
|
||||||
- 优先使用 Tailwind 工具类 完成布局与常规样式,自定义设计令牌应映射为 Tailwind 扩展(在 tailwind.config.ts 中配置
|
- 优先使用 Tailwind 工具类 完成布局与常规样式,自定义设计令牌应映射为 Tailwind 扩展(在 tailwind.config.ts 中配置
|
||||||
theme.extend)。
|
theme.extend)。
|
||||||
|
|
||||||
- Ant Design 组件样式通过 ConfigProvider 的 theme 属性覆盖,确保与 Tailwind 主题令牌同步。在 src/theme/adapters/ 中创建
|
- Ant Design 组件样式通过 ConfigProvider 的 theme 属性覆盖,确保与 Tailwind 主题令牌同步。
|
||||||
antd-theme.ts,导出主题配置对象,引用 tokens.ts 中的设计变量。
|
`ConfigProvider` 同时配置 `locale={zhCN}` 实现全局中文(分页器"条/页"等无需逐个组件设置)。
|
||||||
|
在 src/theme/ 中创建 antd-theme.ts,导出主题配置对象,引用 tokens.ts 中的设计变量。
|
||||||
|
组件级 token(如 Pagination.itemActiveColor / itemActiveBg)在 sharedComponents 中统一管理,
|
||||||
|
亮色/暗色主题共享,避免 CSS !important hack。
|
||||||
|
|
||||||
- 禁止在组件中硬编码颜色、字号等设计令牌,一律使用主题变量或 Tailwind 类名。
|
- 禁止在组件中硬编码颜色、字号等设计令牌,一律使用主题变量或 Tailwind 类名。
|
||||||
|
|
||||||
@@ -223,15 +261,28 @@
|
|||||||
└─ 下载完成 → 用户点击"安装更新" → 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` 文件
|
||||||
|
- 自定义 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
|
||||||
|
- 效果:日常更新仅下载变更区块,预计节省 60%~90% 流量
|
||||||
|
|
||||||
## 环境变量加载机制(主进程)
|
## 环境变量加载机制(主进程)
|
||||||
|
|
||||||
@@ -244,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 规格、数据库表结构、实现清单 |
|
||||||
|
|||||||
122
TODO.md
122
TODO.md
@@ -1,49 +1,6 @@
|
|||||||
# TODO — 船长·HeiXiu 待办与优化清单
|
# TODO — 船长·HeiXiu 待办与优化清单
|
||||||
|
|
||||||
> 最后更新:2026-06-03
|
> 最后更新:2026-06-15
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔴 安全加固(优先级高)
|
|
||||||
|
|
||||||
### 1. 敏感数据加密存储
|
|
||||||
|
|
||||||
当前所有敏感数据存储在 `localStorage` 明文/Base64:
|
|
||||||
|
|
||||||
| 数据 | 现状 | 目标方案 |
|
|
||||||
|------------------|-----------|----------------------------------------|
|
|
||||||
| `access_token` | 明文 | Electron `safeStorage.encryptString()` |
|
|
||||||
| `refresh_token` | 明文 | Electron `safeStorage.encryptString()` |
|
|
||||||
| `passwordBase64` | Base64 编码 | 移除密码存储,仅靠 refresh_token 自动登录 |
|
|
||||||
|
|
||||||
**方案 A(推荐)**:使用 Electron `safeStorage` API
|
|
||||||
|
|
||||||
- macOS → Keychain
|
|
||||||
- Windows → DPAPI
|
|
||||||
- Linux → libsecret
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// preload.ts 暴露安全方法
|
|
||||||
contextBridge.exposeInMainWorld('safeStorage', {
|
|
||||||
encrypt: (plain: string) => ipcRenderer.invoke('safe-storage:encrypt', plain),
|
|
||||||
decrypt: (encrypted: Buffer) => ipcRenderer.invoke('safe-storage:decrypt', encrypted),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 替代现有 setToken / setStoredRefreshToken
|
|
||||||
// 渲染进程不再直接操作 localStorage
|
|
||||||
```
|
|
||||||
|
|
||||||
**方案 B**:敏感操作下沉到主进程
|
|
||||||
|
|
||||||
- `access_token` / `refresh_token` 由主进程管理
|
|
||||||
- 渲染进程通过 IPC 发起请求时由主进程追加 Token
|
|
||||||
- 渲染进程永不持有明文 Token
|
|
||||||
|
|
||||||
### 2. 密码存储策略调整
|
|
||||||
|
|
||||||
- [ ] "记住密码" → 仅回填用户名,不再存储密码(已完成)
|
|
||||||
- [ ] 清理 `StoredAuth.passwordBase64` 字段
|
|
||||||
- [ ] "自动登录" 完全依赖 `refresh_token` 静默续期
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -57,24 +14,32 @@ contextBridge.exposeInMainWorld('safeStorage', {
|
|||||||
|
|
||||||
### 路由与页面
|
### 路由与页面
|
||||||
|
|
||||||
|
- [x] 账户信息页面(`/account`) — `AccountInfo.tsx`,AuthAPI.getUserInfo() 驱动
|
||||||
|
- [x] 充值页面(`/recharge`) — `RechargePage.tsx`,创建订单 → 支付状态跟踪
|
||||||
|
- [x] 订单明细页面(`/orders`) — `OrdersPage.tsx`,状态筛选 + 分页 + 继续支付 + 轮询
|
||||||
- [ ] 合规素材库页面(`/compliance-assets`)
|
- [ ] 合规素材库页面(`/compliance-assets`)
|
||||||
- [ ] 项目管理页面(`/projects`,企业版)
|
- [ ] 项目管理页面(`/projects`,企业版)
|
||||||
- [ ] 账户/会员/充值/消费记录/订单明细页面
|
- [ ] 会员页面(`/vip`)
|
||||||
- [ ] 忘记密码流程
|
- [ ] 消费记录页面
|
||||||
- [ ] 各页面暗色主题适配
|
|
||||||
|
|
||||||
### 导航栏
|
### 导航栏
|
||||||
|
|
||||||
- [ ] 移除调试用的 `console.log`
|
|
||||||
- [ ] 导航配置中的 `action: 'external'` 统一管理(白名单校验)
|
- [ ] 导航配置中的 `action: 'external'` 统一管理(白名单校验)
|
||||||
- [ ] 未登录点击需登录项 → 登录成功后自动跳转到目标页
|
- [ ] 未登录点击需登录项 → 登录成功后自动跳转到目标页
|
||||||
|
|
||||||
### 公告
|
### 公告
|
||||||
|
|
||||||
|
- [x] 公告数据获取与展示(AnnouncementDrawer + fetchAnnouncements API)
|
||||||
- [ ] 公告已读/未读状态
|
- [ ] 公告已读/未读状态
|
||||||
- [ ] 公告红点/角标提醒(有新公告时导航栏图标变化)
|
- [ ] 公告红点/角标提醒(有新公告时导航栏图标变化)
|
||||||
- [ ] 公告缓存(减少重复请求)
|
- [ ] 公告缓存(减少重复请求)
|
||||||
|
|
||||||
|
### 任务
|
||||||
|
|
||||||
|
- [x] 任务状态轮询(批量轮询 + Context 桥接 + 自适应间隔)
|
||||||
|
- [ ] 任务列表导出 CSV 功能完善
|
||||||
|
- [ ] 任务重试/重新提交
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🟢 工程优化(优先级低)
|
## 🟢 工程优化(优先级低)
|
||||||
@@ -87,60 +52,13 @@ contextBridge.exposeInMainWorld('safeStorage', {
|
|||||||
- [ ] 单元测试覆盖(Vitest)
|
- [ ] 单元测试覆盖(Vitest)
|
||||||
- [ ] Bundle 体积分析优化(rollup-plugin-visualizer 已有,定期检查)
|
- [ ] Bundle 体积分析优化(rollup-plugin-visualizer 已有,定期检查)
|
||||||
|
|
||||||
### 增量更新改造
|
|
||||||
|
|
||||||
当前状态:
|
|
||||||
|
|
||||||
- `electron-builder` 已配置 `"differential": true`,打包时已生成 `.exe.blockmap` 文件 ✅
|
|
||||||
- `electron/main/updater.ts` 为**手写全量下载**(`net.request`),未使用 `electron-updater` npm 包 ❌
|
|
||||||
- blockmap 文件生成了但从未被消费,每次更新都是完整下载
|
|
||||||
|
|
||||||
目标方案 — 自定义 Provider + electron-updater:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// electron/main/updater-v2.ts
|
|
||||||
import { autoUpdater } from 'electron-updater';
|
|
||||||
import { Provider } from 'electron-updater/out/providers/Provider';
|
|
||||||
|
|
||||||
class HeiXiuProvider extends Provider<UpdateInfo> {
|
|
||||||
// 版本检查 → 走自定义 API(保留灰度/强更控制)
|
|
||||||
async getLatestVersion(): Promise<UpdateInfo> {
|
|
||||||
const res = await fetch(`${API_BASE}/api/v1/update/check?...`);
|
|
||||||
const data = await res.json();
|
|
||||||
return {
|
|
||||||
version: data.latestVersion,
|
|
||||||
files: [{
|
|
||||||
url: data.downloadUrl,
|
|
||||||
sha512: data.sha512,
|
|
||||||
size: data.fileSize,
|
|
||||||
blockMapSize: data.blockMapSize, // 后端新增字段
|
|
||||||
}],
|
|
||||||
path: data.downloadUrl,
|
|
||||||
sha512: data.sha512,
|
|
||||||
releaseDate: data.releaseDate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
改造内容:
|
|
||||||
|
|
||||||
- [ ] 安装 `electron-updater` npm 包
|
|
||||||
- [ ] 重写 `electron/main/updater.ts` 为自定义 Provider 模式
|
|
||||||
- [ ] 发布脚本 `upload-oss.mjs` 增加 blockmap 文件上传
|
|
||||||
- [ ] `generate-update-info.mjs` 增加 blockmap 字段(`blockMapUrl`、`blockMapSize`)
|
|
||||||
- [ ] 后端 API `/api/v1/update/check` 响应增加 `blockMapUrl`、`blockMapSize` 字段
|
|
||||||
- [ ] 后端 `releases` 表增加 `blockmap_url`、`blockmap_size` 列
|
|
||||||
- [ ] OSS 上传每次版本需同时上传 `.exe` + `.exe.blockmap` 两个文件
|
|
||||||
|
|
||||||
增量效果预估:日常更新下载量从完整安装包 → 仅变化的块(预计节省 60%~90% 流量)。
|
|
||||||
|
|
||||||
### 代码质量
|
### 代码质量
|
||||||
|
|
||||||
- [ ] 移除未使用的 `shared/` 类型和常量
|
- [ ] 移除未使用的 `shared/` 类型和常量
|
||||||
- [ ] 统一 IPC 通道注册/注销模式(避免重复监听)
|
- [ ] 统一 IPC 通道注册/注销模式(避免重复监听)
|
||||||
- [ ] React 组件 lazy loading(React.lazy + Suspense)
|
- [ ] React 组件 lazy loading(React.lazy + Suspense)
|
||||||
- [ ] 错误边界组件(ErrorBoundary)
|
- [ ] 错误边界组件(ErrorBoundary)
|
||||||
|
- [ ] 审计其余 useEffect 缺少清理函数的位置(StrictMode 双重挂载可能导致重复请求,参考 AppProvider 的 aborted 模式)
|
||||||
|
|
||||||
### 文档
|
### 文档
|
||||||
|
|
||||||
@@ -163,6 +81,12 @@ class HeiXiuProvider extends Provider<UpdateInfo> {
|
|||||||
|
|
||||||
## 📋 下次开发计划
|
## 📋 下次开发计划
|
||||||
|
|
||||||
1. **safeStorage 加密改造**(安全加固 #1)
|
1. **任务列表导出 CSV**(`task.ts` 已定义 `exportTaskCSV` API,前端未对接下载按钮)
|
||||||
2. **合规素材库页面**(功能待完善 Route 页面)
|
2. **文件上传端点确认**(`/api/v1/upload` 待后端确认,当前 useFileUpload 已完成前端集成)
|
||||||
3. **各页面 nav 导航补齐**
|
3. **合规素材库页面**(功能待完善 — 按 QT 原版设计,以 Modal/Drawer 叠加而非路由跳转实现)
|
||||||
|
4. **各页面 nav 导航补齐**(后续页面优先使用叠加模式,遵循 QT 多窗口模型 → 见 WORKLOG 2026-06-07)
|
||||||
|
5. **leftWidth / rightWidth / taskPage 加 localStorage 持久化**(UX 优化:刷新页面后保持用户偏好;已通过路由去路由化解决组件卸载问题,但
|
||||||
|
localStorage 持久层仍未实现)
|
||||||
|
6. **轮播图关闭按钮 localStorage 持久化**(关闭按钮已实现,待做:localStorage 记录关闭时间戳,超过 N 天再显示)
|
||||||
|
7. **短信发送接入人机验证**(后期优化,需后端配合:`sendSms` 接口增加 `captcha_token` 参数 + 前端滑块/图形验证组件。当前仅靠
|
||||||
|
60s 冷却按钮 + 后端限流,桌面端 ASAR 可解包 + DevTools 网络面板 + 代理抓包均能直接暴露 API,纯前端限制对脚本攻击无效)
|
||||||
|
|||||||
206
electron/main.ts
206
electron/main.ts
@@ -1,18 +1,18 @@
|
|||||||
import { app, BrowserWindow, ipcMain, dialog } from 'electron';
|
import {app, BrowserWindow, ipcMain, dialog, session} from 'electron';
|
||||||
import { createRequire } from 'node:module';
|
import {createRequire} from 'node:module';
|
||||||
import { fileURLToPath } from 'node:url';
|
import {fileURLToPath} from 'node:url';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { getPlatform, isMacOS } from './main/utils/platform';
|
import {getPlatform, isMacOS} from './main/utils/platform';
|
||||||
import { getWindowIconPath } from './main/utils/logo';
|
import {getWindowIconPath} from './main/utils/logo';
|
||||||
import { buildWindowTitle, parseEdition } from '../shared/constants/app';
|
import {buildWindowTitle, parseEdition} from '../shared/constants/app';
|
||||||
import { loadEnvFile } from './main/load-env';
|
import {loadEnvFile} from './main/load-env';
|
||||||
import { initUpdater, registerUpdateIpcHandlers } from './main/updater';
|
import {initUpdater, registerUpdateIpcHandlers} from './main/updater';
|
||||||
import { setupAppMenu } from './main/menu';
|
import {setupAppMenu} from './main/menu';
|
||||||
import { initLogger, flushLogger, logger } from './main/logger';
|
import {initLogger, flushLogger, logger} from './main/logger';
|
||||||
import { registerLogIpcHandlers } from './main/log-ipc';
|
import {registerLogIpcHandlers} from './main/log-ipc';
|
||||||
import { registerSafeStorageIpcHandlers } from './main/safe-storage-ipc';
|
import {registerSafeStorageIpcHandlers} from './main/safe-storage-ipc';
|
||||||
import { BIDIRECTIONAL } from '../shared/constants/ipc-channels';
|
import {BIDIRECTIONAL} from '../shared/constants/ipc-channels';
|
||||||
|
|
||||||
createRequire(import.meta.url);
|
createRequire(import.meta.url);
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -28,8 +28,8 @@ export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron');
|
|||||||
export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist');
|
export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist');
|
||||||
|
|
||||||
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
|
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
|
||||||
? path.join(process.env.APP_ROOT, 'public')
|
? path.join(process.env.APP_ROOT, 'public')
|
||||||
: RENDERER_DIST;
|
: RENDERER_DIST;
|
||||||
|
|
||||||
// ---------- 平台 & 版本信息 ----------
|
// ---------- 平台 & 版本信息 ----------
|
||||||
const currentPlatform = getPlatform();
|
const currentPlatform = getPlatform();
|
||||||
@@ -41,20 +41,20 @@ const WINDOW_TITLE = buildWindowTitle(currentPlatform, currentEdition);
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
process.on('uncaughtException', (error) => {
|
process.on('uncaughtException', (error) => {
|
||||||
try {
|
try {
|
||||||
logger.error('app', 'Uncaught exception (main process)', error);
|
logger.error('app', 'Uncaught exception (main process)', error);
|
||||||
} catch {
|
} catch {
|
||||||
/* logger 自身异常 — 最后防线 */
|
/* logger 自身异常 — 最后防线 */
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on('unhandledRejection', (reason) => {
|
process.on('unhandledRejection', (reason) => {
|
||||||
try {
|
try {
|
||||||
const error = reason instanceof Error ? reason : new Error(String(reason));
|
const error = reason instanceof Error ? reason : new Error(String(reason));
|
||||||
logger.error('app', 'Unhandled rejection (main process)', error);
|
logger.error('app', 'Unhandled rejection (main process)', error);
|
||||||
} catch {
|
} catch {
|
||||||
/* logger 自身异常 — 最后防线 */
|
/* logger 自身异常 — 最后防线 */
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -68,60 +68,60 @@ let mainWindow: BrowserWindow | null = null;
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
function createMainWindow(): BrowserWindow {
|
function createMainWindow(): BrowserWindow {
|
||||||
const iconPath = getWindowIconPath(app.getAppPath());
|
const iconPath = getWindowIconPath(app.getAppPath());
|
||||||
|
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
title: WINDOW_TITLE,
|
title: WINDOW_TITLE,
|
||||||
icon: iconPath,
|
icon: iconPath,
|
||||||
width: 1280,
|
width: 1280,
|
||||||
height: 800,
|
height: 800,
|
||||||
minWidth: 960,
|
minWidth: 960,
|
||||||
minHeight: 600,
|
minHeight: 600,
|
||||||
show: false,
|
show: false,
|
||||||
...(isMacOS() ? { titleBarStyle: 'hiddenInset' as const } : {}),
|
...(isMacOS() ? {titleBarStyle: 'hiddenInset' as const} : {}),
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, 'preload.mjs'),
|
preload: path.join(__dirname, 'preload.mjs'),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
mainWindow.setTitle(WINDOW_TITLE);
|
mainWindow.setTitle(WINDOW_TITLE);
|
||||||
|
|
||||||
if (VITE_DEV_SERVER_URL) {
|
if (VITE_DEV_SERVER_URL) {
|
||||||
mainWindow.webContents.openDevTools();
|
mainWindow.webContents.openDevTools();
|
||||||
}
|
}
|
||||||
|
|
||||||
// macOS 全屏事件
|
// macOS 全屏事件
|
||||||
if (isMacOS()) {
|
if (isMacOS()) {
|
||||||
mainWindow.on('enter-full-screen', () =>
|
mainWindow.on('enter-full-screen', () =>
|
||||||
mainWindow?.webContents.send('window-fullscreen-changed', true),
|
mainWindow?.webContents.send('window-fullscreen-changed', true),
|
||||||
);
|
);
|
||||||
mainWindow.on('leave-full-screen', () =>
|
mainWindow.on('leave-full-screen', () =>
|
||||||
mainWindow?.webContents.send('window-fullscreen-changed', false),
|
mainWindow?.webContents.send('window-fullscreen-changed', false),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
mainWindow.webContents.on('did-finish-load', () => {
|
mainWindow.webContents.on('did-finish-load', () => {
|
||||||
mainWindow?.webContents.send('main-process-message', new Date().toLocaleString());
|
mainWindow?.webContents.send('main-process-message', new Date().toLocaleString());
|
||||||
mainWindow?.webContents.send('platform-info', {
|
mainWindow?.webContents.send('platform-info', {
|
||||||
platform: currentPlatform,
|
platform: currentPlatform,
|
||||||
edition: currentEdition,
|
edition: currentEdition,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ready-to-show 后显示,避免白屏闪烁
|
// ready-to-show 后显示,避免白屏闪烁
|
||||||
mainWindow.once('ready-to-show', () => {
|
mainWindow.once('ready-to-show', () => {
|
||||||
mainWindow?.show();
|
mainWindow?.show();
|
||||||
});
|
});
|
||||||
|
|
||||||
if (VITE_DEV_SERVER_URL) {
|
if (VITE_DEV_SERVER_URL) {
|
||||||
mainWindow.loadURL(VITE_DEV_SERVER_URL);
|
mainWindow.loadURL(VITE_DEV_SERVER_URL);
|
||||||
} else {
|
} else {
|
||||||
mainWindow.loadFile(path.join(RENDERER_DIST, 'index.html'));
|
mainWindow.loadFile(path.join(RENDERER_DIST, 'index.html'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return mainWindow;
|
return mainWindow;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -129,41 +129,51 @@ function createMainWindow(): BrowserWindow {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
if (!isMacOS()) {
|
if (!isMacOS()) {
|
||||||
app.quit();
|
app.quit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
createMainWindow();
|
createMainWindow();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('before-quit', () => {
|
app.on('before-quit', () => {
|
||||||
flushLogger();
|
flushLogger();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
initLogger();
|
initLogger();
|
||||||
registerLogIpcHandlers();
|
registerLogIpcHandlers();
|
||||||
registerSafeStorageIpcHandlers();
|
registerSafeStorageIpcHandlers();
|
||||||
setupAppMenu();
|
setupAppMenu();
|
||||||
app.setName(WINDOW_TITLE);
|
app.setName(WINDOW_TITLE);
|
||||||
registerUpdateIpcHandlers();
|
registerUpdateIpcHandlers();
|
||||||
|
const fakeUserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36';
|
||||||
|
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
|
||||||
|
const url = details.url;
|
||||||
|
if (url.includes("rh-images-1252422369.cos.ap-beijing.myqcloud.com")) {
|
||||||
|
details.requestHeaders['User-Agent'] = fakeUserAgent;
|
||||||
|
}
|
||||||
|
callback({
|
||||||
|
requestHeaders: details.requestHeaders
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// 文件对话框 IPC 处理(供渲染进程选择文件夹)
|
// 文件对话框 IPC 处理(供渲染进程选择文件夹)
|
||||||
ipcMain.handle(BIDIRECTIONAL.FILE_DIALOG, async (_event, options: Electron.OpenDialogOptions) => {
|
ipcMain.handle(BIDIRECTIONAL.FILE_DIALOG, async (_event, options: Electron.OpenDialogOptions) => {
|
||||||
if (!mainWindow) return { canceled: true, filePaths: [] };
|
if (!mainWindow) return {canceled: true, filePaths: []};
|
||||||
return dialog.showOpenDialog(mainWindow, {
|
return dialog.showOpenDialog(mainWindow, {
|
||||||
title: options?.title || '选择文件夹',
|
title: options?.title || '选择文件夹',
|
||||||
defaultPath: options?.defaultPath || app.getPath('home'),
|
defaultPath: options?.defaultPath || app.getPath('home'),
|
||||||
properties: options?.properties || ['openDirectory'],
|
properties: options?.properties || ['openDirectory'],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 始终打开主窗口
|
// 始终打开主窗口
|
||||||
// 登录/注册由渲染进程内的 Modal 弹层处理,不再新开窗口
|
// 登录/注册由渲染进程内的 Modal 弹层处理,不再新开窗口
|
||||||
const win = createMainWindow();
|
const win = createMainWindow();
|
||||||
initUpdater(win);
|
initUpdater(win);
|
||||||
});
|
});
|
||||||
|
|||||||
372
electron/preload/device.ts
Normal file
372
electron/preload/device.ts
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
/**
|
||||||
|
* 设备指纹采集 — TypeScript 实现
|
||||||
|
*
|
||||||
|
* 与 device.py 逻辑一致,保留原 Python 方法不变。
|
||||||
|
* 通过采集 UUID / CPU / HDD 三项硬件 ID,拼接后做 SHA256 得到 device_id。
|
||||||
|
*
|
||||||
|
* 适用环境:
|
||||||
|
* - Node.js / Electron(Windows):通过 child_process 执行 wmic / powershell 采集硬件 ID
|
||||||
|
* - Node.js / Electron(macOS) :通过 ioreg / sysctl / system_profiler 采集硬件 ID
|
||||||
|
* - 浏览器:仅提供 SHA256 哈希工具函数,硬件采集需由上层注入
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as crypto from "node:crypto";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { platform } from "node:os";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 类型定义
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** 可采集的硬件组件类型 */
|
||||||
|
export type Component = "uuid" | "cpu" | "hdd";
|
||||||
|
|
||||||
|
/** 查询方式 */
|
||||||
|
export type QueryMethod = "auto" | "ps" | "powershell" | "wmic";
|
||||||
|
|
||||||
|
/** 内部解析后的方法 */
|
||||||
|
type ResolvedMethod = "auto" | "powershell" | "wmic";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 查询配置(对应 Python _COMPONENT_QUERIES)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
interface ComponentQuery {
|
||||||
|
powershell: string;
|
||||||
|
wmicArgs: string[];
|
||||||
|
headerTokens: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMPONENT_QUERIES: Record<Component, ComponentQuery> = {
|
||||||
|
uuid: {
|
||||||
|
powershell:
|
||||||
|
"Get-CimInstance -Class Win32_ComputerSystemProduct | " +
|
||||||
|
"Select-Object -ExpandProperty UUID",
|
||||||
|
wmicArgs: ["wmic", "csproduct", "get", "uuid"],
|
||||||
|
headerTokens: ["uuid"],
|
||||||
|
},
|
||||||
|
cpu: {
|
||||||
|
powershell:
|
||||||
|
"Get-CimInstance -Class Win32_Processor | " +
|
||||||
|
"Select-Object -ExpandProperty ProcessorId",
|
||||||
|
wmicArgs: ["wmic", "cpu", "get", "processorid"],
|
||||||
|
headerTokens: ["processorid"],
|
||||||
|
},
|
||||||
|
hdd: {
|
||||||
|
powershell:
|
||||||
|
"Get-CimInstance -Class Win32_DiskDrive | " +
|
||||||
|
"Select-Object -First 1 -ExpandProperty SerialNumber",
|
||||||
|
wmicArgs: ["wmic", "diskdrive", "get", "serialnumber"],
|
||||||
|
headerTokens: ["serialnumber"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Mac 查询配置
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
interface MacComponentQuery {
|
||||||
|
command: string[];
|
||||||
|
/** 从命令 stdout 中提取目标值 */
|
||||||
|
extract: (output: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAC_COMPONENT_QUERIES: Record<Component, MacComponentQuery> = {
|
||||||
|
uuid: {
|
||||||
|
command: ['ioreg', '-d2', '-c', 'IOPlatformExpertDevice'],
|
||||||
|
extract: (output: string): string => {
|
||||||
|
const m = output.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/);
|
||||||
|
return m?.[1] ?? '';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cpu: {
|
||||||
|
command: ['sysctl', '-n', 'hw.model'],
|
||||||
|
extract: (output: string): string => output.trim(),
|
||||||
|
},
|
||||||
|
hdd: {
|
||||||
|
command: ['system_profiler', 'SPNVMeDataType'],
|
||||||
|
extract: (output: string): string => {
|
||||||
|
const m = output.match(/Serial Number:\s*(.+)/i);
|
||||||
|
return m?.[1]?.trim() ?? '';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 工具函数
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** 对应 Python: _normalize_identifier(value) -> str */
|
||||||
|
function normalizeIdentifier(value: string): string {
|
||||||
|
if (!value) return "";
|
||||||
|
return value.replace(/[^A-Za-z0-9]/g, "").toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对应 Python: _first_value_from_output(raw_output, header_tokens) -> str */
|
||||||
|
function firstValueFromOutput(
|
||||||
|
rawOutput: string,
|
||||||
|
headerTokens: readonly string[],
|
||||||
|
): string {
|
||||||
|
if (!rawOutput) return "";
|
||||||
|
const headers = new Set(headerTokens.map((t) => t.toLowerCase()));
|
||||||
|
for (const line of rawOutput.split(/\r?\n/)) {
|
||||||
|
const candidate = line.trim();
|
||||||
|
if (!candidate) continue;
|
||||||
|
if (headers.has(candidate.toLowerCase())) continue;
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对应 Python: _resolve_method(method) -> _ResolvedMethod */
|
||||||
|
function resolveMethod(method: QueryMethod): ResolvedMethod {
|
||||||
|
if (method === "ps") return "powershell";
|
||||||
|
return method;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计算 SHA256 哈希(hex 大写),对应 Python hashlib.sha256(...).hexdigest().upper() */
|
||||||
|
function sha256HexUpper(input: string): string {
|
||||||
|
return crypto.createHash("sha256").update(input, "utf-8").digest("hex").toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 环境检测
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function isWindows(): boolean {
|
||||||
|
return platform() === "win32";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMacOS(): boolean {
|
||||||
|
return platform() === "darwin";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWmicAvailable(): boolean {
|
||||||
|
if (!isWindows()) return false;
|
||||||
|
const result = spawnSync("where", ["wmic"], {
|
||||||
|
timeout: 3000,
|
||||||
|
stdio: "ignore",
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
return result.status === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPowershellAvailable(): boolean {
|
||||||
|
if (!isWindows()) return false;
|
||||||
|
const result = spawnSync("where", ["powershell"], {
|
||||||
|
timeout: 3000,
|
||||||
|
stdio: "ignore",
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
return result.status === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 进程执行
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行命令并返回 stdout(去除首尾空白)。
|
||||||
|
* 对应 Python: _run_process(args, timeout=5.0) -> str
|
||||||
|
*/
|
||||||
|
function runProcess(args: string[], timeoutSec: number = 5.0): string {
|
||||||
|
const command = args[0];
|
||||||
|
const cmdArgs = args.slice(1);
|
||||||
|
try {
|
||||||
|
const result = spawnSync(command, cmdArgs, {
|
||||||
|
timeout: Math.round(timeoutSec * 1000),
|
||||||
|
encoding: "utf-8",
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
windowsHide: true, // 对应 Python STARTF_USESHOWWINDOW
|
||||||
|
});
|
||||||
|
if (result.status !== 0) return "";
|
||||||
|
return (result.stdout ?? "").trim();
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对应 Python: _run_powershell(command) -> str */
|
||||||
|
function runPowershell(command: string): string {
|
||||||
|
if (!isPowershellAvailable()) return "";
|
||||||
|
const args = ["powershell", "-NoProfile", "-NonInteractive", "-Command", command];
|
||||||
|
return runProcess(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对应 Python: _run_wmic(args) -> str */
|
||||||
|
function runWmic(args: readonly string[]): string {
|
||||||
|
if (!isWmicAvailable()) return "";
|
||||||
|
return runProcess([...args]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 执行 Mac 命令并返回 stdout(去除首尾空白) */
|
||||||
|
function runMacCommand(args: string[], timeoutSec: number = 5.0): string {
|
||||||
|
if (!isMacOS()) return "";
|
||||||
|
return runProcess(args, timeoutSec);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mac HDD 标识采集 — 三级回退保证覆盖率
|
||||||
|
* ① NVMe 磁盘序列号(Apple Silicon / 新款 Intel)
|
||||||
|
* ② SATA 磁盘序列号(老款 Intel Mac)
|
||||||
|
* ③ 系统序列号(最终兜底,仍唯一)
|
||||||
|
*/
|
||||||
|
function getMacHddValue(): string {
|
||||||
|
// ① 尝试 NVMe
|
||||||
|
let output = runMacCommand(['system_profiler', 'SPNVMeDataType']);
|
||||||
|
let m = output.match(/Serial Number:\s*(.+)/i);
|
||||||
|
if (m?.[1]?.trim()) return m[1].trim();
|
||||||
|
|
||||||
|
// ② 回退:SATA
|
||||||
|
output = runMacCommand(['system_profiler', 'SPSerialATADataType']);
|
||||||
|
m = output.match(/Serial Number:\s*(.+)/i);
|
||||||
|
if (m?.[1]?.trim()) return m[1].trim();
|
||||||
|
|
||||||
|
// ③ 最终兜底:系统序列号
|
||||||
|
output = runMacCommand(['system_profiler', 'SPHardwareDataType']);
|
||||||
|
m = output.match(/Serial Number[^:]*:\s*(.+)/i);
|
||||||
|
return m?.[1]?.trim() ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// DeviceFingerprint 类
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export class DeviceFingerprint {
|
||||||
|
// private wmicAvailableCache: boolean | null = null;
|
||||||
|
// private powershellAvailableCache: boolean | null = null;
|
||||||
|
|
||||||
|
// ---------- 组件 ID 查询 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询单个硬件组件 ID。
|
||||||
|
* 对应 Python: get_component_id(component, method="auto") -> str
|
||||||
|
*/
|
||||||
|
getComponentId(component: Component, method: QueryMethod = "auto"): string {
|
||||||
|
// Mac 路径:ioreg / sysctl / system_profiler
|
||||||
|
if (isMacOS()) {
|
||||||
|
if (component === 'hdd') {
|
||||||
|
return normalizeIdentifier(getMacHddValue());
|
||||||
|
}
|
||||||
|
const macQuery = MAC_COMPONENT_QUERIES[component];
|
||||||
|
if (!macQuery) return '';
|
||||||
|
const output = runMacCommand(macQuery.command);
|
||||||
|
return normalizeIdentifier(macQuery.extract(output));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows 路径:wmic / powershell
|
||||||
|
const query = COMPONENT_QUERIES[component];
|
||||||
|
if (!query) return "";
|
||||||
|
|
||||||
|
const resolvedMethod = resolveMethod(method);
|
||||||
|
|
||||||
|
const readViaPowershell = (): string => {
|
||||||
|
const rawOutput = runPowershell(query.powershell);
|
||||||
|
return firstValueFromOutput(rawOutput, query.headerTokens);
|
||||||
|
};
|
||||||
|
|
||||||
|
const readViaWmic = (): string => {
|
||||||
|
const rawOutput = runWmic(query.wmicArgs);
|
||||||
|
return firstValueFromOutput(rawOutput, query.headerTokens);
|
||||||
|
};
|
||||||
|
|
||||||
|
let rawValue = "";
|
||||||
|
if (resolvedMethod === "powershell") {
|
||||||
|
rawValue = readViaPowershell();
|
||||||
|
} else if (resolvedMethod === "wmic") {
|
||||||
|
rawValue = readViaWmic();
|
||||||
|
if (!rawValue) {
|
||||||
|
rawValue = readViaPowershell();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// auto: wmic 优先,回退 powershell
|
||||||
|
rawValue = readViaWmic() || readViaPowershell();
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeIdentifier(rawValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询单个硬件组件(别名)。
|
||||||
|
* 对应 Python: get_component(component_type, method="auto") -> str
|
||||||
|
*/
|
||||||
|
getComponent(component: Component, method: QueryMethod = "auto"): string {
|
||||||
|
return this.getComponentId(component, method);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 机器指纹 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成设备指纹 SHA256。
|
||||||
|
* 采集 UUID + HDD + CPU,拼接后做 SHA256 哈希。
|
||||||
|
* 对应 Python: fingerprint_sha256(method="auto") -> str | None
|
||||||
|
*/
|
||||||
|
fingerprintSha256(method: QueryMethod = "auto"): string | null {
|
||||||
|
// Mac 路径:ioreg / sysctl / system_profiler
|
||||||
|
if (isMacOS()) {
|
||||||
|
const uuidId = this.getComponentId("uuid", method);
|
||||||
|
const diskId = this.getComponentId("hdd", method);
|
||||||
|
const cpuId = this.getComponentId("cpu", method);
|
||||||
|
|
||||||
|
if (!uuidId && !diskId && !cpuId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const combined = `${uuidId}|${diskId}|${cpuId}`;
|
||||||
|
const normalized = normalizeIdentifier(combined);
|
||||||
|
return sha256HexUpper(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows 路径:wmic / powershell
|
||||||
|
const resolvedMethod = resolveMethod(method);
|
||||||
|
const wmicOk = isWmicAvailable();
|
||||||
|
const psOk = isPowershellAvailable();
|
||||||
|
|
||||||
|
if (resolvedMethod === "powershell") {
|
||||||
|
if (!psOk) return null;
|
||||||
|
} else {
|
||||||
|
if (!wmicOk && !psOk) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uuidId = this.getComponentId("uuid", method);
|
||||||
|
const diskId = this.getComponentId("hdd", method);
|
||||||
|
const cpuId = this.getComponentId("cpu", method);
|
||||||
|
|
||||||
|
if (!uuidId && !diskId && !cpuId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const combined = `${uuidId}|${diskId}|${cpuId}`;
|
||||||
|
const normalized = normalizeIdentifier(combined);
|
||||||
|
return sha256HexUpper(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取机器码(指纹的别名)。
|
||||||
|
* 对应 Python: get_machine_code(method="auto") -> str | None
|
||||||
|
*/
|
||||||
|
getMachineCode(method: QueryMethod = "auto"): string | null {
|
||||||
|
return this.fingerprintSha256(method);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 便捷函数(无需实例化)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const defaultFingerprint = new DeviceFingerprint();
|
||||||
|
|
||||||
|
/** 获取机器码(单例便捷调用) */
|
||||||
|
export function getMachineCode(method: QueryMethod = "auto"): string | null {
|
||||||
|
return defaultFingerprint.getMachineCode(method);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取设备指纹 SHA256(单例便捷调用) */
|
||||||
|
export function fingerprintSha256(method: QueryMethod = "auto"): string | null {
|
||||||
|
return defaultFingerprint.fingerprintSha256(method);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询单个硬件组件 ID(单例便捷调用) */
|
||||||
|
export function getComponentId(component: Component, method: QueryMethod = "auto"): string {
|
||||||
|
return defaultFingerprint.getComponentId(component, method);
|
||||||
|
}
|
||||||
200
electron/preload/device_service.ts
Normal file
200
electron/preload/device_service.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* 设备信息服务 — TypeScript 实现
|
||||||
|
*
|
||||||
|
* 与 device_service.py 逻辑一致,保留原 Python 方法不变。
|
||||||
|
* 提供带缓存的机器码、用户 IP、MAC 地址查询。
|
||||||
|
*
|
||||||
|
* 适用环境:Node.js / Electron
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as dgram from "node:dgram";
|
||||||
|
import { DeviceFingerprint } from "./device";
|
||||||
|
import type { QueryMethod } from "./device";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 工具函数
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** 对应 Python: _is_valid_ipv4(value) -> bool */
|
||||||
|
function isValidIPv4(value: string): boolean {
|
||||||
|
const parts = value.split(".");
|
||||||
|
if (parts.length !== 4) return false;
|
||||||
|
for (const part of parts) {
|
||||||
|
const num = parseInt(part, 10);
|
||||||
|
if (isNaN(num) || num < 0 || num > 255) return false;
|
||||||
|
if (part.length > 1 && part.startsWith("0")) return false;
|
||||||
|
}
|
||||||
|
// 排除环回地址和 0.0.0.0
|
||||||
|
if (value.startsWith("127.") || value === "0.0.0.0") return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对应 Python: _resolve_user_ip() -> Optional[str] */
|
||||||
|
function resolveUserIp(): string | null {
|
||||||
|
// 优先通过 UDP "伪连接" 获取本机出站 IP
|
||||||
|
try {
|
||||||
|
const sock = dgram.createSocket("udp4");
|
||||||
|
// 同步方式不可行;使用 UDP connect + getsockname 的异步模式
|
||||||
|
// 但在 Node.js 中这本质是同步的 (connect 不发送数据)
|
||||||
|
sock.connect(80, "8.8.8.8");
|
||||||
|
const address = sock.address();
|
||||||
|
sock.close();
|
||||||
|
if (address && typeof address.address === "string") {
|
||||||
|
const ip = address.address;
|
||||||
|
if (isValidIPv4(ip)) return ip;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退: 遍历网络接口查找第一个有效的非环回 IPv4
|
||||||
|
const interfaces = os.networkInterfaces();
|
||||||
|
for (const ifaceList of Object.values(interfaces)) {
|
||||||
|
if (!ifaceList) continue;
|
||||||
|
for (const iface of ifaceList) {
|
||||||
|
if (iface.family === "IPv4") {
|
||||||
|
const ip = iface.address;
|
||||||
|
if (isValidIPv4(ip)) return ip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对应 Python: _resolve_user_mac_address() -> Optional[str] */
|
||||||
|
function resolveUserMacAddress(): string | null {
|
||||||
|
const interfaces = os.networkInterfaces();
|
||||||
|
for (const ifaceList of Object.values(interfaces)) {
|
||||||
|
if (!ifaceList) continue;
|
||||||
|
for (const iface of ifaceList) {
|
||||||
|
// 跳过环回接口
|
||||||
|
if (iface.internal) continue;
|
||||||
|
if (iface.mac && iface.mac !== "00:00:00:00:00:00") {
|
||||||
|
// 对应 Python: 检查多播位
|
||||||
|
const parts = iface.mac.split(":");
|
||||||
|
if (parts.length === 6) {
|
||||||
|
const firstByte = parseInt(parts[0], 16);
|
||||||
|
// uuid.getnode 的多播位检查: (node >> 40) & 1
|
||||||
|
if ((firstByte & 1) === 0) {
|
||||||
|
return iface.mac.toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 未解析标记
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const UNRESOLVED = Symbol("unresolved");
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// DeviceService 类
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export class DeviceService {
|
||||||
|
private fingerprint = new DeviceFingerprint();
|
||||||
|
|
||||||
|
private cachedMachineCode: string | null | typeof UNRESOLVED = UNRESOLVED;
|
||||||
|
private cachedUserIp: string | null | typeof UNRESOLVED = UNRESOLVED;
|
||||||
|
private cachedUserMacAddress: string | null | typeof UNRESOLVED = UNRESOLVED;
|
||||||
|
|
||||||
|
private warmupStarted = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取机器码(带缓存)。
|
||||||
|
* 对应 Python: get_machine_code() -> Optional[str]
|
||||||
|
*/
|
||||||
|
getMachineCode(method: QueryMethod = "auto"): string | null {
|
||||||
|
if (this.cachedMachineCode !== UNRESOLVED) {
|
||||||
|
return this.cachedMachineCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const machineCode = this.fingerprint.getMachineCode(method);
|
||||||
|
if (this.cachedMachineCode === UNRESOLVED) {
|
||||||
|
this.cachedMachineCode = machineCode;
|
||||||
|
}
|
||||||
|
return machineCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户本机 IP(带缓存)。
|
||||||
|
* 对应 Python: get_user_ip() -> Optional[str]
|
||||||
|
*/
|
||||||
|
getUserIp(): string | null {
|
||||||
|
if (this.cachedUserIp !== UNRESOLVED) {
|
||||||
|
return this.cachedUserIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = resolveUserIp();
|
||||||
|
if (this.cachedUserIp === UNRESOLVED && value) {
|
||||||
|
this.cachedUserIp = value;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户 MAC 地址(带缓存)。
|
||||||
|
* 对应 Python: get_user_mac_address() -> Optional[str]
|
||||||
|
*/
|
||||||
|
getUserMacAddress(): string | null {
|
||||||
|
if (this.cachedUserMacAddress !== UNRESOLVED) {
|
||||||
|
return this.cachedUserMacAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = resolveUserMacAddress();
|
||||||
|
if (this.cachedUserMacAddress === UNRESOLVED) {
|
||||||
|
this.cachedUserMacAddress = value;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步预热机器码(后台线程)。
|
||||||
|
* 对应 Python: warm_up_machine_code() -> None
|
||||||
|
*/
|
||||||
|
warmUpMachineCode(): void {
|
||||||
|
if (this.cachedMachineCode !== UNRESOLVED) return;
|
||||||
|
if (this.warmupStarted) return;
|
||||||
|
this.warmupStarted = true;
|
||||||
|
|
||||||
|
// 使用 setImmediate 模拟后台线程,不阻塞当前事件循环
|
||||||
|
setImmediate(() => {
|
||||||
|
const machineCode = this.fingerprint.getMachineCode();
|
||||||
|
if (this.cachedMachineCode === UNRESOLVED) {
|
||||||
|
this.cachedMachineCode = machineCode;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 便捷函数(单例)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const defaultService = new DeviceService();
|
||||||
|
|
||||||
|
/** 获取机器码(单例,带缓存) */
|
||||||
|
export function getMachineCode(method: QueryMethod = "auto"): string | null {
|
||||||
|
return defaultService.getMachineCode(method);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取用户本机 IP(单例,带缓存) */
|
||||||
|
export function getUserIp(): string | null {
|
||||||
|
return defaultService.getUserIp();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取用户 MAC 地址(单例,带缓存) */
|
||||||
|
export function getUserMacAddress(): string | null {
|
||||||
|
return defaultService.getUserMacAddress();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 异步预热机器码 */
|
||||||
|
export function warmUpMachineCode(): void {
|
||||||
|
defaultService.warmUpMachineCode();
|
||||||
|
}
|
||||||
18
index.html
18
index.html
@@ -1,21 +1,21 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<!-- 使用项目 Logo 作为 favicon(Windows 可用 .ico) -->
|
<!-- 使用项目 Logo 作为 favicon(Windows 可用 .ico) -->
|
||||||
<link rel="icon" type="image/png" href="/src/assets/logo/HX.png" />
|
<link rel="icon" type="image/png" href="/src/assets/logo/HX.png" />
|
||||||
<!-- <meta name="viewport" content="width=device-width, initial-scale=1.0" />-->
|
<!-- <meta name="viewport" content="width=device-width, initial-scale=1.0" />-->
|
||||||
<meta http-equiv="Content-Security-Policy" content="
|
<meta http-equiv="Content-Security-Policy" content="
|
||||||
default-src 'self';
|
default-src 'self';
|
||||||
script-src 'self' 'unsafe-inline';
|
script-src 'self' 'unsafe-inline';
|
||||||
style-src 'self' 'unsafe-inline';
|
style-src 'self' 'unsafe-inline';
|
||||||
img-src 'self' data:;
|
img-src 'self' data: https://rh-images-1252422369.cos.ap-beijing.myqcloud.com https://www.heixiu.net ; ;
|
||||||
connect-src 'self' http://8.160.179.64:8000 http://localhost:5173 ws://localhost:5173 https://www.heixiu.net;
|
connect-src 'self' http://8.160.179.64:8000 http://localhost:5173 ws://localhost:5173 https://www.heixiu.net ;
|
||||||
">
|
">
|
||||||
<title>船长·HeiXiu</title>
|
<title>船长·HeiXiu</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
74
package-lock.json
generated
74
package-lock.json
generated
@@ -1,19 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "ele-heixiu",
|
"name": "ele-heixiu",
|
||||||
"version": "0.0.1",
|
"version": "0.0.13",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ele-heixiu",
|
"name": "ele-heixiu",
|
||||||
"version": "0.0.1",
|
"version": "0.0.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@react-buddy/ide-toolbox": "^2.5.0",
|
||||||
"antd": "^6.4.3",
|
"antd": "^6.4.3",
|
||||||
"axios": "^1.16.1",
|
"axios": "^1.16.1",
|
||||||
"electron-updater": "^6.8.3",
|
"electron-updater": "^6.8.3",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7"
|
||||||
"react-router-dom": "^7.16.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.60.0",
|
"@playwright/test": "^1.60.0",
|
||||||
@@ -1949,6 +1949,15 @@
|
|||||||
"react-dom": ">=18.0.0"
|
"react-dom": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-buddy/ide-toolbox": {
|
||||||
|
"version": "2.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-buddy/ide-toolbox/-/ide-toolbox-2.5.0.tgz",
|
||||||
|
"integrity": "sha512-ev2BZUeCymJfKuV3M+rQzdVZYlAacid8GexD3TtP0S+UBCBbH6ZCdkbx2eJoh/X+CEWFctg+2ppxIoaK52A7Rw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
|
||||||
@@ -3932,19 +3941,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/cookie": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/core-util-is": {
|
"node_modules/core-util-is": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
|
||||||
@@ -7094,44 +7090,6 @@
|
|||||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
|
||||||
"version": "7.16.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.16.0.tgz",
|
|
||||||
"integrity": "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"cookie": "^1.0.1",
|
|
||||||
"set-cookie-parser": "^2.6.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=18",
|
|
||||||
"react-dom": ">=18"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"react-dom": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-router-dom": {
|
|
||||||
"version": "7.16.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.16.0.tgz",
|
|
||||||
"integrity": "sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"react-router": "7.16.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=18",
|
|
||||||
"react-dom": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/read-binary-file-arch": {
|
"node_modules/read-binary-file-arch": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
|
||||||
@@ -7531,12 +7489,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/set-cookie-parser": {
|
|
||||||
"version": "2.7.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
|
||||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ele-heixiu",
|
"name": "ele-heixiu",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.12",
|
"version": "0.0.18",
|
||||||
"description": "船长·HeiXiu — 桌面效率工作台",
|
"description": "船长·HeiXiu — 桌面效率工作台",
|
||||||
"author": "HeiXiu 杨烨",
|
"author": "HeiXiu 杨烨",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -26,12 +26,12 @@
|
|||||||
"test:e2e": "playwright test.json"
|
"test:e2e": "playwright test.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@react-buddy/ide-toolbox": "^2.5.0",
|
||||||
"antd": "^6.4.3",
|
"antd": "^6.4.3",
|
||||||
"axios": "^1.16.1",
|
"axios": "^1.16.1",
|
||||||
"electron-updater": "^6.8.3",
|
"electron-updater": "^6.8.3",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7"
|
||||||
"react-router-dom": "^7.16.0"
|
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.heixiu.electron",
|
"appId": "com.heixiu.electron",
|
||||||
|
|||||||
46
src/App.tsx
46
src/App.tsx
@@ -1,18 +1,25 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { HashRouter } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { useAppContext } from './contexts/app-context';
|
import { useAppContext } from '@/components/providers/AppProvider';
|
||||||
import { LoginPage } from './pages/login';
|
import { LoginPage } from './pages/auth';
|
||||||
|
import { SettingsPage } from './pages/settings';
|
||||||
import { on, off, EVENTS } from './utils/event-bus';
|
import { on, off, EVENTS } from './utils/event-bus';
|
||||||
import { AppRoutes } from './router';
|
import { Layout } from '@/components/shell/Layout';
|
||||||
|
import { HomePage } from '@/pages/home/HomePage';
|
||||||
|
import { PageDispatcher } from '@/components/shell/PageDispatcher';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根组件 — 主窗口
|
* 根组件 — 主窗口
|
||||||
*
|
*
|
||||||
* 层级:
|
* 层级:
|
||||||
* HashRouter
|
* Layout(Banner + NavBar)
|
||||||
* ├── LoginPage(Modal 弹层,事件总线驱动)
|
* └── HomePage(主页面,始终挂载)
|
||||||
* ├── AppRoutes(页面路由 + SettingsPage Drawer 叠加)
|
* LoginPage(Modal 弹层,事件总线驱动)
|
||||||
|
* SettingsPage(Modal 居中叠加,事件总线驱动 — 对应 QT 原版设置窗口)
|
||||||
|
*
|
||||||
|
* 设计原则(对应 QT 原版多窗口模型):
|
||||||
|
* - 叠加层(设置、公告、页面等)→ Modal/Drawer + 事件总线,主页面不卸载
|
||||||
|
* - 不再使用 React Router,所有导航由事件总线驱动
|
||||||
*/
|
*/
|
||||||
function App() {
|
function App() {
|
||||||
const { isLoggedIn } = useAppContext();
|
const { isLoggedIn } = useAppContext();
|
||||||
@@ -42,14 +49,31 @@ function App() {
|
|||||||
setLoginOpen(false);
|
setLoginOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 设置 Drawer 状态
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
|
||||||
|
// 监听打开设置 Drawer
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setSettingsOpen(true);
|
||||||
|
on(EVENTS.OPEN_SETTINGS, handler);
|
||||||
|
return () => { off(EVENTS.OPEN_SETTINGS, handler); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HashRouter>
|
<>
|
||||||
|
<Layout>
|
||||||
|
<HomePage />
|
||||||
|
</Layout>
|
||||||
|
|
||||||
{/* 登录 Modal — 可关闭,关闭后主页可操作 */}
|
{/* 登录 Modal — 可关闭,关闭后主页可操作 */}
|
||||||
<LoginPage open={loginOpen && !isLoggedIn} onClose={handleLoginClose} />
|
<LoginPage open={loginOpen && !isLoggedIn} onClose={handleLoginClose} />
|
||||||
|
|
||||||
{/* 页面路由 + SettingsPage Drawer 叠加 */}
|
{/* 设置 Modal — 事件驱动,居中叠加,主页面始终挂载(对应 QT 原版独立设置窗口) */}
|
||||||
<AppRoutes />
|
<SettingsPage open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||||
</HashRouter>
|
|
||||||
|
{/* 页面调度器 — 监听 OPEN_PAGE 事件,根据 modalType 渲染 Modal/Drawer/FloatingPanel */}
|
||||||
|
<PageDispatcher />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// Layout — 页面布局壳(NavBar + 页面内容 Outlet)
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { Outlet } from 'react-router-dom';
|
|
||||||
import { theme as antTheme } from 'antd';
|
|
||||||
import { NavBar } from './navbar';
|
|
||||||
|
|
||||||
export function Layout() {
|
|
||||||
const { token } = antTheme.useToken();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="min-h-screen flex flex-col transition-colors duration-300"
|
|
||||||
style={{ background: token.colorBgLayout }}
|
|
||||||
>
|
|
||||||
<NavBar />
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// SettingsProvider — 设置状态 Provider 组件
|
|
||||||
//
|
|
||||||
// 职责:
|
|
||||||
// - 初始化时从 localStorage 读取已存储的设置项
|
|
||||||
// - 提供 SettingsContext 给所有子组件
|
|
||||||
// - 写操作同步更新 state + localStorage
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { useState, useCallback, type ReactNode } from 'react';
|
|
||||||
import {
|
|
||||||
SettingsContext,
|
|
||||||
readStoredOutputPath,
|
|
||||||
writeStoredOutputPath,
|
|
||||||
clearStoredOutputPath,
|
|
||||||
readStoredTeamRepoPath,
|
|
||||||
writeStoredTeamRepoPath,
|
|
||||||
clearStoredTeamRepoPath,
|
|
||||||
} from '@/contexts/settings-context';
|
|
||||||
import type { SettingsContextValue } from '@/contexts/settings-context';
|
|
||||||
|
|
||||||
interface SettingsProviderProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsProvider({ children }: SettingsProviderProps) {
|
|
||||||
const [outputPath, setOutputPathState] = useState<string>(readStoredOutputPath);
|
|
||||||
const [teamRepoPath, setTeamRepoPathState] = useState<string>(readStoredTeamRepoPath);
|
|
||||||
|
|
||||||
// ---------- outputPath ----------
|
|
||||||
|
|
||||||
const setOutputPath = useCallback((path: string) => {
|
|
||||||
setOutputPathState(path);
|
|
||||||
writeStoredOutputPath(path);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearOutputPath = useCallback(() => {
|
|
||||||
setOutputPathState('');
|
|
||||||
clearStoredOutputPath();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ---------- teamRepoPath ----------
|
|
||||||
|
|
||||||
const setTeamRepoPath = useCallback((path: string) => {
|
|
||||||
setTeamRepoPathState(path);
|
|
||||||
writeStoredTeamRepoPath(path);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearTeamRepoPath = useCallback(() => {
|
|
||||||
setTeamRepoPathState('');
|
|
||||||
clearStoredTeamRepoPath();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const value: SettingsContextValue = {
|
|
||||||
outputPath,
|
|
||||||
teamRepoPath,
|
|
||||||
setOutputPath,
|
|
||||||
setTeamRepoPath,
|
|
||||||
clearOutputPath,
|
|
||||||
clearTeamRepoPath,
|
|
||||||
};
|
|
||||||
|
|
||||||
return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// ThemeProvider — 主题 Provider 组件
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, type ReactNode } from 'react';
|
|
||||||
import { ConfigProvider } from 'antd';
|
|
||||||
|
|
||||||
import { getThemeConfig } from '../theme/antd-theme';
|
|
||||||
import {
|
|
||||||
ThemeContext,
|
|
||||||
readStoredTheme,
|
|
||||||
writeStoredTheme,
|
|
||||||
applyHtmlTheme,
|
|
||||||
STORAGE_KEY,
|
|
||||||
} from '../hooks/use-theme';
|
|
||||||
import type { ThemeContextValue } from '../hooks/use-theme';
|
|
||||||
|
|
||||||
interface ThemeProviderProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ThemeProvider({ children }: ThemeProviderProps) {
|
|
||||||
const [isDark, setIsDark] = useState<boolean>(readStoredTheme);
|
|
||||||
|
|
||||||
// 挂载时同步
|
|
||||||
useEffect(() => {
|
|
||||||
applyHtmlTheme(isDark);
|
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
const toggleTheme = useCallback(() => {
|
|
||||||
setIsDark((prev) => {
|
|
||||||
const next = !prev;
|
|
||||||
writeStoredTheme(next);
|
|
||||||
applyHtmlTheme(next);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setLight = useCallback(() => {
|
|
||||||
setIsDark(false);
|
|
||||||
writeStoredTheme(false);
|
|
||||||
applyHtmlTheme(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setDark = useCallback(() => {
|
|
||||||
setIsDark(true);
|
|
||||||
writeStoredTheme(true);
|
|
||||||
applyHtmlTheme(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 监听系统主题变化(仅用户从未手动切换时跟随)
|
|
||||||
useEffect(() => {
|
|
||||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
|
||||||
const handleChange = (e: MediaQueryListEvent) => {
|
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (stored === null) {
|
|
||||||
setIsDark(e.matches);
|
|
||||||
applyHtmlTheme(e.matches);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
mq.addEventListener('change', handleChange);
|
|
||||||
return () => mq.removeEventListener('change', handleChange);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const ctx: ThemeContextValue = { isDark, toggleTheme, setLight, setDark };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemeContext.Provider value={ctx}>
|
|
||||||
<ConfigProvider theme={getThemeConfig(isDark)}>{children}</ConfigProvider>
|
|
||||||
</ThemeContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// AnnouncementDrawer — 公告侧拉抽屉(SPA 内部弹出)
|
|
||||||
// 数据来源: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 { fetchAnnouncements, type Announcement } from '@/services/modules';
|
|
||||||
|
|
||||||
const { Text, Paragraph } = Typography;
|
|
||||||
|
|
||||||
// ---------- 辅助 ----------
|
|
||||||
|
|
||||||
/** 公告类型 → Tag 颜色映射 */
|
|
||||||
const typeColorMap: Record<string, string> = {
|
|
||||||
system: 'blue',
|
|
||||||
feature: 'purple',
|
|
||||||
activity: 'orange',
|
|
||||||
maintenance: 'red',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 公告类型 → 中文标签 */
|
|
||||||
const typeLabelMap: Record<string, string> = {
|
|
||||||
system: '系统',
|
|
||||||
feature: '功能',
|
|
||||||
activity: '活动',
|
|
||||||
maintenance: '维护',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 格式化日期(YYYY-MM-DD 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 组件 ----------
|
|
||||||
|
|
||||||
interface AnnouncementDrawerProps {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AnnouncementDrawer({ open, onClose }: AnnouncementDrawerProps) {
|
|
||||||
const [list, setList] = useState<Announcement[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// 详情 Modal
|
|
||||||
const [detail, setDetail] = useState<Announcement | null>(null);
|
|
||||||
|
|
||||||
// 打开抽屉时请求公告列表
|
|
||||||
const loadAnnouncements = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const data = await fetchAnnouncements();
|
|
||||||
// 仅展示有效公告
|
|
||||||
setList(data.filter((item) => item.is_active));
|
|
||||||
} catch (err) {
|
|
||||||
setError((err as Error).message || '加载公告失败');
|
|
||||||
setList([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
loadAnnouncements();
|
|
||||||
}
|
|
||||||
}, [open, loadAnnouncements]);
|
|
||||||
|
|
||||||
// ---- 渲染 ----
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Drawer
|
|
||||||
title={
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<NotificationOutlined />
|
|
||||||
公告
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
placement="right"
|
|
||||||
size={380}
|
|
||||||
open={open}
|
|
||||||
onClose={onClose}
|
|
||||||
styles={{ body: { padding: 0 } }}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<Spin description="加载中..." />
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<Empty description={error} />
|
|
||||||
</div>
|
|
||||||
) : list.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<Empty description="暂无公告" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-3 p-3">
|
|
||||||
{list.map((item) => (
|
|
||||||
<Tooltip
|
|
||||||
key={item.id}
|
|
||||||
title={
|
|
||||||
item.content.length > 100
|
|
||||||
? item.content.slice(0, 200) + '…'
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
placement="left"
|
|
||||||
>
|
|
||||||
<Card
|
|
||||||
size="small"
|
|
||||||
className="rounded-md! border-0! shadow-sm! cursor-pointer hover:shadow-md! transition-shadow"
|
|
||||||
title={
|
|
||||||
<Text strong className="text-sm" ellipsis>
|
|
||||||
{item.pinned && '📌 '}
|
|
||||||
{item.title}
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
extra={
|
|
||||||
<Tag
|
|
||||||
color={typeColorMap[item.type] || 'default'}
|
|
||||||
className="m-0! text-xs"
|
|
||||||
>
|
|
||||||
{typeLabelMap[item.type] || item.type}
|
|
||||||
</Tag>
|
|
||||||
}
|
|
||||||
onClick={() => setDetail(item)}
|
|
||||||
>
|
|
||||||
<Paragraph
|
|
||||||
className="mb-2! text-xs"
|
|
||||||
type="secondary"
|
|
||||||
ellipsis={{ rows: 2 }}
|
|
||||||
>
|
|
||||||
{item.content}
|
|
||||||
</Paragraph>
|
|
||||||
<Text type="secondary" className="text-xs">
|
|
||||||
{formatDate(item.created_at)}
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Drawer>
|
|
||||||
|
|
||||||
{/* ======== 公告详情 Modal ======== */}
|
|
||||||
<Modal
|
|
||||||
open={!!detail}
|
|
||||||
onCancel={() => setDetail(null)}
|
|
||||||
footer={null}
|
|
||||||
width={480}
|
|
||||||
centered
|
|
||||||
title={
|
|
||||||
detail && (
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
{detail.pinned && '📌 '}
|
|
||||||
{detail.title}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{detail && (
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Tag
|
|
||||||
color={typeColorMap[detail.type] || 'default'}
|
|
||||||
className="m-0! text-xs"
|
|
||||||
>
|
|
||||||
{typeLabelMap[detail.type] || detail.type}
|
|
||||||
</Tag>
|
|
||||||
<Text type="secondary" className="text-xs">
|
|
||||||
{formatDate(detail.created_at)}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<div className="px-1 py-2 rounded-md"
|
|
||||||
style={{
|
|
||||||
background: 'var(--color-fill-tertiary, rgba(0,0,0,0.04))',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Paragraph className="mb-0! text-sm leading-relaxed whitespace-pre-wrap">
|
|
||||||
{detail.content}
|
|
||||||
</Paragraph>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// NavBar — 自定义顶部导航栏(替代系统菜单)
|
|
||||||
//
|
|
||||||
// 逻辑:
|
|
||||||
// - 所有导航项默认全部显示(不再按登录状态隐藏)
|
|
||||||
// - 未登录时点击需登录的项 → 弹出提示"请先登录"并触发登录 Modal
|
|
||||||
// - 登录/注册 与 退出登录 仍按登录状态互斥显示
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
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';
|
|
||||||
|
|
||||||
const NAVBAR_HEIGHT = 56;
|
|
||||||
|
|
||||||
export function NavBar() {
|
|
||||||
const { platform, edition, isLoggedIn, logout } = useAppContext();
|
|
||||||
const { isDark } = useTheme();
|
|
||||||
const { message } = App.useApp();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
||||||
|
|
||||||
// 根据版本选择导航项
|
|
||||||
const { leftItems, rightItems } = useMemo(() => {
|
|
||||||
const isEnterprise = edition === 'enterprise';
|
|
||||||
return {
|
|
||||||
leftItems: isEnterprise ? enterpriseLeft : personalLeft,
|
|
||||||
rightItems: isEnterprise ? enterpriseRight : personalRight,
|
|
||||||
};
|
|
||||||
}, [edition]);
|
|
||||||
|
|
||||||
// 过滤规则:
|
|
||||||
// requireGuest → 仅未登录时可见(登录/注册)
|
|
||||||
// "退出登录" → 仅已登录时可见
|
|
||||||
// 其他所有项 → 始终可见,未登录点击时弹"请先登录"
|
|
||||||
const visibleLeft = useMemo(
|
|
||||||
() =>
|
|
||||||
leftItems.filter((item) => {
|
|
||||||
if (item.requireGuest && isLoggedIn) return false;
|
|
||||||
return !(item.key === 'logout' && !isLoggedIn);
|
|
||||||
}),
|
|
||||||
[leftItems, isLoggedIn],
|
|
||||||
);
|
|
||||||
const visibleRight = useMemo(
|
|
||||||
() =>
|
|
||||||
rightItems.filter((item) => {
|
|
||||||
if (item.requireGuest && isLoggedIn) return false;
|
|
||||||
return !(item.key === 'logout' && !isLoggedIn);
|
|
||||||
}),
|
|
||||||
[rightItems, isLoggedIn],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 点击导航项
|
|
||||||
const handleNavClick = useCallback(
|
|
||||||
(item: NavItemConfig) => {
|
|
||||||
// 需要登录但未登录 → 提示并弹出登录 Modal
|
|
||||||
if (item.requireLogin && !isLoggedIn) {
|
|
||||||
message.warning('请先登录');
|
|
||||||
emit(EVENTS.SHOW_LOGIN);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (item.action) {
|
|
||||||
case 'drawer':
|
|
||||||
if (item.key === 'announcements') {
|
|
||||||
setDrawerOpen(true);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'button':
|
|
||||||
if (item.key === 'logout') {
|
|
||||||
logout();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'external':
|
|
||||||
if (item.target) {
|
|
||||||
window.open(item.target, '_blank');
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'navigate':
|
|
||||||
case 'spa':
|
|
||||||
if (item.key === 'auth') {
|
|
||||||
emit(EVENTS.SHOW_LOGIN);
|
|
||||||
} else if (item.target) {
|
|
||||||
navigate(item.target);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[isLoggedIn, logout, message],
|
|
||||||
);
|
|
||||||
|
|
||||||
const macPaddingLeft = platform === 'darwin' ? 80 : 12;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="navbar-drag-region flex items-center justify-between select-none"
|
|
||||||
style={{
|
|
||||||
height: NAVBAR_HEIGHT,
|
|
||||||
paddingLeft: macPaddingLeft,
|
|
||||||
paddingRight: 12,
|
|
||||||
background: isDark
|
|
||||||
? 'linear-gradient(180deg, #1E1B4B 0%, #1A1730 100%)'
|
|
||||||
: 'linear-gradient(180deg, #FFFFFF 0%, #F5F3FF 100%)',
|
|
||||||
borderBottom: `1px solid ${isDark ? '#2E2A4A' : '#E5E7EB'}`,
|
|
||||||
boxShadow: isDark ? '0 1px 4px rgba(0,0,0,0.3)' : '0 1px 4px rgba(99,102,241,0.06)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="navbar-no-drag flex items-center gap-0.5">
|
|
||||||
{visibleLeft.map((item) => (
|
|
||||||
<NavItem key={item.key} item={item} onClick={handleNavClick} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="navbar-no-drag flex items-center gap-0.5">
|
|
||||||
{visibleRight.map((item) => (
|
|
||||||
<NavItem key={item.key} item={item} onClick={handleNavClick} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnnouncementDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { NavBar } from './NavBar';
|
|
||||||
export { AnnouncementDrawer } from './AnnouncementDrawer';
|
|
||||||
export type { NavItemConfig, NavBarConfig, NavAction } from './types';
|
|
||||||
@@ -1,28 +1,65 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// AppProvider — 应用状态 Provider 组件
|
// AppProvider — 全局应用状态 Context + Provider + Hook
|
||||||
|
//
|
||||||
|
// 导出:
|
||||||
|
// AppContext, useAppContext() — Context + Consumer Hook
|
||||||
|
// AppContextValue — 类型
|
||||||
|
// AppProvider — Provider 组件
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, type ReactNode } from 'react';
|
import { createContext, useContext, useState, useMemo, useCallback, useEffect, type ReactNode } from 'react';
|
||||||
import type { Platform, Edition } from '@shared/types';
|
import type { Platform, Edition } from '@shared/types';
|
||||||
|
|
||||||
import { getPlatform, isDev } from '@/utils/platform';
|
import { getPlatform, isDev } from '@/utils/platform';
|
||||||
import { parseEdition } from '@shared/constants/app';
|
|
||||||
import { safeIpcOn, safeIpcOff } from '@/utils/ipc';
|
import { safeIpcOn, safeIpcOff } from '@/utils/ipc';
|
||||||
import { AppContext } from '@/contexts/app-context';
|
import { AuthAPI, type LoginResponseBody } from '@/services/modules/auth';
|
||||||
import type { AppContextValue } from '@/contexts/app-context';
|
|
||||||
import type { LoginResponseBody } from '@/services/modules';
|
|
||||||
import { setToken, clearToken, initTokenStore } from '@/services/request';
|
import { setToken, clearToken, initTokenStore } from '@/services/request';
|
||||||
import { emit, EVENTS } from '@/utils/event-bus';
|
import { emit, EVENTS } from '@/utils/event-bus';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/logger';
|
||||||
import { AuthAPI } from '@/services/modules';
|
import { getAutoLoginFlag, clearAutoLoginFlag } from '@/services/auth-persistence';
|
||||||
import { getAutoLoginFlag, clearAutoLoginFlag } from '@/pages/login/hooks/use-auth-state';
|
|
||||||
import {
|
import {
|
||||||
initAuthRefresh,
|
initAuthRefresh,
|
||||||
getStoredRefreshToken,
|
getStoredRefreshToken,
|
||||||
setStoredRefreshToken,
|
setStoredRefreshToken,
|
||||||
clearStoredRefreshToken,
|
clearStoredRefreshToken,
|
||||||
initRefreshTokenStore,
|
initRefreshTokenStore,
|
||||||
|
scheduleProactiveRefresh,
|
||||||
|
clearProactiveRefresh,
|
||||||
} from '@/services/auth-token';
|
} from '@/services/auth-token';
|
||||||
|
import {initModelCache} from '@/services/cache/model-cache';
|
||||||
|
|
||||||
|
// ---------- Context 类型 ----------
|
||||||
|
|
||||||
|
export interface AppContextValue {
|
||||||
|
/** 当前操作系统平台 */
|
||||||
|
platform: Platform;
|
||||||
|
/** 产品版本(个人版 / 企业版) */
|
||||||
|
edition: Edition;
|
||||||
|
/** 用户是否已登录 */
|
||||||
|
isLoggedIn: boolean;
|
||||||
|
/** 当前登录用户信息(未登录时为 null,结构与 LoginResponseBody 一致) */
|
||||||
|
user: LoginResponseBody | null;
|
||||||
|
/** 登录:保存 Token 并更新登录态(auth 为登录接口返回值,异步加密存储) */
|
||||||
|
login: (auth: LoginResponseBody) => Promise<void>;
|
||||||
|
/** 退出登录:清除 Token 和用户状态 */
|
||||||
|
logout: () => void;
|
||||||
|
/** 是否为开发环境 */
|
||||||
|
isDevMode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AppContext = createContext<AppContextValue | null>(null);
|
||||||
|
|
||||||
|
// ---------- Consumer Hook ----------
|
||||||
|
|
||||||
|
export function useAppContext(): AppContextValue {
|
||||||
|
const ctx = useContext(AppContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useAppContext() 必须在 <AppProvider> 内部调用');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Provider 组件 ----------
|
||||||
|
|
||||||
interface AppProviderProps {
|
interface AppProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -30,17 +67,32 @@ interface AppProviderProps {
|
|||||||
|
|
||||||
export function AppProvider({ children }: AppProviderProps) {
|
export function AppProvider({ children }: AppProviderProps) {
|
||||||
const [platform] = useState<Platform>(getPlatform);
|
const [platform] = useState<Platform>(getPlatform);
|
||||||
const [edition] = useState<Edition>(() => {
|
|
||||||
return parseEdition(import.meta.env.VITE_EDITION);
|
|
||||||
});
|
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
const [user, setUser] = useState<LoginResponseBody | null>(null);
|
const [user, setUser] = useState<LoginResponseBody | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* edition 从登录用户的 account_type 派生(运行时判断,无需构建变量)
|
||||||
|
*
|
||||||
|
* 映射关系:
|
||||||
|
* "individual" → personal ← 个人用户
|
||||||
|
* "company" | "employee" → enterprise ← 企业用户
|
||||||
|
* null(未登录) → personal ← 默认最小功能集
|
||||||
|
*
|
||||||
|
* 安全性:edition 是 useMemo 派生值,不可独立赋值;
|
||||||
|
* 不写入 localStorage,重启/登出后回退默认值,必须重新登录才能确权。
|
||||||
|
*/
|
||||||
|
const edition: Edition = useMemo(() => {
|
||||||
|
if (!user) return 'personal';
|
||||||
|
return user.account_type === 'individual' ? 'personal' : 'enterprise';
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
// ---------- 登录 / 退出 ----------
|
// ---------- 登录 / 退出 ----------
|
||||||
|
|
||||||
const login = useCallback(async (auth: LoginResponseBody) => {
|
const login = useCallback(async (auth: LoginResponseBody) => {
|
||||||
await setToken(auth.access_token);
|
await setToken(auth.access_token);
|
||||||
await setStoredRefreshToken(auth.refresh_token);
|
await setStoredRefreshToken(auth.refresh_token);
|
||||||
|
// 启动主动刷新定时器(在 access_token 过期前自动续期)
|
||||||
|
scheduleProactiveRefresh(auth.access_token);
|
||||||
setUser(auth);
|
setUser(auth);
|
||||||
setIsLoggedIn(true);
|
setIsLoggedIn(true);
|
||||||
logger.info('auth', 'User logged in', {
|
logger.info('auth', 'User logged in', {
|
||||||
@@ -54,16 +106,24 @@ export function AppProvider({ children }: AppProviderProps) {
|
|||||||
logger.info('auth', 'User logged out');
|
logger.info('auth', 'User logged out');
|
||||||
clearToken();
|
clearToken();
|
||||||
clearStoredRefreshToken();
|
clearStoredRefreshToken();
|
||||||
|
clearProactiveRefresh();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setIsLoggedIn(false);
|
setIsLoggedIn(false);
|
||||||
emit(EVENTS.LOGOUT);
|
emit(EVENTS.LOGOUT);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ---------- 启动时初始化 token 存储 + 自动登录 ----------
|
// ---------- 启动时初始化 token 存储 + 自动登录 ----------
|
||||||
|
//
|
||||||
|
// 注意:React StrictMode(开发模式)会挂载→卸载→重新挂载以暴露副作用问题。
|
||||||
|
// aborted 标志确保卸载后不再执行异步回调,避免 refreshToken / getUserInfo 重复请求。
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 从加密存储恢复 token 到内存缓存
|
let aborted = false;
|
||||||
Promise.all([initTokenStore(), initRefreshTokenStore()]).then(() => {
|
|
||||||
|
// 从加密存储恢复 token + 模型缓存 到内存缓存
|
||||||
|
Promise.all([initTokenStore(), initRefreshTokenStore(), initModelCache()]).then(() => {
|
||||||
|
if (aborted) return;
|
||||||
|
|
||||||
// 初始化 401 刷新回调(token 缓存就绪后才注册)
|
// 初始化 401 刷新回调(token 缓存就绪后才注册)
|
||||||
initAuthRefresh();
|
initAuthRefresh();
|
||||||
|
|
||||||
@@ -75,12 +135,15 @@ export function AppProvider({ children }: AppProviderProps) {
|
|||||||
|
|
||||||
AuthAPI.refreshToken({ refresh_token: refreshToken })
|
AuthAPI.refreshToken({ refresh_token: refreshToken })
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
|
if (aborted) return;
|
||||||
await setToken(res.access_token);
|
await setToken(res.access_token);
|
||||||
await setStoredRefreshToken(res.refresh_token);
|
await setStoredRefreshToken(res.refresh_token);
|
||||||
|
scheduleProactiveRefresh(res.access_token);
|
||||||
setIsLoggedIn(true);
|
setIsLoggedIn(true);
|
||||||
logger.info('auth', 'Auto-login succeeded');
|
logger.info('auth', 'Auto-login succeeded');
|
||||||
// 补全用户信息
|
// 补全用户信息
|
||||||
AuthAPI.getUserInfo().then((info) => {
|
AuthAPI.getUserInfo().then((info) => {
|
||||||
|
if (aborted) return;
|
||||||
const u: LoginResponseBody = {
|
const u: LoginResponseBody = {
|
||||||
access_token: res.access_token,
|
access_token: res.access_token,
|
||||||
refresh_token: res.refresh_token,
|
refresh_token: res.refresh_token,
|
||||||
@@ -90,27 +153,30 @@ export function AppProvider({ children }: AppProviderProps) {
|
|||||||
user_id: info.id,
|
user_id: info.id,
|
||||||
username: info.username,
|
username: info.username,
|
||||||
role: info.role,
|
role: info.role,
|
||||||
email: info.email,
|
email: info.email ?? '',
|
||||||
balance_cent: info.balance_cent,
|
balance_cent: info.balance_cent,
|
||||||
account_type: info.account_type,
|
account_type: info.account_type,
|
||||||
display_name: info.display_name,
|
display_name: info.display_name ?? '',
|
||||||
company_id: info.company_id,
|
company_id: info.company_id ?? '',
|
||||||
real_name: info.real_name,
|
real_name: info.real_name ?? '',
|
||||||
phone: info.phone,
|
phone: info.phone,
|
||||||
license_code: '',
|
license_code: '',
|
||||||
admin_permissions: info.admin_permissions,
|
admin_permissions: info.admin_permissions ?? [],
|
||||||
};
|
};
|
||||||
setUser(u);
|
setUser(u);
|
||||||
emit(EVENTS.LOGIN_SUCCESS, u);
|
emit(EVENTS.LOGIN_SUCCESS, u);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
if (aborted) return;
|
||||||
logger.warn('auth', 'Auto-login failed, refresh_token expired');
|
logger.warn('auth', 'Auto-login failed, refresh_token expired');
|
||||||
clearStoredRefreshToken();
|
clearStoredRefreshToken();
|
||||||
// 仅清除自动登录标记,保留用户名供下次表单回填
|
// 仅清除自动登录标记,保留用户名供下次表单回填
|
||||||
clearAutoLoginFlag();
|
clearAutoLoginFlag();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return () => { aborted = true; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ---------- 监听主进程推送的平台/版本信息 ----------
|
// ---------- 监听主进程推送的平台/版本信息 ----------
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// SettingsContext — 设置项集中状态管理(类似 Vue Pinia store)
|
// SettingsProvider — 设置项集中状态管理 Context + Provider + Hook
|
||||||
//
|
//
|
||||||
// 职责:
|
// 职责:
|
||||||
// - 存储路径的集中管理(read/write/clear)
|
// - 存储路径的集中管理(read/write/clear)
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
// - 全局组件通过 useSettings() 消费
|
// - 全局组件通过 useSettings() 消费
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { createContext, useContext } from 'react';
|
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||||
|
|
||||||
// ---------- localStorage 键 ----------
|
// ---------- localStorage 键 ----------
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ export interface SettingsContextValue {
|
|||||||
|
|
||||||
export const SettingsContext = createContext<SettingsContextValue | null>(null);
|
export const SettingsContext = createContext<SettingsContextValue | null>(null);
|
||||||
|
|
||||||
// ---------- 工具函数(localStorage 读写) ----------
|
// ---------- 工具函数(localStorage 读写)----------
|
||||||
|
|
||||||
export function readStoredOutputPath(): string {
|
export function readStoredOutputPath(): string {
|
||||||
try {
|
try {
|
||||||
@@ -92,3 +92,49 @@ export function useSettings(): SettingsContextValue {
|
|||||||
}
|
}
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Provider 组件 ----------
|
||||||
|
|
||||||
|
interface SettingsProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsProvider({ children }: SettingsProviderProps) {
|
||||||
|
const [outputPath, setOutputPathState] = useState<string>(readStoredOutputPath);
|
||||||
|
const [teamRepoPath, setTeamRepoPathState] = useState<string>(readStoredTeamRepoPath);
|
||||||
|
|
||||||
|
// ---------- outputPath ----------
|
||||||
|
|
||||||
|
const setOutputPath = useCallback((path: string) => {
|
||||||
|
setOutputPathState(path);
|
||||||
|
writeStoredOutputPath(path);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearOutputPath = useCallback(() => {
|
||||||
|
setOutputPathState('');
|
||||||
|
clearStoredOutputPath();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ---------- teamRepoPath ----------
|
||||||
|
|
||||||
|
const setTeamRepoPath = useCallback((path: string) => {
|
||||||
|
setTeamRepoPathState(path);
|
||||||
|
writeStoredTeamRepoPath(path);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearTeamRepoPath = useCallback(() => {
|
||||||
|
setTeamRepoPathState('');
|
||||||
|
clearStoredTeamRepoPath();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value: SettingsContextValue = {
|
||||||
|
outputPath,
|
||||||
|
teamRepoPath,
|
||||||
|
setOutputPath,
|
||||||
|
setTeamRepoPath,
|
||||||
|
clearOutputPath,
|
||||||
|
clearTeamRepoPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
|
||||||
|
}
|
||||||
161
src/components/providers/ThemeProvider.tsx
Normal file
161
src/components/providers/ThemeProvider.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ThemeProvider — 主题 Context + Provider + Hook + 工具函数
|
||||||
|
//
|
||||||
|
// 桌面端(Electron)使用 View Transitions API 驱动主题切换:
|
||||||
|
// GPU 合成器截取旧/新两帧做一次 cross-fade,替代 80+ 个
|
||||||
|
// 独立 CSS 过渡,消除 Electron Chromium 的合成器卡顿。
|
||||||
|
// 浏览器不支持时回退到 CSS transition 方案。
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useEffect, useLayoutEffect, useCallback, type ReactNode } from 'react';
|
||||||
|
import { flushSync } from 'react-dom';
|
||||||
|
import { ConfigProvider } from 'antd';
|
||||||
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
|
|
||||||
|
import { getThemeConfig } from '../../theme/antd-theme';
|
||||||
|
|
||||||
|
// ---------- Context 类型 ----------
|
||||||
|
|
||||||
|
export interface ThemeContextValue {
|
||||||
|
isDark: boolean;
|
||||||
|
toggleTheme: () => void;
|
||||||
|
setLight: () => void;
|
||||||
|
setDark: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||||
|
|
||||||
|
// ---------- localStorage 工具函数 ----------
|
||||||
|
|
||||||
|
export const STORAGE_KEY = 'ele-heixiu-theme';
|
||||||
|
|
||||||
|
export function readStoredTheme(): boolean {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored === 'dark') return true;
|
||||||
|
if (stored === 'light') return false;
|
||||||
|
} catch {
|
||||||
|
/* 静默忽略 */
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeStoredTheme(isDark: boolean): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, isDark ? 'dark' : 'light');
|
||||||
|
} catch {
|
||||||
|
/* 静默忽略 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyHtmlTheme(isDark: boolean): void {
|
||||||
|
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
||||||
|
if (isDark) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Consumer Hook ----------
|
||||||
|
|
||||||
|
export function useTheme(): ThemeContextValue {
|
||||||
|
const ctx = useContext(ThemeContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useTheme() 必须在 <ThemeProvider> 内部调用');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Provider 组件 ----------
|
||||||
|
|
||||||
|
interface ThemeProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 检测 View Transitions API 是否可用(Chromium 111+,Electron 28+) */
|
||||||
|
function supportsViewTransition(): boolean {
|
||||||
|
return typeof document !== 'undefined' && 'startViewTransition' in document;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: ThemeProviderProps) {
|
||||||
|
const [isDark, setIsDark] = useState<boolean>(readStoredTheme);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
applyHtmlTheme(isDark);
|
||||||
|
}, [isDark]);
|
||||||
|
|
||||||
|
const toggleTheme = useCallback(() => {
|
||||||
|
if (supportsViewTransition()) {
|
||||||
|
// View Transitions API:GPU 合成器 cross-fade,1 个动画替代 N 个 CSS 过渡
|
||||||
|
document.startViewTransition(() => {
|
||||||
|
flushSync(() => {
|
||||||
|
setIsDark((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
writeStoredTheme(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 回退:普通 React 状态更新 + CSS transition
|
||||||
|
setIsDark((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
writeStoredTheme(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setLight = useCallback(() => {
|
||||||
|
if (supportsViewTransition()) {
|
||||||
|
document.startViewTransition(() => {
|
||||||
|
flushSync(() => {
|
||||||
|
setIsDark(false);
|
||||||
|
writeStoredTheme(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setIsDark(false);
|
||||||
|
writeStoredTheme(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setDark = useCallback(() => {
|
||||||
|
if (supportsViewTransition()) {
|
||||||
|
document.startViewTransition(() => {
|
||||||
|
flushSync(() => {
|
||||||
|
setIsDark(true);
|
||||||
|
writeStoredTheme(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setIsDark(true);
|
||||||
|
writeStoredTheme(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 监听系统主题变化(仅用户从未手动切换时跟随)
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const handleChange = (e: MediaQueryListEvent) => {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored === null) {
|
||||||
|
setIsDark(e.matches);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mq.addEventListener('change', handleChange);
|
||||||
|
return () => mq.removeEventListener('change', handleChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const ctx: ThemeContextValue = { isDark, toggleTheme, setLight, setDark };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={ctx}>
|
||||||
|
<ConfigProvider theme={getThemeConfig(isDark)} locale={zhCN}>{children}</ConfigProvider>
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/components/shell/Layout.tsx
Normal file
24
src/components/shell/Layout.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// ============================================================
|
||||||
|
// Layout — 页面布局壳(Banner轮播 + NavBar + 页面内容)
|
||||||
|
// BannerCarousel 始终渲染(当前仅有首页,后续多面板切换时 Banner 始终保持)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { theme as antTheme } from 'antd';
|
||||||
|
import { NavBar } from './navbar';
|
||||||
|
import { BannerCarousel } from '@/pages/home/features/BannerCarousel';
|
||||||
|
|
||||||
|
export function Layout({ children }: { children: ReactNode }) {
|
||||||
|
const { token } = antTheme.useToken();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="h-screen flex flex-col transition-colors duration-150"
|
||||||
|
style={{ background: token.colorBgLayout }}
|
||||||
|
>
|
||||||
|
<BannerCarousel />
|
||||||
|
<NavBar />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
249
src/components/shell/PageDispatcher.tsx
Normal file
249
src/components/shell/PageDispatcher.tsx
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
// ============================================================
|
||||||
|
// PageDispatcher — 页面事件调度器
|
||||||
|
//
|
||||||
|
// 监听 EVENTS.OPEN_PAGE,根据 modalType 渲染对应容器:
|
||||||
|
// - modal → antd Modal(居中模态,有遮罩阻断交互)
|
||||||
|
// - drawer → antd Drawer(侧滑抽屉)
|
||||||
|
// - floating → FloatingPanel(非模态浮动面板,可拖拽,不阻断交互)
|
||||||
|
//
|
||||||
|
// 全局互斥:同一时刻最多打开一个页面组件。
|
||||||
|
// 已打开时不响应 OPEN_PAGE 事件(保留面板内互操作能力:拖拽、图片拖放等)。
|
||||||
|
//
|
||||||
|
// PAGE_MAP 集中注册所有可调度页面:
|
||||||
|
// 有真实组件 → { component: XxxPage, label: '...', width?: number }
|
||||||
|
// 占位页面 → 只写 label,自动渲染 "页面开发中"
|
||||||
|
// 懒加载页面 → component: React.lazy(() => import('...'))
|
||||||
|
// 新增页面只需在这里加一行,无需创建独立文件
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import {type ComponentType, useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||||
|
import {Drawer, Modal} from 'antd';
|
||||||
|
import {EVENTS, off, on} from '@/utils/event-bus';
|
||||||
|
import {FloatingPanel} from '@/components/ui/FloatingPanel';
|
||||||
|
|
||||||
|
import {WechatWorkPage} from '@/pages/panels/WechatWorkPage';
|
||||||
|
import {AccountInfo} from '@/pages/panels/AccountInfo';
|
||||||
|
import {BillingInfo} from '@/pages/panels/BillingInfo';
|
||||||
|
import {RechargePage} from '@/pages/panels/RechargePage';
|
||||||
|
import {OrdersPage} from '@/pages/panels/OrdersPage';
|
||||||
|
import {VipPage} from '@/pages/panels/VipPage';
|
||||||
|
|
||||||
|
// ---------- 类型 ----------
|
||||||
|
|
||||||
|
type ModalType = 'modal' | 'drawer' | 'floating';
|
||||||
|
|
||||||
|
interface PageEventPayload {
|
||||||
|
target: string;
|
||||||
|
modalType: ModalType;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前活跃面板(id 用于 key / 关闭定位) */
|
||||||
|
interface ActivePanel extends PageEventPayload {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageConfig {
|
||||||
|
/** 页面组件(未提供则使用自动生成的占位组件)。
|
||||||
|
* 支持 React.lazy(() => import('...')) 懒加载。 */
|
||||||
|
component?: ComponentType;
|
||||||
|
/** 页面标签(显示在容器标题栏) */
|
||||||
|
label: string;
|
||||||
|
/** 容器宽度(px)。也支持函数动态计算:() => window.innerWidth * 0.6 */
|
||||||
|
width?: number | (() => number);
|
||||||
|
/** 容器高度(px),仅 FloatingPanel 生效。也支持函数动态计算 */
|
||||||
|
height?: number | (() => number);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 占位组件(模块级,引用稳定,避免渲染中创建组件) ----------
|
||||||
|
|
||||||
|
function PlaceholderPage({label}: { label: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{padding: 8}}>
|
||||||
|
<p style={{color: 'inherit', opacity: 0.6, margin: 0}}>{label} — 页面开发中</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 为每个占位页预创建稳定组件引用 */
|
||||||
|
function createPlaceholder(label: string): ComponentType {
|
||||||
|
const C = () => <PlaceholderPage label={label} />;
|
||||||
|
C.displayName = `Placeholder_${label}`;
|
||||||
|
return C;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- target → 页面组件映射 ----------
|
||||||
|
|
||||||
|
const PAGE_MAP: Record<string, PageConfig> = {
|
||||||
|
'/wechat-work': {component: WechatWorkPage, label: '企业微信'},
|
||||||
|
'/compliance-assets': {label: '合规素材库'},
|
||||||
|
'/account': {
|
||||||
|
component: AccountInfo,
|
||||||
|
label: '账户',
|
||||||
|
width: () => Math.min(window.outerHeight * 0.65, 720),
|
||||||
|
height: () => Math.min(window.outerHeight * 0.65, 680),
|
||||||
|
},
|
||||||
|
'/vip': {component: VipPage, label: 'VIP 会员开通', width: 560},
|
||||||
|
'/recharge': {component: RechargePage, label: '充值', width: 520},
|
||||||
|
'/billing': {label: '账单记录',
|
||||||
|
component: BillingInfo,
|
||||||
|
width: 960,
|
||||||
|
height: 680,},
|
||||||
|
'/orders': {
|
||||||
|
component: OrdersPage,
|
||||||
|
label: '订单明细',
|
||||||
|
width: 820,
|
||||||
|
height: 640,
|
||||||
|
},
|
||||||
|
'/projects': {label: '项目管理'},
|
||||||
|
'/quota': {
|
||||||
|
component: AccountInfo,
|
||||||
|
label: '配额',
|
||||||
|
width: () => Math.min(window.outerHeight * 0.65, 720),
|
||||||
|
height: () => Math.min(window.outerHeight * 0.65, 680),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 预计算所有占位组件(模块加载时执行一次,引用永久稳定)
|
||||||
|
const RESOLVED_PAGE_MAP = new Map<string, ComponentType>();
|
||||||
|
for (const [key, config] of Object.entries(PAGE_MAP)) {
|
||||||
|
RESOLVED_PAGE_MAP.set(key, config.component || createPlaceholder(config.label));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析页面组件(纯查表,不创建新组件) */
|
||||||
|
function resolvePage(target: string): ComponentType | null {
|
||||||
|
return RESOLVED_PAGE_MAP.get(target) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析容器宽度(支持静态数值或动态函数) */
|
||||||
|
function resolveWidth(target: string): number | undefined {
|
||||||
|
const w = PAGE_MAP[target]?.width;
|
||||||
|
return typeof w === 'function' ? w() : w;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析容器高度(支持静态数值或动态函数) */
|
||||||
|
function resolveHeight(target: string): number | undefined {
|
||||||
|
const h = PAGE_MAP[target]?.height;
|
||||||
|
return typeof h === 'function' ? h() : h;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 组件 ----------
|
||||||
|
|
||||||
|
export function PageDispatcher() {
|
||||||
|
const [activePanel, setActivePanel] = useState<ActivePanel | null>(null);
|
||||||
|
const nextIdRef = useRef(0);
|
||||||
|
const [locked, setLocked] = useState(false);
|
||||||
|
|
||||||
|
// 关闭面板
|
||||||
|
const closePanel = useCallback(() => {
|
||||||
|
setActivePanel(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 监听 OPEN_PAGE 事件
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (payload: unknown) => {
|
||||||
|
const {target, modalType, label} = payload as PageEventPayload;
|
||||||
|
|
||||||
|
if (!target || !PAGE_MAP[target]) {
|
||||||
|
console.warn(`[PageDispatcher] 未找到页面组件: ${target}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局互斥:已有面板打开时,禁止打开新面板
|
||||||
|
// 不关闭已有面板,保留其内部互操作能力(拖拽、图片拖放等)
|
||||||
|
setActivePanel((prev) => {
|
||||||
|
if (prev) return prev;
|
||||||
|
return {id: `pnl-${nextIdRef.current++}`, target, modalType, label};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
on(EVENTS.OPEN_PAGE, handler);
|
||||||
|
return () => off(EVENTS.OPEN_PAGE, handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 监听 CLOSE_PAGE 事件(允许页面组件内部"取消"按钮关闭容器)
|
||||||
|
useEffect(() => {
|
||||||
|
on(EVENTS.CLOSE_PAGE, closePanel);
|
||||||
|
return () => off(EVENTS.CLOSE_PAGE, closePanel);
|
||||||
|
}, [closePanel]);
|
||||||
|
|
||||||
|
// 监听 PAGE_LOCK 事件(页面组件操作进行中时禁止关闭容器)
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (payload: unknown) => {
|
||||||
|
setLocked((payload as { locked: boolean }).locked);
|
||||||
|
};
|
||||||
|
on(EVENTS.PAGE_LOCK, handler);
|
||||||
|
return () => off(EVENTS.PAGE_LOCK, handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 面板关闭时重置锁定状态(防止下次打开时仍处于锁定)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activePanel) setLocked(false);
|
||||||
|
}, [activePanel]);
|
||||||
|
|
||||||
|
// 页面组件(useMemo 稳定引用)
|
||||||
|
const PanelPage = useMemo<ComponentType | null>(
|
||||||
|
() => (activePanel ? resolvePage(activePanel.target) : null),
|
||||||
|
[activePanel],
|
||||||
|
);
|
||||||
|
|
||||||
|
const panelWidth = activePanel ? resolveWidth(activePanel.target) : undefined;
|
||||||
|
const panelHeight = activePanel ? resolveHeight(activePanel.target) : undefined;
|
||||||
|
|
||||||
|
// 容器默认尺寸(动态计算,视口缩放自动适配)
|
||||||
|
const defaultModalWidth = Math.min(window.innerWidth * 0.55, 720);
|
||||||
|
const defaultDrawerWidth = Math.min(window.innerWidth * 0.38, 520);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* ======== Modal ======== */}
|
||||||
|
{activePanel?.modalType === 'modal' && PanelPage && (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
centered
|
||||||
|
title={activePanel.label}
|
||||||
|
onCancel={closePanel}
|
||||||
|
footer={null}
|
||||||
|
destroyOnHidden={true}
|
||||||
|
mask={{closable: !locked}}
|
||||||
|
width={panelWidth ?? defaultModalWidth}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line react-hooks/static-components */}
|
||||||
|
<PanelPage />
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ======== Drawer ======== */}
|
||||||
|
{activePanel?.modalType === 'drawer' && PanelPage && (
|
||||||
|
<Drawer
|
||||||
|
open
|
||||||
|
title={activePanel.label}
|
||||||
|
onClose={closePanel}
|
||||||
|
destroyOnHidden={true}
|
||||||
|
mask={{
|
||||||
|
closable:locked
|
||||||
|
}}
|
||||||
|
size={panelWidth ?? defaultDrawerWidth}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line react-hooks/static-components */}
|
||||||
|
<PanelPage />
|
||||||
|
</Drawer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ======== Floating Panel ======== */}
|
||||||
|
{activePanel?.modalType === 'floating' && PanelPage && (
|
||||||
|
<FloatingPanel
|
||||||
|
key={activePanel.id}
|
||||||
|
open
|
||||||
|
title={activePanel.label}
|
||||||
|
onClose={closePanel}
|
||||||
|
width={panelWidth ?? defaultDrawerWidth}
|
||||||
|
height={panelHeight}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line react-hooks/static-components */}
|
||||||
|
<PanelPage />
|
||||||
|
</FloatingPanel>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
188
src/components/shell/navbar/AnnouncementDrawer.tsx
Normal file
188
src/components/shell/navbar/AnnouncementDrawer.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
// ============================================================
|
||||||
|
// AnnouncementDrawer — 公告侧拉抽屉(SPA 内部弹出)
|
||||||
|
// 数据来源: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 { fetchAnnouncements, type Announcement, type AnnouncementListResponse, type AnnouncementTypeValue } from '@/services/modules/announcement';
|
||||||
|
import { EnumResolver } from '@/utils/enum-resolver';
|
||||||
|
|
||||||
|
const {Text, Paragraph} = Typography;
|
||||||
|
|
||||||
|
// ---------- 辅助 ----------
|
||||||
|
|
||||||
|
/** 格式化日期(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 组件 ----------
|
||||||
|
|
||||||
|
interface AnnouncementDrawerProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnnouncementDrawer({open, onClose}: AnnouncementDrawerProps) {
|
||||||
|
const [list, setList] = useState<Announcement[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 详情 Modal
|
||||||
|
const [detail, setDetail] = useState<Announcement | null>(null);
|
||||||
|
|
||||||
|
// 打开抽屉时请求公告列表
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// ---- 渲染 ----
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Drawer
|
||||||
|
title={
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<NotificationOutlined />
|
||||||
|
公告
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
placement="right"
|
||||||
|
size={380}
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
styles={{body: {padding: 0}}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Spin description="加载中..." />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Empty description={error} />
|
||||||
|
</div>
|
||||||
|
) : list.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Empty description="暂无公告" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3 p-3">
|
||||||
|
{list.map((item:Announcement) => (
|
||||||
|
<Tooltip
|
||||||
|
key={item.id}
|
||||||
|
title={
|
||||||
|
item.content.length > 100
|
||||||
|
? item.content.slice(0, 200) + '…'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
placement="left"
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
className="rounded-md! border-0! shadow-sm! cursor-pointer hover:shadow-md! transition-shadow"
|
||||||
|
title={
|
||||||
|
<Text strong className="text-sm" ellipsis>
|
||||||
|
{item.pinned && '📌 '}
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Tag
|
||||||
|
color={EnumResolver.announcement.type.color(item.type as AnnouncementTypeValue)}
|
||||||
|
className="m-0! text-xs"
|
||||||
|
>
|
||||||
|
{EnumResolver.announcement.type.label(item.type as AnnouncementTypeValue)}
|
||||||
|
</Tag>
|
||||||
|
}
|
||||||
|
onClick={() => setDetail(item)}
|
||||||
|
>
|
||||||
|
<Paragraph
|
||||||
|
className="mb-2! text-xs"
|
||||||
|
type="secondary"
|
||||||
|
ellipsis={{rows: 2}}
|
||||||
|
>
|
||||||
|
{item.content}
|
||||||
|
</Paragraph>
|
||||||
|
<Text type="secondary" className="text-xs">
|
||||||
|
{formatDate(item.created_at)}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
{/* ======== 公告详情 Modal ======== */}
|
||||||
|
<Modal
|
||||||
|
open={!!detail}
|
||||||
|
onCancel={() => setDetail(null)}
|
||||||
|
footer={null}
|
||||||
|
width={480}
|
||||||
|
centered
|
||||||
|
title={
|
||||||
|
detail && (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{detail.pinned && '📌 '}
|
||||||
|
{detail.title}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{detail && (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Tag
|
||||||
|
color={EnumResolver.announcement.type.color(detail.type as AnnouncementTypeValue)}
|
||||||
|
className="m-0! text-xs"
|
||||||
|
>
|
||||||
|
{EnumResolver.announcement.type.label(detail.type as AnnouncementTypeValue)}
|
||||||
|
</Tag>
|
||||||
|
<Text type="secondary" className="text-xs">
|
||||||
|
{formatDate(detail.created_at)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="px-1 py-2 rounded-md"
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-fill-tertiary, rgba(0,0,0,0.04))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paragraph className="mb-0! text-sm leading-relaxed whitespace-pre-wrap">
|
||||||
|
{detail.content}
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
src/components/shell/navbar/NavBar.tsx
Normal file
140
src/components/shell/navbar/NavBar.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
// ============================================================
|
||||||
|
// NavBar — 自定义顶部导航栏(替代系统菜单)
|
||||||
|
//
|
||||||
|
// 逻辑:
|
||||||
|
// - 所有导航项默认全部显示(不再按登录状态隐藏)
|
||||||
|
// - 未登录时点击需登录的项 → 弹出提示"请先登录"并触发登录 Modal
|
||||||
|
// - 登录/注册 与 退出登录 仍按登录状态互斥显示
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import {useState, useCallback, useMemo} from 'react';
|
||||||
|
import {App} from 'antd';
|
||||||
|
|
||||||
|
import {useAppContext} from '@/components/providers/AppProvider';
|
||||||
|
import {useTheme} from '@/components/providers/ThemeProvider';
|
||||||
|
import {emit, EVENTS} from '@/utils/event-bus.ts';
|
||||||
|
import {NavItem} from './NavItem';
|
||||||
|
import {AnnouncementDrawer} from './AnnouncementDrawer';
|
||||||
|
import {personalLeft, personalRight, enterpriseLeft, enterpriseRight} from './nav-config';
|
||||||
|
import type {NavItemConfig} from './types';
|
||||||
|
|
||||||
|
const NAVBAR_HEIGHT = 56;
|
||||||
|
|
||||||
|
export function NavBar() {
|
||||||
|
const {platform, edition, isLoggedIn, logout} = useAppContext();
|
||||||
|
const {isDark} = useTheme();
|
||||||
|
const {message} = App.useApp();
|
||||||
|
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
|
||||||
|
// 根据版本选择导航项
|
||||||
|
const {leftItems, rightItems} = useMemo(() => {
|
||||||
|
const isEnterprise = edition === 'enterprise';
|
||||||
|
return {
|
||||||
|
leftItems: isEnterprise ? enterpriseLeft : personalLeft,
|
||||||
|
rightItems: isEnterprise ? enterpriseRight : personalRight,
|
||||||
|
};
|
||||||
|
}, [edition]);
|
||||||
|
|
||||||
|
// 过滤规则:
|
||||||
|
// requireGuest → 仅未登录时可见(登录/注册)
|
||||||
|
// "退出登录" → 仅已登录时可见
|
||||||
|
// 其他所有项 → 始终可见,未登录点击时弹"请先登录"
|
||||||
|
const visibleLeft = useMemo(
|
||||||
|
() =>
|
||||||
|
leftItems.filter((item) => {
|
||||||
|
if (item.requireGuest && isLoggedIn) return false;
|
||||||
|
return !(item.key === 'logout' && !isLoggedIn);
|
||||||
|
}),
|
||||||
|
[leftItems, isLoggedIn],
|
||||||
|
);
|
||||||
|
const visibleRight: 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
} else if (item.key === 'auth') {
|
||||||
|
emit(EVENTS.SHOW_LOGIN);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'external':
|
||||||
|
if (item.target) {
|
||||||
|
window.open(item.target, '_blank');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'page':
|
||||||
|
if (item.target) {
|
||||||
|
emit(EVENTS.OPEN_PAGE, {
|
||||||
|
target: item.target,
|
||||||
|
modalType: item.modalType ?? 'floating',
|
||||||
|
label: item.label,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isLoggedIn, logout, message],
|
||||||
|
);
|
||||||
|
|
||||||
|
const macPaddingLeft = platform === 'darwin' ? 80 : 12;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="navbar-drag-region flex items-center justify-between select-none"
|
||||||
|
style={{
|
||||||
|
height: NAVBAR_HEIGHT,
|
||||||
|
paddingLeft: macPaddingLeft,
|
||||||
|
paddingRight: 12,
|
||||||
|
background: isDark
|
||||||
|
? 'linear-gradient(180deg, #1E1B4B 0%, #1A1730 100%)'
|
||||||
|
: 'linear-gradient(180deg, #FFFFFF 0%, #F5F3FF 100%)',
|
||||||
|
borderBottom: `1px solid ${isDark ? '#2E2A4A' : '#E5E7EB'}`,
|
||||||
|
boxShadow: isDark ? '0 1px 4px rgba(0,0,0,0.3)' : '0 1px 4px rgba(99,102,241,0.06)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="navbar-no-drag flex items-center gap-0.5">
|
||||||
|
{visibleLeft.map((item) => (
|
||||||
|
<NavItem key={item.key} item={item} onClick={handleNavClick} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="navbar-no-drag flex items-center gap-0.5">
|
||||||
|
{visibleRight.map((item) => (
|
||||||
|
<NavItem key={item.key} item={item} onClick={handleNavClick} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnnouncementDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
// 布局:图标(SVG)在上,文字在下
|
// 布局:图标(SVG)在上,文字在下
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useTheme } from '@/hooks/use-theme.ts';
|
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||||
import type { NavItemConfig } from './types';
|
import type { NavItemConfig } from './types';
|
||||||
|
|
||||||
interface NavItemProps {
|
interface NavItemProps {
|
||||||
1
src/components/shell/navbar/index.ts
Normal file
1
src/components/shell/navbar/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { NavBar } from './NavBar';
|
||||||
@@ -28,8 +28,9 @@ const personalLeft: NavItemConfig[] = [
|
|||||||
key: 'compliance-assets',
|
key: 'compliance-assets',
|
||||||
label: '合规素材库',
|
label: '合规素材库',
|
||||||
iconSrc: complianceAssetsSvg,
|
iconSrc: complianceAssetsSvg,
|
||||||
action: 'navigate',
|
action: 'page',
|
||||||
target: '/compliance-assets',
|
target: '/compliance-assets',
|
||||||
|
modalType: 'floating',
|
||||||
position: 'left',
|
position: 'left',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -47,8 +48,9 @@ const personalRight: NavItemConfig[] = [
|
|||||||
key: 'account',
|
key: 'account',
|
||||||
label: '查帐户',
|
label: '查帐户',
|
||||||
iconSrc: accountSvg,
|
iconSrc: accountSvg,
|
||||||
action: 'navigate',
|
action: 'page',
|
||||||
target: '/account',
|
target: '/account',
|
||||||
|
modalType: 'floating',
|
||||||
position: 'right',
|
position: 'right',
|
||||||
requireLogin: true,
|
requireLogin: true,
|
||||||
},
|
},
|
||||||
@@ -56,16 +58,18 @@ const personalRight: NavItemConfig[] = [
|
|||||||
key: 'vip-activate',
|
key: 'vip-activate',
|
||||||
label: '开会员/激活',
|
label: '开会员/激活',
|
||||||
iconSrc: membershipSvg,
|
iconSrc: membershipSvg,
|
||||||
action: 'navigate',
|
action: 'page',
|
||||||
target: '/vip',
|
target: '/vip',
|
||||||
|
modalType: 'modal',
|
||||||
position: 'right',
|
position: 'right',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'recharge-points',
|
key: 'recharge-points',
|
||||||
label: '充值积分',
|
label: '充值',
|
||||||
iconSrc: rechargeSvg,
|
iconSrc: rechargeSvg,
|
||||||
action: 'navigate',
|
action: 'page',
|
||||||
target: '/recharge',
|
target: '/recharge',
|
||||||
|
modalType: 'modal',
|
||||||
position: 'right',
|
position: 'right',
|
||||||
requireLogin: true,
|
requireLogin: true,
|
||||||
},
|
},
|
||||||
@@ -73,8 +77,9 @@ const personalRight: NavItemConfig[] = [
|
|||||||
key: 'billing-history',
|
key: 'billing-history',
|
||||||
label: '消费记录',
|
label: '消费记录',
|
||||||
iconSrc: billingSvg,
|
iconSrc: billingSvg,
|
||||||
action: 'navigate',
|
action: 'page',
|
||||||
target: '/billing',
|
target: '/billing',
|
||||||
|
modalType: 'floating',
|
||||||
position: 'right',
|
position: 'right',
|
||||||
requireLogin: true,
|
requireLogin: true,
|
||||||
},
|
},
|
||||||
@@ -82,8 +87,9 @@ const personalRight: NavItemConfig[] = [
|
|||||||
key: 'order-details',
|
key: 'order-details',
|
||||||
label: '订单明细',
|
label: '订单明细',
|
||||||
iconSrc: orderDetailsSvg,
|
iconSrc: orderDetailsSvg,
|
||||||
action: 'navigate',
|
action: 'page',
|
||||||
target: '/orders',
|
target: '/orders',
|
||||||
|
modalType: 'floating',
|
||||||
position: 'right',
|
position: 'right',
|
||||||
requireLogin: true,
|
requireLogin: true,
|
||||||
},
|
},
|
||||||
@@ -91,24 +97,23 @@ const personalRight: NavItemConfig[] = [
|
|||||||
key: 'wechat-work',
|
key: 'wechat-work',
|
||||||
label: '企业微信',
|
label: '企业微信',
|
||||||
iconSrc: wechatWorkSvg,
|
iconSrc: wechatWorkSvg,
|
||||||
action: 'external',
|
action: 'page',
|
||||||
target: 'https://work.weixin.qq.com/',
|
target: '/wechat-work',
|
||||||
|
modalType: 'modal',
|
||||||
position: 'right',
|
position: 'right',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'settings',
|
key: 'settings',
|
||||||
label: '设置',
|
label: '设置',
|
||||||
iconSrc: settingsSvg,
|
iconSrc: settingsSvg,
|
||||||
action: 'navigate',
|
action: 'button',
|
||||||
target: '/settings',
|
|
||||||
position: 'right',
|
position: 'right',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'auth',
|
key: 'auth',
|
||||||
label: '登录/注册',
|
label: '登录/注册',
|
||||||
iconSrc: accountSvg,
|
iconSrc: accountSvg,
|
||||||
action: 'navigate',
|
action: 'button',
|
||||||
target: '/login',
|
|
||||||
position: 'right',
|
position: 'right',
|
||||||
requireGuest: true,
|
requireGuest: true,
|
||||||
},
|
},
|
||||||
@@ -132,16 +137,18 @@ const enterpriseLeft: NavItemConfig[] = [
|
|||||||
key: 'compliance-assets',
|
key: 'compliance-assets',
|
||||||
label: '合规素材库',
|
label: '合规素材库',
|
||||||
iconSrc: complianceAssetsSvg,
|
iconSrc: complianceAssetsSvg,
|
||||||
action: 'navigate',
|
action: 'page',
|
||||||
target: '/compliance-assets',
|
target: '/compliance-assets',
|
||||||
|
modalType: 'floating',
|
||||||
position: 'left',
|
position: 'left',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'project-management',
|
key: 'project-management',
|
||||||
label: '项目管理',
|
label: '项目管理',
|
||||||
iconSrc: projectManagementSvg,
|
iconSrc: projectManagementSvg,
|
||||||
action: 'navigate',
|
action: 'page',
|
||||||
target: '/projects',
|
target: '/projects',
|
||||||
|
modalType: 'floating',
|
||||||
position: 'left',
|
position: 'left',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -159,8 +166,9 @@ const enterpriseRight: NavItemConfig[] = [
|
|||||||
key: 'quota-check',
|
key: 'quota-check',
|
||||||
label: '查配额',
|
label: '查配额',
|
||||||
iconSrc: accountSvg,
|
iconSrc: accountSvg,
|
||||||
action: 'navigate',
|
action: 'page',
|
||||||
target: '/quota',
|
target: '/quota',
|
||||||
|
modalType: 'floating',
|
||||||
position: 'right',
|
position: 'right',
|
||||||
requireLogin: false,
|
requireLogin: false,
|
||||||
},
|
},
|
||||||
@@ -168,16 +176,14 @@ const enterpriseRight: NavItemConfig[] = [
|
|||||||
key: 'settings',
|
key: 'settings',
|
||||||
label: '设置',
|
label: '设置',
|
||||||
iconSrc: settingsSvg,
|
iconSrc: settingsSvg,
|
||||||
action: 'navigate',
|
action: 'button',
|
||||||
target: '/settings',
|
|
||||||
position: 'right',
|
position: 'right',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'auth',
|
key: 'auth',
|
||||||
label: '登录/注册',
|
label: '登录/注册',
|
||||||
iconSrc: accountSvg,
|
iconSrc: accountSvg,
|
||||||
action: 'navigate',
|
action: 'button',
|
||||||
target: '/login',
|
|
||||||
position: 'right',
|
position: 'right',
|
||||||
requireGuest: true,
|
requireGuest: true,
|
||||||
},
|
},
|
||||||
@@ -6,10 +6,9 @@ import type { Edition } from '@shared/types';
|
|||||||
|
|
||||||
/** 导航项的行为类型 */
|
/** 导航项的行为类型 */
|
||||||
export type NavAction =
|
export type NavAction =
|
||||||
| 'navigate' // 新开窗口
|
| 'page' // 打开页面 Modal(携带 target 路径标识符,由事件总线分发)
|
||||||
| 'spa' // 单页应用内导航(当前窗口)
|
|
||||||
| 'drawer' // 侧拉抽屉(公告等)
|
| 'drawer' // 侧拉抽屉(公告等)
|
||||||
| 'button' // 纯按钮行为(退出登录)
|
| 'button' // 纯按钮行为(退出登录等)
|
||||||
| 'external'; // 外部链接
|
| 'external'; // 外部链接
|
||||||
|
|
||||||
/** 单个导航项配置 */
|
/** 单个导航项配置 */
|
||||||
@@ -24,6 +23,8 @@ export interface NavItemConfig {
|
|||||||
action: NavAction;
|
action: NavAction;
|
||||||
/** 点击回调 / 路由路径 / 外部 URL(取决于 action) */
|
/** 点击回调 / 路由路径 / 外部 URL(取决于 action) */
|
||||||
target?: string;
|
target?: string;
|
||||||
|
/** 窗口类型(仅 action='page' 时生效):modal 居中模态 | drawer 侧滑 | floating 非模态浮动面板 */
|
||||||
|
modalType?: 'modal' | 'drawer' | 'floating';
|
||||||
/** 仅在指定版本下显示 */
|
/** 仅在指定版本下显示 */
|
||||||
edition?: Edition | Edition[];
|
edition?: Edition | Edition[];
|
||||||
/** 仅已登录时显示 */
|
/** 仅已登录时显示 */
|
||||||
215
src/components/ui/FloatingPanel.tsx
Normal file
215
src/components/ui/FloatingPanel.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
// ============================================================
|
||||||
|
// FloatingPanel — 非模态可拖拽浮动面板
|
||||||
|
//
|
||||||
|
// 特性:
|
||||||
|
// - 标题栏拖拽移动,无遮罩,不阻断主页面交互
|
||||||
|
// - 初始居中定位,支持暗色主题
|
||||||
|
// - 边界检测(不拖出视口)
|
||||||
|
// - 多个面板可同时打开(点击置顶)
|
||||||
|
// - 关闭按钮 + 最小尺寸约束
|
||||||
|
//
|
||||||
|
// 对应 QT 原版"非模态窗口"的 Web 映射
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback, useEffect, type ReactNode } from 'react';
|
||||||
|
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||||
|
|
||||||
|
// ---------- 类型 ----------
|
||||||
|
|
||||||
|
export interface FloatingPanelProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
/** 初始宽度(px),默认 480 */
|
||||||
|
width?: number;
|
||||||
|
/** 初始高度(px),默认 360 */
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 全局 z-index 管理 ----------
|
||||||
|
|
||||||
|
let globalZIndex = 1000;
|
||||||
|
function nextZIndex(): number {
|
||||||
|
globalZIndex += 1;
|
||||||
|
return globalZIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 组件 ----------
|
||||||
|
|
||||||
|
export function FloatingPanel({ open, onClose, title, children, width = 480, height = 360 }: FloatingPanelProps) {
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
|
||||||
|
// 位置与尺寸
|
||||||
|
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const [zIndex, setZIndex] = useState(() => nextZIndex());
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 拖拽状态
|
||||||
|
const draggingRef = useRef(false);
|
||||||
|
const dragStartRef = useRef({ x: 0, y: 0, panelX: 0, panelY: 0 });
|
||||||
|
|
||||||
|
// 初始居中(首次 open 时计算)
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && position === null) {
|
||||||
|
const x = Math.max(0, (window.innerWidth - width) / 2);
|
||||||
|
const y = Math.max(0, (window.innerHeight - height) / 2);
|
||||||
|
setPosition({ x, y });
|
||||||
|
}
|
||||||
|
}, [open, position, width, height]);
|
||||||
|
|
||||||
|
// 拖拽启动
|
||||||
|
const handleMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
// 仅标题栏区域可拖拽
|
||||||
|
if ((e.target as HTMLElement).closest('.fp-close-btn')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
draggingRef.current = true;
|
||||||
|
dragStartRef.current = {
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
panelX: position?.x ?? 0,
|
||||||
|
panelY: position?.y ?? 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[position],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 全局 mousemove / mouseup
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!draggingRef.current) return;
|
||||||
|
const ds = dragStartRef.current;
|
||||||
|
const newX = ds.panelX + (e.clientX - ds.x);
|
||||||
|
const newY = ds.panelY + (e.clientY - ds.y);
|
||||||
|
|
||||||
|
// 边界约束:保留标题栏至少 40px 可见,确保用户能拖回
|
||||||
|
const minVisible = 40;
|
||||||
|
const maxX = window.innerWidth - minVisible;
|
||||||
|
const maxY = window.innerHeight - minVisible;
|
||||||
|
|
||||||
|
setPosition({
|
||||||
|
x: Math.max(-width + minVisible, Math.min(maxX, newX)),
|
||||||
|
y: Math.max(0, Math.min(maxY, newY)),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
draggingRef.current = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [width]);
|
||||||
|
|
||||||
|
// 点击面板 → 置顶
|
||||||
|
const bringToFront = useCallback(() => {
|
||||||
|
setZIndex(nextZIndex());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!open || !position) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
onMouseDown={bringToFront}
|
||||||
|
className="fp-panel"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: position.x,
|
||||||
|
top: position.y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
zIndex,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
borderRadius: 8,
|
||||||
|
boxShadow: isDark
|
||||||
|
? '0 8px 32px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.08)'
|
||||||
|
: '0 8px 32px rgba(0,0,0,0.15), 0 0 0 1px rgba(0,0,0,0.06)',
|
||||||
|
background: isDark ? '#1E1B4B' : '#FFFFFF',
|
||||||
|
overflow: 'hidden',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 标题栏 — 拖拽手柄 */}
|
||||||
|
<div
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '0 12px 0 16px',
|
||||||
|
height: 40,
|
||||||
|
flexShrink: 0,
|
||||||
|
cursor: 'grab',
|
||||||
|
background: isDark
|
||||||
|
? 'linear-gradient(180deg, #2E2A5A 0%, #252050 100%)'
|
||||||
|
: 'linear-gradient(180deg, #F5F3FF 0%, #EEF2FF 100%)',
|
||||||
|
borderBottom: `1px solid ${isDark ? '#3E3A6A' : '#E5E7EB'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: isDark ? '#E8E6F0' : '#1E1B4B',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="fp-close-btn"
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
title="关闭"
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 16,
|
||||||
|
color: isDark ? '#A5A3C0' : '#9CA3AF',
|
||||||
|
background: 'transparent',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = isDark ? 'rgba(239,68,68,0.2)' : 'rgba(239,68,68,0.1)';
|
||||||
|
e.currentTarget.style.color = '#EF4444';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'transparent';
|
||||||
|
e.currentTarget.style.color = isDark ? '#A5A3C0' : '#9CA3AF';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内容区 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'auto',
|
||||||
|
userSelect: 'text',
|
||||||
|
padding: 16,
|
||||||
|
color: isDark ? '#E8E6F0' : '#1E1B4B',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
import { Modal, Typography } from 'antd';
|
import { Modal, Typography } from 'antd';
|
||||||
import { FileTextOutlined } from '@ant-design/icons';
|
import { FileTextOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { useTheme } from '@/hooks/use-theme';
|
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||||
|
|
||||||
// Vite ?raw 导入:将 .txt 内容作为字符串直接内联
|
// Vite ?raw 导入:将 .txt 内容作为字符串直接内联
|
||||||
import serviceAgreement from '@/assets/legal/用户服务协议.txt?raw';
|
import serviceAgreement from '@/assets/legal/用户服务协议.txt?raw';
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// AppContext — 全局应用状态
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { createContext, useContext } from 'react';
|
|
||||||
import type { Platform, Edition } from '@shared/types';
|
|
||||||
import type { LoginResponseBody } from '@/services/modules';
|
|
||||||
|
|
||||||
// ---------- Context 类型 ----------
|
|
||||||
|
|
||||||
export interface AppContextValue {
|
|
||||||
/** 当前操作系统平台 */
|
|
||||||
platform: Platform;
|
|
||||||
/** 产品版本(个人版 / 企业版) */
|
|
||||||
edition: Edition;
|
|
||||||
/** 用户是否已登录 */
|
|
||||||
isLoggedIn: boolean;
|
|
||||||
/** 当前登录用户信息(未登录时为 null,结构与 LoginResponseBody 一致) */
|
|
||||||
user: LoginResponseBody | null;
|
|
||||||
/** 登录:保存 Token 并更新登录态(auth 为登录接口返回值,异步加密存储) */
|
|
||||||
login: (auth: LoginResponseBody) => Promise<void>;
|
|
||||||
/** 退出登录:清除 Token 和用户状态 */
|
|
||||||
logout: () => void;
|
|
||||||
/** 是否为开发环境 */
|
|
||||||
isDevMode: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AppContext = createContext<AppContextValue | null>(null);
|
|
||||||
|
|
||||||
// ---------- Consumer Hook ----------
|
|
||||||
|
|
||||||
export function useAppContext(): AppContextValue {
|
|
||||||
const ctx = useContext(AppContext);
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error('useAppContext() 必须在 <AppProvider> 内部调用');
|
|
||||||
}
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
364
src/hooks/use-api.ts
Normal file
364
src/hooks/use-api.ts
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
// ============================================================
|
||||||
|
// use-api.ts — 业务 API Hook 集合
|
||||||
|
//
|
||||||
|
// 基于 useAsyncData / useAsyncMutation 封装各模块 API,
|
||||||
|
// 组件只需调用 Hook 获取 { data, loading, error } 三元组,
|
||||||
|
// 不再需要在组件内手写 useEffect + cancelled flag + useState。
|
||||||
|
//
|
||||||
|
// 每个 Hook 自动处理:
|
||||||
|
// - 登录状态门控(enabled: isLoggedIn)
|
||||||
|
// - 竞态条件
|
||||||
|
// - 错误日志
|
||||||
|
// - 用户可读错误信息(errorMessage)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import {useMemo, useState, useRef, useEffect, useCallback} from 'react';
|
||||||
|
import {useAppContext} from '@/components/providers/AppProvider';
|
||||||
|
import { TaskAPI, POLLING_STATUSES, type TaskListRequestParams, type TaskInfoItemResponseBody, type CreatTaskRequestBody, type TaskStatusResponseBody, type TaskStatusString } from '@/services/modules/task';
|
||||||
|
import { ModelAPI, MODEL_CATEGORY_LABELS, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models';
|
||||||
|
import { fetchBanners, type Banner } from '@/services/modules/banner';
|
||||||
|
import { AuthAPI, type UserInfoBody } from '@/services/modules/auth';
|
||||||
|
import { VipAPI, type VipLevelsItem } from '@/services/modules/vip-api';
|
||||||
|
import { BillingAPI, type BalanceResponseBody, type LedgerItemResponseBody, type LedgerReqeustParam } from '@/services/modules/billing';
|
||||||
|
import {useAsyncData, UseAsyncDataReturn, useAsyncMutation} from './use-async';
|
||||||
|
import {getCachedModels, setCachedModels} from '@/services/cache/model-cache';
|
||||||
|
import { upload } from '@/services/request';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Banner
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** 首页轮播图数据 Hook */
|
||||||
|
export function useBannerList() {
|
||||||
|
const {isLoggedIn} = useAppContext();
|
||||||
|
|
||||||
|
return useAsyncData<Banner[]>(
|
||||||
|
(signal) => fetchBanners(signal),
|
||||||
|
[],
|
||||||
|
{enabled: isLoggedIn, label: 'banner-list'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 模型
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** 模型列表数据 Hook(缓存优先 + 后台刷新)
|
||||||
|
*
|
||||||
|
* 策略:
|
||||||
|
* 1. 启动时同步读取加密缓存 → 立即展示(避免每次打开都 loading)
|
||||||
|
* 2. 异步拉取最新模型列表 → 覆盖缓存数据
|
||||||
|
* 3. 拉取成功后将最新数据加密写入 localStorage
|
||||||
|
*
|
||||||
|
* 因后端 category 查询参数实际不可用(传参返回空列表),
|
||||||
|
* 改为不带分类参数一次拉取全部,再由客户端根据响应中的
|
||||||
|
* category_name 字段手动分组。 */
|
||||||
|
export function useModelList() {
|
||||||
|
const {isLoggedIn} = useAppContext();
|
||||||
|
|
||||||
|
// 缓存即时数据(首次 render 同步读取,后续 API 返回后覆盖)
|
||||||
|
const [instantData, setInstantData] = useState<ModelsListResponseBody[] | null>(() =>
|
||||||
|
getCachedModels(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result: UseAsyncDataReturn<ModelsListResponseBody[]> = useAsyncData<ModelsListResponseBody[]>(
|
||||||
|
async (signal) => {
|
||||||
|
const models = await ModelAPI.fetchModels({page: 1, page_size: 200}, signal);
|
||||||
|
// 异步写入加密缓存(best-effort,不影响数据流)
|
||||||
|
setCachedModels(models).catch(() => {});
|
||||||
|
setInstantData(models);
|
||||||
|
return models;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
{enabled: isLoggedIn, label: 'model-list'},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 合并策略:API 数据 > 缓存即时数据
|
||||||
|
const mergedData = result.data ?? instantData;
|
||||||
|
|
||||||
|
// loading 仅在无任何数据时展示(缓存命中时不闪烁 loading)
|
||||||
|
const loading = result.loading && mergedData === null;
|
||||||
|
|
||||||
|
// 按 category_name 分组(后端返回的 category_name 为 slug 格式如 img2img)
|
||||||
|
const groupedModels: Map<string, ModelsListResponseBody[]> = useMemo(() => {
|
||||||
|
const groups = new Map<string, ModelsListResponseBody[]>();
|
||||||
|
mergedData?.forEach((model: ModelsListResponseBody) => {
|
||||||
|
const cat: ModelCategoryStringEnum = model.model_type;
|
||||||
|
if (!groups.has(cat)) groups.set(cat, []);
|
||||||
|
groups.get(cat)!.push(model);
|
||||||
|
});
|
||||||
|
return groups;
|
||||||
|
}, [mergedData]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
/** 合并后的模型数据(缓存优先) */
|
||||||
|
data: mergedData,
|
||||||
|
/** 仅在无缓存且正在加载时为 true */
|
||||||
|
loading,
|
||||||
|
/** 按 model_type 分组后的模型(key 为后端 slug) */
|
||||||
|
groupedModels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 任务列表
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface UseTaskListParams {
|
||||||
|
/** 完整的 API 请求参数(由调用方根据 antd Table onChange 构建) */
|
||||||
|
requestParams: TaskListRequestParams;
|
||||||
|
/** 外部传入的刷新信号(任意变化触发重新请求) */
|
||||||
|
refreshSignal?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务列表 Hook(分页 + 筛选 + 排序)
|
||||||
|
*
|
||||||
|
* 调用方负责构建 TaskListRequestParams(包括 edition-aware 的 user_ids 逻辑),
|
||||||
|
* 本 Hook 仅负责数据获取、竞态控制、错误日志。
|
||||||
|
*/
|
||||||
|
export function useTaskList(params: UseTaskListParams) {
|
||||||
|
const {isLoggedIn} = useAppContext();
|
||||||
|
const {requestParams, refreshSignal = 0} = params;
|
||||||
|
|
||||||
|
return useAsyncData(
|
||||||
|
(signal) => TaskAPI.getTaskList(requestParams, signal),
|
||||||
|
[requestParams, refreshSignal],
|
||||||
|
{enabled: isLoggedIn, label: 'task-list'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 任务详情
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** 任务详情 Hook(taskId 为 null 时不请求) */
|
||||||
|
export function useTaskDetail(taskId: string | null | undefined) {
|
||||||
|
const {isLoggedIn} = useAppContext();
|
||||||
|
|
||||||
|
return useAsyncData<TaskInfoItemResponseBody>(
|
||||||
|
(signal) => TaskAPI.getTaskInfo(taskId!, signal),
|
||||||
|
[taskId],
|
||||||
|
{enabled: isLoggedIn && !!taskId, label: 'task-detail'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 账户信息(用户信息 + VIP 等级,并行请求)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** 账户信息页数据 Hook — 返回 [userInfo, vipsInfo] 元组 */
|
||||||
|
export function useAccountInfo() {
|
||||||
|
const { isLoggedIn } = useAppContext();
|
||||||
|
return useAsyncData<[UserInfoBody, VipLevelsItem[]]>(
|
||||||
|
(signal) => Promise.all([
|
||||||
|
AuthAPI.getUserInfo(signal),
|
||||||
|
VipAPI.getVipLevels(signal),
|
||||||
|
]),
|
||||||
|
[],
|
||||||
|
{ enabled: isLoggedIn, label: 'account-info' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 账单
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** 账户余额 Hook */
|
||||||
|
export function useBillingBalance() {
|
||||||
|
const { isLoggedIn } = useAppContext();
|
||||||
|
return useAsyncData<BalanceResponseBody>(
|
||||||
|
(signal) => BillingAPI.getBalance(signal),
|
||||||
|
[],
|
||||||
|
{ enabled: isLoggedIn, label: 'billing-balance' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 账单明细 Hook(支持分页参数) */
|
||||||
|
export function useBillingLedger(params: LedgerReqeustParam) {
|
||||||
|
const { isLoggedIn } = useAppContext();
|
||||||
|
return useAsyncData<LedgerItemResponseBody[]>(
|
||||||
|
(signal) => BillingAPI.getLedger(params, signal),
|
||||||
|
[params],
|
||||||
|
{ enabled: isLoggedIn, label: 'billing-ledger' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 提交任务
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** 提交任务 Mutation Hook(device_id 由 API 层自动注入,调用方无需传入) */
|
||||||
|
export function useSubmitTask(options?: {
|
||||||
|
onSuccess?: (data: TaskInfoItemResponseBody) => void;
|
||||||
|
onError?: (err: Error) => void;
|
||||||
|
}) {
|
||||||
|
return useAsyncMutation<TaskInfoItemResponseBody, [Omit<CreatTaskRequestBody, 'device_id'>]>(
|
||||||
|
(body) => TaskAPI.submitTask(body),
|
||||||
|
{
|
||||||
|
label: 'submit-task',
|
||||||
|
onSuccess: options?.onSuccess,
|
||||||
|
onError: options?.onError,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 任务状态轮询
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量轮询非终态任务状态,返回合并后的任务列表。
|
||||||
|
*
|
||||||
|
* - 自动识别 tasks 中的非终态任务(pending / submitted / processing)
|
||||||
|
* - 调用 batchTaskStatus 批量查询状态
|
||||||
|
* - 使用服务端返回的 next_poll_ms(本地限制 2s ~ 30s)自适应间隔
|
||||||
|
* - 递归 setTimeout 而非 setInterval(避免请求堆积)
|
||||||
|
* - onTerminal 回调:当某个任务变为终态时触发(用于触发完整列表刷新)
|
||||||
|
*/
|
||||||
|
export function useTaskPolling(
|
||||||
|
tasks: TaskInfoItemResponseBody[],
|
||||||
|
onTerminal?: () => void,
|
||||||
|
): TaskInfoItemResponseBody[] {
|
||||||
|
const [merged, setMerged] = useState<TaskInfoItemResponseBody[]>(tasks);
|
||||||
|
const mergedRef = useRef(merged);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
|
||||||
|
// 保持 ref 与 state 同步,避免轮询闭包读到过期数据
|
||||||
|
mergedRef.current = merged;
|
||||||
|
|
||||||
|
// 源数据变化时同步(服务端拉取的新数据覆盖本地合并结果)
|
||||||
|
useEffect(() => {
|
||||||
|
setMerged(tasks);
|
||||||
|
}, [tasks]);
|
||||||
|
|
||||||
|
// 用 ref 稳定化 onTerminal 回调,避免 useEffect 因回调引用变化而重启
|
||||||
|
const onTerminalRef = useRef(onTerminal);
|
||||||
|
onTerminalRef.current = onTerminal;
|
||||||
|
|
||||||
|
// 轮询循环:当 tasks(源数据)变化时重启
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
|
||||||
|
// 检查源数据中是否有非终态任务
|
||||||
|
const hasNonTerminal = tasks.some(
|
||||||
|
t => POLLING_STATUSES.includes(t.status as TaskStatusString),
|
||||||
|
);
|
||||||
|
if (!hasNonTerminal) return;
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
|
||||||
|
// 从最新 merged 中取非终态 ID(而非闭包捕获的旧值)
|
||||||
|
const currentMerged = mergedRef.current;
|
||||||
|
const ids = currentMerged
|
||||||
|
.filter(t => POLLING_STATUSES.includes(t.status as TaskStatusString))
|
||||||
|
.map(t => t.id);
|
||||||
|
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await TaskAPI.batchTaskStatus({ task_ids: ids });
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
|
||||||
|
const statusMap = new Map(
|
||||||
|
result.items
|
||||||
|
.filter((s): s is TaskStatusResponseBody => s !== null && s !== undefined)
|
||||||
|
.map(s => [s.task_id, s]),
|
||||||
|
);
|
||||||
|
|
||||||
|
let hasTerminal = false;
|
||||||
|
|
||||||
|
setMerged(prev => prev.map(task => {
|
||||||
|
const st = statusMap.get(task.id);
|
||||||
|
if (!st || st.status === task.status) return task;
|
||||||
|
|
||||||
|
const isNowTerminal = !POLLING_STATUSES.includes(st.status as TaskStatusString);
|
||||||
|
if (isNowTerminal) hasTerminal = true;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...task,
|
||||||
|
status: st.status,
|
||||||
|
cost_cent: st.cost_cent ?? task.cost_cent,
|
||||||
|
error_code: st.error_code ?? task.error_code,
|
||||||
|
error_message: st.error_message ?? task.error_message,
|
||||||
|
finished_at: st.finished_at ?? task.finished_at,
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (hasTerminal) onTerminalRef.current?.();
|
||||||
|
|
||||||
|
// 检查合并后是否还有非终态任务(从 ref 读取最新值)
|
||||||
|
const stillPending = mergedRef.current.some(
|
||||||
|
t => POLLING_STATUSES.includes(t.status as TaskStatusString),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (stillPending && mountedRef.current) {
|
||||||
|
const delay = Math.max(2000, Math.min(30000, result.next_poll_ms || 5000));
|
||||||
|
timerRef.current = setTimeout(poll, delay);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
// 出错后 10s 重试
|
||||||
|
timerRef.current = setTimeout(poll, 10000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 首次轮询延迟 2s(给服务端缓冲时间)
|
||||||
|
timerRef.current = setTimeout(poll, 2000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [tasks]);
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 文件上传
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** 上传接口路径(与后端 /api/v1/upload 对齐) */
|
||||||
|
const UPLOAD_ACTION = '/api/v1/upload';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件上传 Hook — 返回 antd Upload 组件兼容的 customRequest。
|
||||||
|
*
|
||||||
|
* 设计要点:
|
||||||
|
* - 复用 request.ts 的 upload() 函数(自动携带 Token、超时、错误处理)
|
||||||
|
* - 通过 customRequest 集成到 antd Upload,而非直接设 action(避免 Token 缺失)
|
||||||
|
* - 上传成功后 antd 自动将 response 写入 file.response,供表单提交时提取 URL
|
||||||
|
*/
|
||||||
|
export function useFileUpload() {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const customRequest = useCallback((options: any) => {
|
||||||
|
const file = options.file as File;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file, options.filename || file.name);
|
||||||
|
|
||||||
|
upload<{ url: string }>(UPLOAD_ACTION, formData, (percent) => {
|
||||||
|
options.onProgress?.({ percent });
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
options.onSuccess?.(result);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
options.onError?.(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { customRequest, uploadAction: UPLOAD_ACTION } as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 重新导出类型和工具 ----------
|
||||||
|
|
||||||
|
export {MODEL_CATEGORY_LABELS};
|
||||||
|
export type {ModelCategoryStringEnum, ModelsListResponseBody, TaskInfoItemResponseBody, Banner};
|
||||||
258
src/hooks/use-async.ts
Normal file
258
src/hooks/use-async.ts
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
// ============================================================
|
||||||
|
// useAsyncData / useAsyncMutation — 通用异步数据 Hook
|
||||||
|
//
|
||||||
|
// 职责:
|
||||||
|
// - 自动竞态处理(仅应用最后一次请求的结果)
|
||||||
|
// - 自动错误日志记录
|
||||||
|
// - RequestErrorType → 用户可读错误信息映射
|
||||||
|
// - 为 use-api.ts 提供基础设施
|
||||||
|
//
|
||||||
|
// 用法:
|
||||||
|
// const { data, loading, error, refetch } = useAsyncData(
|
||||||
|
// () => TaskAPI.getTaskList(params),
|
||||||
|
// [page, pageSize],
|
||||||
|
// { enabled: isLoggedIn },
|
||||||
|
// );
|
||||||
|
//
|
||||||
|
// const { execute, loading, error } = useAsyncMutation(
|
||||||
|
// (body) => TaskAPI.submitTask(body),
|
||||||
|
// { onSuccess: () => refetchTasks() },
|
||||||
|
// );
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { RequestError, RequestErrorType } from '@/services/types';
|
||||||
|
import { logger } from '@/utils/logger';
|
||||||
|
|
||||||
|
// ---------- 错误信息映射 ----------
|
||||||
|
|
||||||
|
/** RequestErrorType → 用户可读的默认错误信息 */
|
||||||
|
export function getErrorMessage(err: unknown): string {
|
||||||
|
if (err instanceof RequestError) {
|
||||||
|
switch (err.type) {
|
||||||
|
case RequestErrorType.NETWORK:
|
||||||
|
return '网络连接失败,请检查网络';
|
||||||
|
case RequestErrorType.TIMEOUT:
|
||||||
|
return '请求超时,请稍后重试';
|
||||||
|
case RequestErrorType.HTTP:
|
||||||
|
return err.message || '服务器响应异常';
|
||||||
|
case RequestErrorType.BUSINESS:
|
||||||
|
return err.message || '业务处理失败';
|
||||||
|
case RequestErrorType.CANCELLED:
|
||||||
|
return '请求已取消';
|
||||||
|
case RequestErrorType.AUTH_EXPIRED:
|
||||||
|
return '登录已过期,请重新登录';
|
||||||
|
default:
|
||||||
|
return err.message || '未知错误';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (err instanceof Error) {
|
||||||
|
return err.message || '发生未知错误';
|
||||||
|
}
|
||||||
|
return '发生未知错误';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- useAsyncData — 数据获取 ----------
|
||||||
|
|
||||||
|
export interface UseAsyncDataOptions {
|
||||||
|
/** 是否启用请求(false 时不触发 fetch,常用于登录后才请求) */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** 请求标识(用于日志,如 'task-list'、'models') */
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseAsyncDataReturn<T> {
|
||||||
|
/** 响应数据 */
|
||||||
|
data: T | null;
|
||||||
|
/** 是否正在加载(含首次请求和后续刷新) */
|
||||||
|
loading: boolean;
|
||||||
|
/** 错误对象(RequestError 或普通 Error) */
|
||||||
|
error: Error | null;
|
||||||
|
/** 用户可读的错误信息 */
|
||||||
|
errorMessage: string | null;
|
||||||
|
/** 手动触发重新请求 */
|
||||||
|
refetch: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用数据获取 Hook。
|
||||||
|
*
|
||||||
|
* - 当 `deps` 变化或 `refetch()` 调用时自动重新请求
|
||||||
|
* - 自动处理竞态条件(仅最后一次请求的结果生效)
|
||||||
|
* - `enabled: false` 时不发起请求(适用于需登录后请求的场景)
|
||||||
|
*/
|
||||||
|
export function useAsyncData<T>(
|
||||||
|
fetcher: (signal: AbortSignal) => Promise<T>,
|
||||||
|
deps: unknown[],
|
||||||
|
options: UseAsyncDataOptions = {},
|
||||||
|
): UseAsyncDataReturn<T> {
|
||||||
|
const { enabled = true, label } = options;
|
||||||
|
|
||||||
|
const [data, setData] = useState<T | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
// 竞态计数器
|
||||||
|
const raceRef = useRef(0);
|
||||||
|
// 手动刷新触发器
|
||||||
|
const [refreshTick, setRefreshTick] = useState(0);
|
||||||
|
// 是否已挂载(避免 setState on unmounted)
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
|
||||||
|
const refetch = useCallback(() => {
|
||||||
|
setRefreshTick((t) => t + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
return () => { mountedRef.current = false; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
setData(null);
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raceId = ++raceRef.current;
|
||||||
|
let cancelled = false;
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await fetcher(abortController.signal);
|
||||||
|
if (!cancelled && raceId === raceRef.current && mountedRef.current) {
|
||||||
|
setData(result);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// 请求被 AbortController 显式取消 → 静默忽略,不设 error 状态
|
||||||
|
if (abortController.signal.aborted) return;
|
||||||
|
if (!cancelled && raceId === raceRef.current && mountedRef.current) {
|
||||||
|
const normalized = err instanceof Error ? err : new Error(String(err));
|
||||||
|
setError(normalized);
|
||||||
|
// 仅在非取消、非认证过期时记录日志(取消是预期行为,认证过期由拦截器统一处理)
|
||||||
|
if (err instanceof RequestError) {
|
||||||
|
if (err.type !== RequestErrorType.CANCELLED && err.type !== RequestErrorType.AUTH_EXPIRED) {
|
||||||
|
logger.warn('request', `${label || 'fetch'} 请求失败`, normalized);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn('request', `${label || 'fetch'} 请求失败`, normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled && raceId === raceRef.current && mountedRef.current) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void run();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
abortController.abort();
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [enabled, refreshTick, ...deps]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
errorMessage: error ? getErrorMessage(error) : null,
|
||||||
|
refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- useAsyncMutation — 数据变更 ----------
|
||||||
|
|
||||||
|
export interface UseAsyncMutationOptions<T, Args extends unknown[]> {
|
||||||
|
/** 请求标识(用于日志) */
|
||||||
|
label?: string;
|
||||||
|
/** 成功回调 */
|
||||||
|
onSuccess?: (data: T, ...args: Args) => void;
|
||||||
|
/** 失败回调 */
|
||||||
|
onError?: (err: Error, ...args: Args) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseAsyncMutationReturn<T, Args extends unknown[]> {
|
||||||
|
/** 执行变更 */
|
||||||
|
execute: (...args: Args) => Promise<T | undefined>;
|
||||||
|
/** 是否正在执行 */
|
||||||
|
loading: boolean;
|
||||||
|
/** 最近一次错误 */
|
||||||
|
error: Error | null;
|
||||||
|
/** 用户可读的错误信息 */
|
||||||
|
errorMessage: string | null;
|
||||||
|
/** 清除错误状态 */
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用数据变更 Hook(POST / PUT / DELETE 等)。
|
||||||
|
*
|
||||||
|
* - 不自动执行,需手动调用 `execute()`
|
||||||
|
* - 返回 Promise,调用方可 await 获取结果
|
||||||
|
* - 自动错误日志记录
|
||||||
|
*/
|
||||||
|
export function useAsyncMutation<T, Args extends unknown[] = []>(
|
||||||
|
mutator: (...args: Args) => Promise<T>,
|
||||||
|
options: UseAsyncMutationOptions<T, Args> = {},
|
||||||
|
): UseAsyncMutationReturn<T, Args> {
|
||||||
|
const { label, onSuccess, onError } = options;
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
return () => { mountedRef.current = false; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const execute = useCallback(
|
||||||
|
async (...args: Args): Promise<T | undefined> => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await mutator(...args);
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
onSuccess?.(result, ...args);
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
const normalized = err instanceof Error ? err : new Error(String(err));
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setError(normalized);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
if (err instanceof RequestError) {
|
||||||
|
if (err.type !== RequestErrorType.CANCELLED && err.type !== RequestErrorType.AUTH_EXPIRED) {
|
||||||
|
logger.warn('request', `${label || 'mutation'} 执行失败`, normalized);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn('request', `${label || 'mutation'} 执行失败`, normalized);
|
||||||
|
}
|
||||||
|
onError?.(normalized, ...args);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mutator, label, onSuccess, onError],
|
||||||
|
);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
execute,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
errorMessage: error ? getErrorMessage(error) : null,
|
||||||
|
reset,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// useTheme — 亮/暗主题切换 Hook
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { createContext, useContext } from 'react';
|
|
||||||
|
|
||||||
// ---------- Context 类型 ----------
|
|
||||||
|
|
||||||
export interface ThemeContextValue {
|
|
||||||
isDark: boolean;
|
|
||||||
toggleTheme: () => void;
|
|
||||||
setLight: () => void;
|
|
||||||
setDark: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ThemeContext = createContext<ThemeContextValue | null>(null);
|
|
||||||
|
|
||||||
// ---------- 工具函数 ----------
|
|
||||||
|
|
||||||
export const STORAGE_KEY = 'ele-heixiu-theme';
|
|
||||||
|
|
||||||
export function readStoredTheme(): boolean {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (stored === 'dark') return true;
|
|
||||||
if (stored === 'light') return false;
|
|
||||||
} catch {
|
|
||||||
/* 静默忽略 */
|
|
||||||
}
|
|
||||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
|
||||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function writeStoredTheme(isDark: boolean): void {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEY, isDark ? 'dark' : 'light');
|
|
||||||
} catch {
|
|
||||||
/* 静默忽略 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyHtmlTheme(isDark: boolean): void {
|
|
||||||
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
|
||||||
if (isDark) {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Consumer Hook ----------
|
|
||||||
|
|
||||||
export function useTheme(): ThemeContextValue {
|
|
||||||
const ctx = useContext(ThemeContext);
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error('useTheme() 必须在 <ThemeProvider> 内部调用');
|
|
||||||
}
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
62
src/main.tsx
62
src/main.tsx
@@ -1,53 +1,53 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { App as AntdApp } from 'antd';
|
import {App as AntdApp} from 'antd';
|
||||||
|
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import { ThemeProvider } from './components/ThemeProvider';
|
import {ThemeProvider} from './components/providers/ThemeProvider';
|
||||||
import { AppProvider } from './components/AppProvider';
|
import {AppProvider} from './components/providers/AppProvider';
|
||||||
import { SettingsProvider } from './components/SettingsProvider';
|
import {SettingsProvider} from './components/providers/SettingsProvider';
|
||||||
// 全局样式(包含 Tailwind 指令 + 主题扩展 + 重置)
|
// 全局样式(包含 Tailwind 指令 + 主题扩展 + 重置)
|
||||||
import './theme/globals.css';
|
import './theme/globals.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
{/* AppProvider — 全局应用状态(平台/版本/登录态) */}
|
{/* AppProvider — 全局应用状态(平台/版本/登录态) */}
|
||||||
<AppProvider>
|
<AppProvider>
|
||||||
{/* ThemeProvider 内部管理 ConfigProvider(亮色/暗色切换) */}
|
{/* ThemeProvider 内部管理 ConfigProvider(亮色/暗色切换) */}
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
{/* SettingsProvider — 集中管理设置项(存储路径等) */}
|
{/* SettingsProvider — 集中管理设置项(存储路径等) */}
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
{/* AntdApp 提供 message / notification / modal 的静态方法上下文 */}
|
{/* AntdApp 提供 message / notification / modal 的静态方法上下文 */}
|
||||||
<AntdApp>
|
<AntdApp>
|
||||||
<App />
|
<App />
|
||||||
</AntdApp>
|
</AntdApp>
|
||||||
</SettingsProvider>
|
</SettingsProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</AppProvider>
|
</AppProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 仅在 Electron 环境中监听主进程消息(浏览器预览时跳过)
|
// 仅在 Electron 环境中监听主进程消息(浏览器预览时跳过)
|
||||||
import { safeIpcOn } from './utils/ipc';
|
import { safeIpcOn } from './utils/ipc';
|
||||||
safeIpcOn('main-process-message', (_event: unknown, message: unknown) => {
|
safeIpcOn('main-process-message', (_event: unknown, message: unknown) => {
|
||||||
console.log('[Main Process Message]:', message);
|
console.log('[Main Process Message]:', message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 渲染进程全局未捕获错误(兜底记录到日志文件)
|
// 渲染进程全局未捕获错误(兜底记录到日志文件)
|
||||||
import { logger } from './utils/logger';
|
import {logger} from './utils/logger';
|
||||||
|
|
||||||
window.addEventListener('error', (event) => {
|
window.addEventListener('error', (event) => {
|
||||||
logger.error('ui', 'Uncaught error (renderer)', event.error, {
|
logger.error('ui', 'Uncaught error (renderer)', event.error, {
|
||||||
filename: event.filename,
|
filename: event.filename,
|
||||||
lineno: event.lineno,
|
lineno: event.lineno,
|
||||||
colno: event.colno,
|
colno: event.colno,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('unhandledrejection', (event) => {
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
const error =
|
const error =
|
||||||
event.reason instanceof Error
|
event.reason instanceof Error
|
||||||
? event.reason
|
? event.reason
|
||||||
: new Error(String(event.reason));
|
: new Error(String(event.reason));
|
||||||
logger.error('ui', 'Unhandled promise rejection (renderer)', error);
|
logger.error('ui', 'Unhandled promise rejection (renderer)', error);
|
||||||
});
|
});
|
||||||
|
|||||||
189
src/pages/auth/components/ChangePasswordModal.tsx
Normal file
189
src/pages/auth/components/ChangePasswordModal.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 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 '@/components/providers/ThemeProvider';
|
||||||
|
import { AuthAPI } from '@/services/modules/auth';
|
||||||
|
import { validatePassword, validateConfirmPassword, runFieldChecks } 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 () => {
|
||||||
|
if (runFieldChecks(setErrors,
|
||||||
|
{ field: 'currentPassword', error: !currentPassword ? '请输入当前密码' : null },
|
||||||
|
{ field: 'newPassword', error: validatePassword(newPassword) },
|
||||||
|
{ field: 'confirmPassword', error: validateConfirmPassword(confirmPassword, newPassword) },
|
||||||
|
)) return;
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
255
src/pages/auth/components/ForgotPasswordModal.tsx
Normal file
255
src/pages/auth/components/ForgotPasswordModal.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 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 '@/components/providers/ThemeProvider';
|
||||||
|
import { AuthAPI } from '@/services/modules/auth';
|
||||||
|
import {
|
||||||
|
validatePhone,
|
||||||
|
validateSmsCode,
|
||||||
|
validatePassword,
|
||||||
|
validateConfirmPassword,
|
||||||
|
runFieldChecks,
|
||||||
|
} 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 () => {
|
||||||
|
if (runFieldChecks(setErrors,
|
||||||
|
{ field: 'phone', error: validatePhone(phone) },
|
||||||
|
{ field: 'smsCode', error: validateSmsCode(smsCode) },
|
||||||
|
{ field: 'newPassword', error: validatePassword(newPassword) },
|
||||||
|
{ field: 'confirmPassword', error: validateConfirmPassword(confirmPassword, newPassword) },
|
||||||
|
)) return;
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,11 +4,11 @@
|
|||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { Input, Button, Checkbox, Typography } from 'antd';
|
import { Input, Button, Checkbox, Typography } from 'antd';
|
||||||
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
import { UserOutlined, LockOutlined, EyeTwoTone, EyeInvisibleOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { loginFields } from '../config/login-fields';
|
import { loginFields } from '../config/login-fields';
|
||||||
import { validateForm } from '../config/validators';
|
import { validateForm } from '../config/validators';
|
||||||
import { LegalTextModal } from '@/components/common/LegalTextModal';
|
import { LegalTextModal } from '@/components/ui/LegalTextModal';
|
||||||
import type { LoginFormValues } from '../types';
|
import type { LoginFormValues } from '../types';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
@@ -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,12 +34,13 @@ 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 || '');
|
||||||
const [remember, setRemember] = useState(initialRemember);
|
const [remember, setRemember] = useState(initialRemember);
|
||||||
const [autoLogin, setAutoLogin] = useState(initialAutoLogin);
|
const [autoLogin, setAutoLogin] = useState(initialAutoLogin);
|
||||||
const [agreed, setAgreed] = useState(false);
|
const [agreed, setAgreed] = useState(true);
|
||||||
const [errors, setErrors] = useState<Record<string, string[]>>({});
|
const [errors, setErrors] = useState<Record<string, string[]>>({});
|
||||||
|
|
||||||
// 协议弹窗
|
// 协议弹窗
|
||||||
@@ -101,6 +103,8 @@ export function LoginForm({
|
|||||||
setPassword(e.target.value);
|
setPassword(e.target.value);
|
||||||
clearError('password');
|
clearError('password');
|
||||||
}}
|
}}
|
||||||
|
iconRender={(visible) => (visible ? <EyeTwoTone /> : <EyeInvisibleOutlined />)}
|
||||||
|
onPressEnter={handleSubmit}
|
||||||
status={errors.password ? 'error' : undefined}
|
status={errors.password ? 'error' : undefined}
|
||||||
allowClear
|
allowClear
|
||||||
/>
|
/>
|
||||||
@@ -147,12 +151,7 @@ export function LoginForm({
|
|||||||
自动登录
|
自动登录
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a className="text-xs" onClick={() => onForgotPassword?.()}>
|
||||||
className="text-xs"
|
|
||||||
onClick={() => {
|
|
||||||
/* TODO: 忘记密码流程 */
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
忘记密码?
|
忘记密码?
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -165,6 +164,11 @@ export function LoginForm({
|
|||||||
setAgreed(e.target.checked);
|
setAgreed(e.target.checked);
|
||||||
clearError('_agreement');
|
clearError('_agreement');
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={(event)=>{
|
||||||
|
if (event.key === 'Enter' || event.keyCode === 13) {
|
||||||
|
setAgreed(!agreed);
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
已阅读并同意
|
已阅读并同意
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
// RegisterForm — 注册表单(仅个人版)
|
// RegisterForm — 注册表单(仅个人版)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useState, useCallback, useRef } from 'react';
|
import React, { useState, useCallback, useRef } from 'react';
|
||||||
import { Input, Button, Checkbox, Typography } from 'antd';
|
import { Input, Button, Checkbox, Typography } from 'antd';
|
||||||
|
|
||||||
import { LegalTextModal } from '@/components/common/LegalTextModal';
|
import { LegalTextModal } from '@/components/ui/LegalTextModal';
|
||||||
import {
|
import {
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
LockOutlined,
|
LockOutlined,
|
||||||
@@ -87,17 +87,16 @@ export function RegisterForm({ submitting = false, onSubmit, onSendSms }: Regist
|
|||||||
onSendSms?.(phone);
|
onSendSms?.(phone);
|
||||||
}, [values.phone, onSendSms]);
|
}, [values.phone, onSendSms]);
|
||||||
|
|
||||||
// 跨字段校验
|
|
||||||
const crossValidators = {
|
|
||||||
confirmPassword: (val: string) => {
|
|
||||||
if (!val) return '请再次输入密码';
|
|
||||||
if (val !== values.password) return '两次密码不一致';
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// 提交
|
// 提交
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
|
// 跨字段校验(内联避免每次渲染创建新引用致使用 useCallback 失效)
|
||||||
|
const crossValidators = {
|
||||||
|
confirmPassword: (val: string) => {
|
||||||
|
if (!val) return '请再次输入密码';
|
||||||
|
if (val !== values.password) return '两次密码不一致';
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
};
|
||||||
const fieldErrors = validateForm(values, registerFields, crossValidators);
|
const fieldErrors = validateForm(values, registerFields, crossValidators);
|
||||||
if (Object.keys(fieldErrors).length > 0) {
|
if (Object.keys(fieldErrors).length > 0) {
|
||||||
setErrors(fieldErrors);
|
setErrors(fieldErrors);
|
||||||
@@ -208,6 +207,11 @@ export function RegisterForm({ submitting = false, onSubmit, onSendSms }: Regist
|
|||||||
return n;
|
return n;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={(e)=>{
|
||||||
|
if(e.key==="Enter" || e.keyCode == 13) {
|
||||||
|
setAgreed(!agreed);
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
已阅读并同意
|
已阅读并同意
|
||||||
@@ -102,6 +102,32 @@ export function validateField(value: string, rules: import('../types').FieldRule
|
|||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 逐字段运行校验器,有错误则设置并返回 true。
|
||||||
|
*
|
||||||
|
* 用法(在 handleSubmit 中):
|
||||||
|
* if (runFieldChecks(setErrors,
|
||||||
|
* { field: 'phone', error: validatePhone(phone) },
|
||||||
|
* { field: 'pwd', error: validatePassword(pwd) },
|
||||||
|
* { field: 'confirm', error: validateConfirmPassword(confirm, pwd) },
|
||||||
|
* )) return;
|
||||||
|
*/
|
||||||
|
export function runFieldChecks(
|
||||||
|
setErrors: (errors: Record<string, string>) => void,
|
||||||
|
...checks: Array<{ field: string; error: string | null }>
|
||||||
|
): boolean {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
for (const { field, error } of checks) {
|
||||||
|
if (error) newErrors[field] = error;
|
||||||
|
}
|
||||||
|
if (Object.keys(newErrors).length > 0) {
|
||||||
|
setErrors(newErrors);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
setErrors({});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验整个表单
|
* 校验整个表单
|
||||||
* @returns 错误映射 { fieldName: errorMsg[] },空对象表示全部通过
|
* @returns 错误映射 { fieldName: errorMsg[] },空对象表示全部通过
|
||||||
73
src/pages/auth/hooks/use-auth-state.ts
Normal file
73
src/pages/auth/hooks/use-auth-state.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// ============================================================
|
||||||
|
// useAuthState — 记住账号 / 自动登录 表单状态 Hook
|
||||||
|
//
|
||||||
|
// "记住密码":下次打开页面自动回填用户名(不再存储密码)
|
||||||
|
// "自动登录":登录成功时标记,AppProvider 启动时用 refresh_token 静默续期
|
||||||
|
//
|
||||||
|
// 持久化逻辑已提取到 services/auth-persistence.ts,
|
||||||
|
// 此处仅负责 React 组件状态(useState + useCallback)。
|
||||||
|
//
|
||||||
|
// 安全:
|
||||||
|
// - 密码从不持久化存储(access_token / refresh_token 由 safeStorage 加密保护)
|
||||||
|
// - 安全性由 refresh_token + access_token 的 JWT 双重机制 + OS 密钥链保障
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
saveAuth,
|
||||||
|
clearSavedAuth,
|
||||||
|
getSavedUsername,
|
||||||
|
getSavedRemember,
|
||||||
|
getSavedAutoLogin,
|
||||||
|
} from '@/services/auth-persistence';
|
||||||
|
|
||||||
|
export { getAutoLoginFlag, clearAutoLoginFlag, clearSavedAuth } from '@/services/auth-persistence';
|
||||||
|
|
||||||
|
// ---------- Hook ----------
|
||||||
|
|
||||||
|
export function useAuthState() {
|
||||||
|
const [username, setUsername] = useState(getSavedUsername);
|
||||||
|
const [remember, setRemember] = useState(getSavedRemember);
|
||||||
|
const [autoLogin, setAutoLogin] = useState(getSavedAutoLogin);
|
||||||
|
|
||||||
|
const handleRememberChange = useCallback((checked: boolean) => {
|
||||||
|
setRemember(checked);
|
||||||
|
if (!checked) {
|
||||||
|
setAutoLogin(false);
|
||||||
|
clearSavedAuth();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAutoLoginChange = useCallback((checked: boolean) => {
|
||||||
|
setAutoLogin(checked);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录成功后调用。
|
||||||
|
* 参数由调用方(LoginPage)直接从表单值传入,避免 useCallback 的 stale closure。
|
||||||
|
*
|
||||||
|
* @param user - 用户名
|
||||||
|
* @param _password - 明文密码(不再持久化存储,保留参数以兼容调用方)
|
||||||
|
* @param doRemember - 是否勾选"记住用户名"
|
||||||
|
* @param doAutoLogin - 是否勾选"自动登录"
|
||||||
|
*/
|
||||||
|
const handleSaveAuth = useCallback(
|
||||||
|
(user: string, _password: string, doRemember: boolean, doAutoLogin: boolean) => {
|
||||||
|
setUsername(user);
|
||||||
|
if (doRemember || doAutoLogin) {
|
||||||
|
saveAuth(user, doRemember, doAutoLogin);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
username,
|
||||||
|
remember,
|
||||||
|
autoLogin,
|
||||||
|
setUsername,
|
||||||
|
handleRememberChange,
|
||||||
|
handleAutoLoginChange,
|
||||||
|
saveAuth: handleSaveAuth,
|
||||||
|
};
|
||||||
|
}
|
||||||
57
src/pages/auth/hooks/use-sms-cooldown.ts
Normal file
57
src/pages/auth/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 };
|
||||||
|
}
|
||||||
@@ -10,12 +10,13 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { Modal, Tabs, Typography, App } from 'antd';
|
import { Modal, Tabs, Typography, App } from 'antd';
|
||||||
|
|
||||||
import { useAppContext } from '@/contexts/app-context.ts';
|
import { useAppContext } from '@/components/providers/AppProvider';
|
||||||
import { useTheme } from '@/hooks/use-theme.ts';
|
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||||
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, type LoginResponseBody } from '@/services/modules/auth';
|
||||||
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(),
|
||||||
@@ -111,13 +112,13 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
|
|||||||
const loginTab = (
|
const loginTab = (
|
||||||
<LoginForm
|
<LoginForm
|
||||||
initialUsername={authState.username}
|
initialUsername={authState.username}
|
||||||
|
|
||||||
initialRemember={authState.remember}
|
initialRemember={authState.remember}
|
||||||
initialAutoLogin={authState.autoLogin}
|
initialAutoLogin={authState.autoLogin}
|
||||||
submitting={submitting}
|
submitting={submitting}
|
||||||
onSubmit={handleLogin}
|
onSubmit={handleLogin}
|
||||||
onRememberChange={authState.handleRememberChange}
|
onRememberChange={authState.handleRememberChange}
|
||||||
onAutoLoginChange={authState.handleAutoLoginChange}
|
onAutoLoginChange={authState.handleAutoLoginChange}
|
||||||
|
onForgotPassword={() => setForgotOpen(true)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -126,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}
|
||||||
@@ -167,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)} />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
327
src/pages/home/HomeContent.tsx
Normal file
327
src/pages/home/HomeContent.tsx
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
// ============================================================
|
||||||
|
// HomeContent — 首页三列正文布局 + HomeProvider(Context + Provider + Hook)
|
||||||
|
// 可拖拽调整三栏宽度,所有数据请求在登录后发送
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||||
|
import { theme as antTheme, Empty } from 'antd';
|
||||||
|
|
||||||
|
import { useAppContext } from '@/components/providers/AppProvider';
|
||||||
|
import type { ModelsListResponseBody } from '@/services/modules/models';
|
||||||
|
import type { TaskInfoItemResponseBody } from '@/services/modules/task';
|
||||||
|
import { LeftPanel } from './panels/LeftPanel';
|
||||||
|
import { CenterPanel } from './panels/CenterPanel';
|
||||||
|
import { RightPanel } from './panels/RightPanel';
|
||||||
|
|
||||||
|
import { Typography } from 'antd';
|
||||||
|
|
||||||
|
import { HomeContext } from './HomeContext';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
// ---------- 常量 ----------
|
||||||
|
|
||||||
|
/** 左列最小/默认宽度 */
|
||||||
|
const LEFT_MIN = 400;
|
||||||
|
const LEFT_DEFAULT = 750;
|
||||||
|
/** 右列最小/默认宽度 */
|
||||||
|
const RIGHT_MIN = 300;
|
||||||
|
const RIGHT_DEFAULT = 480;
|
||||||
|
/** 中列最小宽度 */
|
||||||
|
const CENTER_MIN = 300;
|
||||||
|
/** 拖拽分隔条宽度 */
|
||||||
|
const DIVIDER_WIDTH = 6;
|
||||||
|
/** 布局固定消耗:左右 padding(12*2) + 两个分隔条(6*2) */
|
||||||
|
const LAYOUT_OVERHEAD = 36; // 24 + 12
|
||||||
|
|
||||||
|
// ---------- 组件 ----------
|
||||||
|
|
||||||
|
export function HomeContent() {
|
||||||
|
const { token } = antTheme.useToken();
|
||||||
|
const { isLoggedIn } = useAppContext();
|
||||||
|
|
||||||
|
// ======== HomeContext 状态 ========
|
||||||
|
|
||||||
|
const [selectedModel, setSelectedModel] = useState<ModelsListResponseBody | null>(null);
|
||||||
|
const [selectedTask, setSelectedTask] = useState<TaskInfoItemResponseBody | null>(null);
|
||||||
|
const [taskPage, setTaskPage] = useState(1);
|
||||||
|
const [taskPageSize, setTaskPageSize] = useState(10);
|
||||||
|
const [isLeftCollapsed, setIsLeftCollapsed] = useState(false);
|
||||||
|
|
||||||
|
const toggleLeftCollapsed = useCallback(() => {
|
||||||
|
setIsLeftCollapsed((v) => !v);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSetSelectedModel = useCallback(
|
||||||
|
(model: ModelsListResponseBody | null) => {
|
||||||
|
setSelectedModel(model);
|
||||||
|
setSelectedTask(null);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSetSelectedTask = useCallback(
|
||||||
|
(task: TaskInfoItemResponseBody | null) => {
|
||||||
|
setSelectedTask(task);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const contextValue = useMemo(() => ({
|
||||||
|
selectedModel,
|
||||||
|
setSelectedModel: handleSetSelectedModel,
|
||||||
|
taskPage,
|
||||||
|
taskPageSize,
|
||||||
|
setTaskPage,
|
||||||
|
setTaskPageSize,
|
||||||
|
selectedTask,
|
||||||
|
setSelectedTask: handleSetSelectedTask,
|
||||||
|
isLeftCollapsed,
|
||||||
|
toggleLeftCollapsed,
|
||||||
|
}), [
|
||||||
|
selectedModel,
|
||||||
|
handleSetSelectedModel,
|
||||||
|
taskPage,
|
||||||
|
taskPageSize,
|
||||||
|
selectedTask,
|
||||||
|
handleSetSelectedTask,
|
||||||
|
isLeftCollapsed,
|
||||||
|
toggleLeftCollapsed,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ======== 拖拽调整列宽 ========
|
||||||
|
|
||||||
|
const [leftWidth, setLeftWidth] = useState(LEFT_DEFAULT);
|
||||||
|
const [rightWidth, setRightWidth] = useState(RIGHT_DEFAULT);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 拖拽状态
|
||||||
|
const [dragging, setDragging] = useState<'left' | 'right' | null>(null);
|
||||||
|
// 分隔条 hover 状态
|
||||||
|
const [hoveredDivider, setHoveredDivider] = useState<'left' | 'right' | null>(null);
|
||||||
|
const dragState = useRef<{
|
||||||
|
startX: number;
|
||||||
|
startWidth: number;
|
||||||
|
}>({ startX: 0, startWidth: 0 });
|
||||||
|
|
||||||
|
// 用 ref 追踪最新列宽,避免拖拽 mousemove 闭包中读到过期值
|
||||||
|
const leftWidthRef = useRef(leftWidth);
|
||||||
|
const rightWidthRef = useRef(rightWidth);
|
||||||
|
const draggingRef = useRef(dragging);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
leftWidthRef.current = leftWidth;
|
||||||
|
rightWidthRef.current = rightWidth;
|
||||||
|
draggingRef.current = dragging;
|
||||||
|
}, [leftWidth, rightWidth, dragging]);
|
||||||
|
|
||||||
|
const handleDividerMouseDown = (side: 'left' | 'right') => (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
// eslint-disable-next-line react-hooks/refs
|
||||||
|
dragState.current = { startX: e.clientX, startWidth: side === 'left' ? leftWidth : rightWidth };
|
||||||
|
setDragging(side);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!dragging) return;
|
||||||
|
|
||||||
|
const containerEl = containerRef.current;
|
||||||
|
if (!containerEl) return;
|
||||||
|
const containerWidth = containerEl.clientWidth;
|
||||||
|
const ds = dragState.current;
|
||||||
|
const delta = e.clientX - ds.startX;
|
||||||
|
|
||||||
|
if (dragging === 'left') {
|
||||||
|
// 左列最大宽度 = 容器宽 - 固定消耗 - 中列最小宽 - 右列当前宽
|
||||||
|
// 确保中列至少保留 CENTER_MIN、右列不被挤出视口
|
||||||
|
const maxLeft = containerWidth - LAYOUT_OVERHEAD - CENTER_MIN - rightWidthRef.current;
|
||||||
|
const newWidth = Math.max(LEFT_MIN, Math.min(maxLeft, ds.startWidth + delta));
|
||||||
|
setLeftWidth(newWidth);
|
||||||
|
} else {
|
||||||
|
// 右列最大宽度同理:不能把中列挤到 CENTER_MIN 以下
|
||||||
|
const maxRight = containerWidth - LAYOUT_OVERHEAD - CENTER_MIN - leftWidthRef.current;
|
||||||
|
const newWidth = Math.max(RIGHT_MIN, Math.min(maxRight, ds.startWidth - delta));
|
||||||
|
setRightWidth(newWidth);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setDragging(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [dragging]);
|
||||||
|
|
||||||
|
// ======== 视口缩小自动收窄面板 ========
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
// 拖拽中由 mousemove handler 处理约束,避免冲突
|
||||||
|
if (draggingRef.current) return;
|
||||||
|
|
||||||
|
const containerWidth = el.clientWidth;
|
||||||
|
const currentLeft = leftWidthRef.current;
|
||||||
|
const currentRight = rightWidthRef.current;
|
||||||
|
|
||||||
|
// 检查当前总宽度是否超出容器
|
||||||
|
const totalNeeded = currentLeft + LAYOUT_OVERHEAD + CENTER_MIN + currentRight;
|
||||||
|
if (totalNeeded <= containerWidth) return;
|
||||||
|
|
||||||
|
// 超出容器 → 优先收缩右侧面板,再收左侧
|
||||||
|
const deficit = totalNeeded - containerWidth;
|
||||||
|
const rightSlack = currentRight - RIGHT_MIN;
|
||||||
|
|
||||||
|
if (rightSlack >= deficit) {
|
||||||
|
setRightWidth(currentRight - deficit);
|
||||||
|
} else {
|
||||||
|
setRightWidth(RIGHT_MIN);
|
||||||
|
const remainingDeficit = deficit - rightSlack;
|
||||||
|
setLeftWidth(Math.max(LEFT_MIN, currentLeft - remainingDeficit));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ======== 分隔条样式工厂 ========
|
||||||
|
|
||||||
|
const dividerStyle = (side: 'left' | 'right'): React.CSSProperties => {
|
||||||
|
const isActive = dragging === side;
|
||||||
|
const isHovered = hoveredDivider === side;
|
||||||
|
return {
|
||||||
|
width: DIVIDER_WIDTH,
|
||||||
|
flexShrink: 0,
|
||||||
|
cursor: 'col-resize',
|
||||||
|
background: isActive
|
||||||
|
? token.colorPrimary
|
||||||
|
: isHovered
|
||||||
|
? `${token.colorPrimary}60`
|
||||||
|
: token.colorBorderSecondary,
|
||||||
|
position: 'relative' as const,
|
||||||
|
zIndex: 10,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
transition: 'background 0.15s linear',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ======== 未登录提示 ========
|
||||||
|
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
return (
|
||||||
|
<HomeContext.Provider value={contextValue}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flex: 1,
|
||||||
|
background: token.colorBgLayout,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Empty
|
||||||
|
description={
|
||||||
|
<Text type="secondary">
|
||||||
|
请先登录后再使用首页功能
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</HomeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== 三列布局(可拖拽) ========
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HomeContext.Provider value={contextValue}>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
padding: 12,
|
||||||
|
gap: 0,
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
background: token.colorBgLayout,
|
||||||
|
cursor: !!dragging ? 'col-resize' : undefined,
|
||||||
|
userSelect: !!dragging ? 'none' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 左列 */}
|
||||||
|
{!isLeftCollapsed && (
|
||||||
|
<>
|
||||||
|
<div style={{ width: leftWidth, overflow: 'hidden', flexShrink: 0 }}>
|
||||||
|
<LeftPanel />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 左分隔条 */}
|
||||||
|
<div
|
||||||
|
onMouseDown={handleDividerMouseDown('left')}
|
||||||
|
style={dividerStyle('left')}
|
||||||
|
onMouseEnter={() => setHoveredDivider('left')}
|
||||||
|
onMouseLeave={() => setHoveredDivider(null)}
|
||||||
|
>
|
||||||
|
{/* 拖拽手柄竖线 */}
|
||||||
|
<div style={{
|
||||||
|
width: 2,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 1,
|
||||||
|
background: dragging === 'left'
|
||||||
|
? '#fff'
|
||||||
|
: hoveredDivider === 'left'
|
||||||
|
? token.colorPrimary
|
||||||
|
: token.colorBorder,
|
||||||
|
transition: 'background 0.15s linear',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 中列 */}
|
||||||
|
<div style={{ flex: 1, overflow: 'hidden', minWidth: CENTER_MIN }}>
|
||||||
|
<CenterPanel />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右分隔条 */}
|
||||||
|
<div
|
||||||
|
onMouseDown={handleDividerMouseDown('right')}
|
||||||
|
style={dividerStyle('right')}
|
||||||
|
onMouseEnter={() => setHoveredDivider('right')}
|
||||||
|
onMouseLeave={() => setHoveredDivider(null)}
|
||||||
|
>
|
||||||
|
{/* 拖拽手柄竖线 */}
|
||||||
|
<div style={{
|
||||||
|
width: 2,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 1,
|
||||||
|
background: dragging === 'right'
|
||||||
|
? '#fff'
|
||||||
|
: hoveredDivider === 'right'
|
||||||
|
? token.colorPrimary
|
||||||
|
: token.colorBorder,
|
||||||
|
transition: 'background 0.15s linear',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右列 */}
|
||||||
|
<div style={{ width: rightWidth, overflow: 'hidden', flexShrink: 0 }}>
|
||||||
|
<RightPanel />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</HomeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/pages/home/HomeContext.tsx
Normal file
43
src/pages/home/HomeContext.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// ============================================================
|
||||||
|
// HomeContext — 首页共享状态(Context + Provider + Hook)
|
||||||
|
//
|
||||||
|
// 原与 HomeContent.tsx 耦合在同一文件,现独立以便:
|
||||||
|
// - 消费者可以明确只 import 状态 Hook,不关心布局细节
|
||||||
|
// - 新增 consumer 时不需要翻阅 358 行 HomeContent
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
import type { ModelsListResponseBody } from '@/services/modules/models';
|
||||||
|
import type { TaskInfoItemResponseBody } from '@/services/modules/task';
|
||||||
|
|
||||||
|
// ---------- Context 类型 ----------
|
||||||
|
|
||||||
|
export interface HomeContextValue {
|
||||||
|
/** 当前选中的模型 */
|
||||||
|
selectedModel: ModelsListResponseBody | null;
|
||||||
|
setSelectedModel: (model: ModelsListResponseBody | null) => void;
|
||||||
|
/** 任务记录分页 — 当前页码 */
|
||||||
|
taskPage: number;
|
||||||
|
/** 任务记录分页 — 每页条数 */
|
||||||
|
taskPageSize: number;
|
||||||
|
setTaskPage: (page: number) => void;
|
||||||
|
setTaskPageSize: (size: number) => void;
|
||||||
|
/** 当前选中的任务记录(用于右侧预览) */
|
||||||
|
selectedTask: TaskInfoItemResponseBody | null;
|
||||||
|
setSelectedTask: (task: TaskInfoItemResponseBody | null) => void;
|
||||||
|
/** 侧边栏折叠状态 */
|
||||||
|
isLeftCollapsed: boolean;
|
||||||
|
toggleLeftCollapsed: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HomeContext = createContext<HomeContextValue | null>(null);
|
||||||
|
|
||||||
|
// ---------- Consumer Hook ----------
|
||||||
|
|
||||||
|
export function useHomeContext(): HomeContextValue {
|
||||||
|
const ctx = useContext(HomeContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useHomeContext() 必须在 <HomeContent> 内部调用');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -1,277 +1,14 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// HomePage — 首页仪表盘(从 App.tsx 提取)
|
// HomePage — 首页(业务入口)
|
||||||
|
// 组合:BannerCarousel(在 Layout 中)+ HomeContent(三列正文)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { HomeContent } from './HomeContent';
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Space,
|
|
||||||
Tag,
|
|
||||||
Typography,
|
|
||||||
Switch as AntSwitch,
|
|
||||||
Slider,
|
|
||||||
Progress,
|
|
||||||
theme as antTheme,
|
|
||||||
} from 'antd';
|
|
||||||
import {
|
|
||||||
ApiOutlined,
|
|
||||||
ThunderboltOutlined,
|
|
||||||
BulbOutlined,
|
|
||||||
BulbFilled,
|
|
||||||
DownloadOutlined,
|
|
||||||
SyncOutlined,
|
|
||||||
CheckCircleOutlined,
|
|
||||||
ExclamationCircleOutlined,
|
|
||||||
RocketOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
|
|
||||||
import { getPlatformName, isDev } from '@/utils/platform';
|
|
||||||
import { gradientPrimary } from '@/theme/tokens';
|
|
||||||
import { useTheme } from '@/hooks/use-theme';
|
|
||||||
import { useUpdater } from '@/hooks/use-updater';
|
|
||||||
import { useAppContext } from '@/contexts/app-context';
|
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const { isDark, toggleTheme } = useTheme();
|
|
||||||
const { platform, edition, isLoggedIn, logout } = useAppContext();
|
|
||||||
const { token } = antTheme.useToken();
|
|
||||||
const [progress, setProgress] = useState(65);
|
|
||||||
const {
|
|
||||||
status: updateStatus,
|
|
||||||
progress: updateProgress,
|
|
||||||
info: updateInfo,
|
|
||||||
error: updateError,
|
|
||||||
checkForUpdates,
|
|
||||||
installUpdate,
|
|
||||||
} = useUpdater();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 p-8">
|
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||||
<div className="mx-auto max-w-4xl space-y-6">
|
<HomeContent />
|
||||||
{/* ---- 标题区 + 主题开关 ---- */}
|
|
||||||
<div className="text-center space-y-2">
|
|
||||||
<div className="flex items-center justify-center gap-4">
|
|
||||||
<Title level={1} className="mb-0! gradient-primary-text">
|
|
||||||
HeiXiu · 桌面工作台
|
|
||||||
</Title>
|
|
||||||
<AntSwitch
|
|
||||||
checked={isDark}
|
|
||||||
onChange={toggleTheme}
|
|
||||||
checkedChildren={<BulbFilled />}
|
|
||||||
unCheckedChildren={<BulbOutlined />}
|
|
||||||
title={isDark ? '切换亮色模式' : '切换暗色模式'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Text type="secondary">
|
|
||||||
Electron + React 19 + TypeScript + Ant Design + Tailwind CSS
|
|
||||||
</Text>
|
|
||||||
<br />
|
|
||||||
<Space size={4}>
|
|
||||||
<Tag color={isDark ? 'blue' : 'purple'}>
|
|
||||||
{isDark ? '🌙 暗色' : '☀️ 亮色'}
|
|
||||||
</Tag>
|
|
||||||
<Tag color={edition === 'enterprise' ? 'gold' : 'green'}>
|
|
||||||
{edition === 'enterprise' ? '企业版' : '个人版'}
|
|
||||||
</Tag>
|
|
||||||
{isLoggedIn ? (
|
|
||||||
<>
|
|
||||||
<Tag color="blue">已登录</Tag>
|
|
||||||
<Button size="small" type="link" danger onClick={logout}>
|
|
||||||
退出登录
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Tag color="default">未登录</Tag>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ---- 导航栏状态提示 ---- */}
|
|
||||||
<Card size="small" variant="borderless" className="rounded-lg">
|
|
||||||
<Text type="secondary" className="text-xs">
|
|
||||||
💡 当前使用 <Text strong>H5 自定义导航栏</Text> 替代系统菜单。
|
|
||||||
{platform === 'darwin'
|
|
||||||
? ' macOS 保留了最小 Edit 菜单以支持 Cmd+C/V 等快捷键。'
|
|
||||||
: ' Windows/Linux 已完全移除系统菜单栏。'}
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ---- 主题色预览 ---- */}
|
|
||||||
<Card
|
|
||||||
title="🎨 蓝紫渐变主题色预览"
|
|
||||||
variant="borderless"
|
|
||||||
className="rounded-lg shadow-lg"
|
|
||||||
>
|
|
||||||
<Space orientation="vertical" size="large" className="w-full">
|
|
||||||
{/* 渐变展示 */}
|
|
||||||
<div>
|
|
||||||
<Text strong>品牌渐变</Text>
|
|
||||||
<div className="flex gap-3 mt-2">
|
|
||||||
<div
|
|
||||||
className="flex-1 h-16 rounded-lg flex items-center justify-center text-white font-medium shadow-md"
|
|
||||||
style={{ background: gradientPrimary }}
|
|
||||||
>
|
|
||||||
135° 渐变
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="flex-1 h-16 rounded-lg flex items-center justify-center text-white font-medium shadow-md"
|
|
||||||
style={{ background: 'linear-gradient(90deg, #4F46E5, #7C3AED)' }}
|
|
||||||
>
|
|
||||||
90° 渐变
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 组件示例 */}
|
|
||||||
<div>
|
|
||||||
<Text strong>Ant Design 组件</Text>
|
|
||||||
<Space wrap className="mt-2">
|
|
||||||
<Button type="primary" icon={<ThunderboltOutlined />}>
|
|
||||||
主按钮
|
|
||||||
</Button>
|
|
||||||
<Button>默认</Button>
|
|
||||||
<Button type="dashed">虚线</Button>
|
|
||||||
<Button type="text">文字</Button>
|
|
||||||
<Button type="link">链接</Button>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 滑块 + 进度条 */}
|
|
||||||
<div>
|
|
||||||
<Text strong>交互组件</Text>
|
|
||||||
<div className="flex items-center gap-4 mt-2">
|
|
||||||
<span className="text-sm" style={{ color: token.colorTextSecondary }}>
|
|
||||||
进度:
|
|
||||||
</span>
|
|
||||||
<Slider
|
|
||||||
className="flex-1"
|
|
||||||
value={progress}
|
|
||||||
onChange={setProgress}
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
/>
|
|
||||||
<Progress
|
|
||||||
type="circle"
|
|
||||||
percent={progress}
|
|
||||||
size={48}
|
|
||||||
strokeColor={{ '0%': '#4F46E5', '100%': '#7C3AED' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Space>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ---- 平台信息 ---- */}
|
|
||||||
<Card title="🖥️ 系统信息" variant="borderless" className="rounded-lg shadow-lg">
|
|
||||||
<Space orientation="vertical" size="middle" className="w-full">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ApiOutlined style={{ color: token.colorPrimary }} />
|
|
||||||
<Text strong>运行平台:</Text>
|
|
||||||
<Tag color="purple">{getPlatformName()}</Tag>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ApiOutlined style={{ color: token.colorPrimary }} />
|
|
||||||
<Text strong>运行环境:</Text>
|
|
||||||
<Tag color={isDev() ? 'blue' : 'green'}>
|
|
||||||
{isDev() ? '开发模式' : '生产模式'}
|
|
||||||
</Tag>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ApiOutlined style={{ color: token.colorPrimary }} />
|
|
||||||
<Text strong>当前导航:</Text>
|
|
||||||
<Tag color="purple">
|
|
||||||
H5 自定义导航栏({edition === 'enterprise' ? '企业版' : '个人版'}
|
|
||||||
布局)
|
|
||||||
</Tag>
|
|
||||||
</div>
|
|
||||||
</Space>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ---- 版本更新 ---- */}
|
|
||||||
<Card title="🔄 版本更新" variant="borderless" className="rounded-lg shadow-lg">
|
|
||||||
<Space orientation="vertical" size="middle" className="w-full">
|
|
||||||
{/* 状态提示 */}
|
|
||||||
{updateStatus === 'checking' && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<SyncOutlined spin style={{ color: token.colorPrimary }} />
|
|
||||||
<Text type="secondary">正在检查更新...</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{updateStatus === 'no-update' && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CheckCircleOutlined style={{ color: token.colorSuccess }} />
|
|
||||||
<Text type="success">已是最新版本</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{updateStatus === 'error' && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ExclamationCircleOutlined style={{ color: token.colorError }} />
|
|
||||||
<Text type="danger">{updateError?.message || '检查更新失败'}</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 发现新版本 */}
|
|
||||||
{(updateStatus === 'available' || updateStatus === 'downloading') && (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<RocketOutlined style={{ color: token.colorPrimary }} />
|
|
||||||
<Text strong>发现新版本:{updateInfo?.version}</Text>
|
|
||||||
{updateInfo?.forceUpdate && <Tag color="red">强制更新</Tag>}
|
|
||||||
</div>
|
|
||||||
{updateInfo?.releaseNotes && (
|
|
||||||
<Text type="secondary" className="text-xs whitespace-pre-line">
|
|
||||||
{updateInfo.releaseNotes}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 下载进度 */}
|
|
||||||
{updateStatus === 'downloading' && (
|
|
||||||
<Progress
|
|
||||||
percent={updateProgress.percent}
|
|
||||||
strokeColor={{ '0%': '#4F46E5', '100%': '#7C3AED' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 下载完成 */}
|
|
||||||
{updateStatus === 'downloaded' && (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CheckCircleOutlined style={{ color: token.colorSuccess }} />
|
|
||||||
<Text>更新已下载完毕,点击按钮安装并重启</Text>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={<DownloadOutlined />}
|
|
||||||
onClick={installUpdate}
|
|
||||||
>
|
|
||||||
安装更新
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Button
|
|
||||||
icon={<SyncOutlined spin={updateStatus === 'checking'} />}
|
|
||||||
onClick={checkForUpdates}
|
|
||||||
disabled={updateStatus === 'checking'}
|
|
||||||
>
|
|
||||||
检查更新
|
|
||||||
</Button>
|
|
||||||
{isDev() && (
|
|
||||||
<Text type="secondary" className="text-xs">
|
|
||||||
(生产环境启动 5 秒后自动检查)
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Space>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
47
src/pages/home/controls/AudioListInput.tsx
Normal file
47
src/pages/home/controls/AudioListInput.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// ============================================================
|
||||||
|
// AudioListInput — audioList → Dragger(音频文件拖拽上传)
|
||||||
|
// 用于声纹克隆的音频样本上传场景
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { Upload } from 'antd';
|
||||||
|
import { InboxOutlined } from '@ant-design/icons';
|
||||||
|
import type { UploadFile } from 'antd/es/upload';
|
||||||
|
import type { WidgetProps } from './types';
|
||||||
|
import { useFileUpload } from '@/hooks/use-api';
|
||||||
|
|
||||||
|
const { Dragger } = Upload;
|
||||||
|
|
||||||
|
export function AudioListInput({ value, onChange, disabled, config }: WidgetProps) {
|
||||||
|
const fileList = (value as UploadFile[]) || [];
|
||||||
|
const maxSizeMb = (config.maxFileSizeMb as number) || 10;
|
||||||
|
const maxCount = (config.maxFiles as number) || 10;
|
||||||
|
const filters = config.fileFilter as string[] | undefined;
|
||||||
|
|
||||||
|
const { customRequest } = useFileUpload();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dragger
|
||||||
|
multiple
|
||||||
|
accept={
|
||||||
|
filters
|
||||||
|
? filters.map((ext) => `.${ext}`).join(',')
|
||||||
|
: '.wav,.mp3,.m4a,.flac'
|
||||||
|
}
|
||||||
|
fileList={fileList}
|
||||||
|
customRequest={customRequest}
|
||||||
|
onChange={({ fileList: newList }) => onChange?.(newList)}
|
||||||
|
disabled={disabled}
|
||||||
|
beforeUpload={() => true}
|
||||||
|
maxCount={maxCount}
|
||||||
|
>
|
||||||
|
<p className="ant-upload-drag-icon">
|
||||||
|
<InboxOutlined />
|
||||||
|
</p>
|
||||||
|
<p className="ant-upload-text">点击或拖拽音频文件到此区域上传</p>
|
||||||
|
<p className="ant-upload-hint">
|
||||||
|
支持 {filters?.join(' / ').toUpperCase() || 'WAV / MP3 / M4A / FLAC'}
|
||||||
|
,单文件不超过 {maxSizeMb}MB
|
||||||
|
</p>
|
||||||
|
</Dragger>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/pages/home/controls/Checkbox.tsx
Normal file
17
src/pages/home/controls/Checkbox.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// ============================================================
|
||||||
|
// Checkbox — checkbox → Switch
|
||||||
|
// 用于布尔开关:原始模式、平铺、Base64 输出等
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { Switch } from 'antd';
|
||||||
|
import type { WidgetProps } from './types';
|
||||||
|
|
||||||
|
export function Checkbox({ value, onChange, disabled }: WidgetProps) {
|
||||||
|
return (
|
||||||
|
<Switch
|
||||||
|
checked={value as boolean}
|
||||||
|
onChange={(v) => onChange?.(v)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/pages/home/controls/ComboBox.tsx
Normal file
24
src/pages/home/controls/ComboBox.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ComboBox — comboBox → Select
|
||||||
|
// 用于枚举选择:宽高比、音色、分辨率、模型等
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { Select } from 'antd';
|
||||||
|
import type { WidgetProps } from './types';
|
||||||
|
|
||||||
|
export function ComboBox({ value, onChange, disabled, config }: WidgetProps) {
|
||||||
|
const enumValues = config.enumValues as string[] | undefined;
|
||||||
|
const options = enumValues?.map((v) => ({ label: v, value: v })) || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={value as string}
|
||||||
|
onChange={(v) => onChange?.(v)}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={(config.placeholder as string) || '请选择'}
|
||||||
|
options={options}
|
||||||
|
allowClear={!config.required}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/pages/home/controls/DoubleSpinBox.tsx
Normal file
23
src/pages/home/controls/DoubleSpinBox.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// ============================================================
|
||||||
|
// DoubleSpinBox — doubleSpinBox → InputNumber (step=config.step)
|
||||||
|
// 用于小数输入:语速、音量、相似度等
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { InputNumber } from 'antd';
|
||||||
|
import type { WidgetProps } from './types';
|
||||||
|
|
||||||
|
export function DoubleSpinBox({ value, onChange, disabled, config }: WidgetProps) {
|
||||||
|
return (
|
||||||
|
<InputNumber
|
||||||
|
value={value as number}
|
||||||
|
onChange={(v) => onChange?.(v != null ? v : undefined)}
|
||||||
|
disabled={disabled}
|
||||||
|
min={config.minValue as number | undefined}
|
||||||
|
max={config.maxValue as number | undefined}
|
||||||
|
step={config.step as number || 0.1}
|
||||||
|
style={{ width: '100%', minWidth: 140 }}
|
||||||
|
placeholder={(config.placeholder as string) || undefined}
|
||||||
|
controls
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
src/pages/home/controls/ImageInput.tsx
Normal file
119
src/pages/home/controls/ImageInput.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ImageInput — imageInput → Upload(单张图片)
|
||||||
|
// 用于垫图、参考图等单图上传场景
|
||||||
|
//
|
||||||
|
// enable_switch 支持:
|
||||||
|
// 当 API ui.enable_switch === true 时,默认禁用(灰色),
|
||||||
|
// 需点击 Switch 开关后才能上传——匹配原 Qt 客户端行为。
|
||||||
|
// 开关关闭时清除已上传文件。
|
||||||
|
//
|
||||||
|
// 表单值语义(配合 constraints_config.requires 依赖禁用):
|
||||||
|
// undefined → 开关关闭 / 未启用 → isValueEmpty = true → 依赖字段禁用
|
||||||
|
// [] → 开关开启但未上传文件 → isValueEmpty = false → 依赖字段启用
|
||||||
|
// [{...}] → 已上传文件 → isValueEmpty = false → 依赖字段启用
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { Upload, Switch, Space, Typography, message } from 'antd';
|
||||||
|
import { UploadOutlined } from '@ant-design/icons';
|
||||||
|
import type { UploadFile, RcFile } from 'antd/es/upload';
|
||||||
|
import type { WidgetProps } from './types';
|
||||||
|
import { useFileUpload } from '@/hooks/use-api';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建图片上传前的类型 & 大小校验函数(ImageInput / ImageListInput 共用)
|
||||||
|
* @param maxSizeMb 最大文件大小(MB),默认 10
|
||||||
|
*/
|
||||||
|
export function createImageBeforeUpload(maxSizeMb: number = 10) {
|
||||||
|
return (file: RcFile) => {
|
||||||
|
const isImage = file.type.startsWith('image/');
|
||||||
|
if (!isImage) {
|
||||||
|
message.error('只能上传图片文件');
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}
|
||||||
|
if (file.size / 1024 / 1024 >= maxSizeMb) {
|
||||||
|
message.error(`图片大小不能超过 ${maxSizeMb}MB`);
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建图片 accept 字符串 */
|
||||||
|
function buildAccept(filters?: string[]): string {
|
||||||
|
if (!filters || filters.length === 0) return 'image/png,image/jpg,image/jpeg,image/webp';
|
||||||
|
return filters.map((ext) => `image/${ext}`).join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标记"已启用但空"的空数组,避免 react 渲染时引用变化 */
|
||||||
|
const ENABLED_EMPTY: UploadFile[] = [];
|
||||||
|
|
||||||
|
export function ImageInput({ value, onChange, disabled, config }: WidgetProps) {
|
||||||
|
const fileList = (value as UploadFile[]) || [];
|
||||||
|
const maxSizeMb = (config.maxFileSizeMb as number) || 10;
|
||||||
|
const hasEnableSwitch = (config.enableSwitch as boolean) || false;
|
||||||
|
|
||||||
|
// 文件上传 Hook(提供 customRequest → antd Upload 集成)
|
||||||
|
const { customRequest } = useFileUpload();
|
||||||
|
|
||||||
|
// 有 enable_switch 时默认禁用,需手动开启
|
||||||
|
const [switchOn, setSwitchOn] = useState(!hasEnableSwitch);
|
||||||
|
const isUploadDisabled = disabled || !switchOn;
|
||||||
|
|
||||||
|
const handleSwitchChange = useCallback(
|
||||||
|
(on: boolean) => {
|
||||||
|
setSwitchOn(on);
|
||||||
|
if (!on) {
|
||||||
|
// 关闭开关 → 表单值置为 undefined(isValueEmpty = true,依赖字段禁用)
|
||||||
|
onChange?.(undefined);
|
||||||
|
} else {
|
||||||
|
// 开启开关 → 空数组 = "已启用但未上传"(isValueEmpty = false,依赖字段启用)
|
||||||
|
onChange?.(ENABLED_EMPTY as unknown as UploadFile[]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space orientation="vertical" style={{ width: '100%' }} size={8}>
|
||||||
|
{hasEnableSwitch && (
|
||||||
|
<Space size={8}>
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={switchOn}
|
||||||
|
onChange={handleSwitchChange}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<Text type={switchOn ? 'success' : 'secondary'} style={{ fontSize: 12 }}>
|
||||||
|
{switchOn ? '已启用' : '点击开关启用上传'}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Upload
|
||||||
|
listType="picture-card"
|
||||||
|
maxCount={1}
|
||||||
|
accept={buildAccept(config.fileFilter as string[] | undefined)}
|
||||||
|
fileList={fileList}
|
||||||
|
customRequest={customRequest}
|
||||||
|
onChange={({ fileList: newList }) => {
|
||||||
|
// 仅当开关开启时上报文件变化
|
||||||
|
if (switchOn || !hasEnableSwitch) {
|
||||||
|
onChange?.(newList as unknown as UploadFile[]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isUploadDisabled}
|
||||||
|
beforeUpload={createImageBeforeUpload(maxSizeMb)}
|
||||||
|
>
|
||||||
|
{fileList.length < 1 && (
|
||||||
|
<div style={{ opacity: isUploadDisabled ? 0.4 : 1 }}>
|
||||||
|
<UploadOutlined />
|
||||||
|
<div style={{ marginTop: 4, fontSize: 12 }}>上传</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Upload>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/pages/home/controls/ImageListInput.tsx
Normal file
49
src/pages/home/controls/ImageListInput.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ImageListInput — imageList → Dragger(多张图片拖拽上传)
|
||||||
|
// 用于图生图模型的多图输入场景
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { Upload } from 'antd';
|
||||||
|
import { InboxOutlined } from '@ant-design/icons';
|
||||||
|
import type { UploadFile } from 'antd/es/upload';
|
||||||
|
import type { WidgetProps } from './types';
|
||||||
|
import { createImageBeforeUpload } from './ImageInput';
|
||||||
|
import { useFileUpload } from '@/hooks/use-api';
|
||||||
|
|
||||||
|
const { Dragger } = Upload;
|
||||||
|
|
||||||
|
export function ImageListInput({ value, onChange, disabled, config }: WidgetProps) {
|
||||||
|
const fileList = (value as UploadFile[]) || [];
|
||||||
|
const maxSizeMb = (config.maxFileSizeMb as number) || 10;
|
||||||
|
const maxCount = (config.maxFiles as number) || 10;
|
||||||
|
const filters = config.fileFilter as string[] | undefined;
|
||||||
|
|
||||||
|
const { customRequest } = useFileUpload();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dragger
|
||||||
|
multiple
|
||||||
|
listType="picture"
|
||||||
|
accept={
|
||||||
|
filters
|
||||||
|
? filters.map((ext) => `image/${ext}`).join(',')
|
||||||
|
: 'image/png,image/jpg,image/jpeg,image/webp'
|
||||||
|
}
|
||||||
|
fileList={fileList}
|
||||||
|
customRequest={customRequest}
|
||||||
|
onChange={({ fileList: newList }) => onChange?.(newList)}
|
||||||
|
disabled={disabled}
|
||||||
|
beforeUpload={createImageBeforeUpload(maxSizeMb)}
|
||||||
|
maxCount={maxCount}
|
||||||
|
>
|
||||||
|
<p className="ant-upload-drag-icon">
|
||||||
|
<InboxOutlined />
|
||||||
|
</p>
|
||||||
|
<p className="ant-upload-text">点击或拖拽图片到此区域上传</p>
|
||||||
|
<p className="ant-upload-hint">
|
||||||
|
支持 {filters?.join(' / ').toUpperCase() || 'PNG / JPG / WebP'}
|
||||||
|
,单张不超过 {maxSizeMb}MB
|
||||||
|
</p>
|
||||||
|
</Dragger>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/pages/home/controls/LineInput.tsx
Normal file
21
src/pages/home/controls/LineInput.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// ============================================================
|
||||||
|
// LineInput — lineEdit → Input
|
||||||
|
// 用于单行文本输入(如 custom_voice_id)
|
||||||
|
// 同时作为未知 widget 类型的兜底控件
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { Input } from 'antd';
|
||||||
|
import type { WidgetProps } from './types';
|
||||||
|
|
||||||
|
export function LineInput({ value, onChange, disabled, config }: WidgetProps) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
value={value as string}
|
||||||
|
onChange={(e) => onChange?.(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={(config.placeholder as string) || '请输入'}
|
||||||
|
maxLength={config.maxLength as number | undefined}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/pages/home/controls/SeedanceContent.tsx
Normal file
55
src/pages/home/controls/SeedanceContent.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// ============================================================
|
||||||
|
// SeedanceContent — seedance2Content → 自定义内容编排控件
|
||||||
|
//
|
||||||
|
// 对应 Seedance 2.0 视频生成模型的 content 参数(json_array)
|
||||||
|
// 最终形态:文本 + 参考图 + 参考视频自由组合编排
|
||||||
|
//
|
||||||
|
// TODO: 当前为占位实现,后续需完善拖拽编排能力
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { Input, Typography, theme as antTheme } from 'antd';
|
||||||
|
import { PictureOutlined, VideoCameraOutlined } from '@ant-design/icons';
|
||||||
|
import type { WidgetProps } from './types';
|
||||||
|
|
||||||
|
const { TextArea } = Input;
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
export function SeedanceContent({ value, onChange, disabled, config }: WidgetProps) {
|
||||||
|
const { token } = antTheme.useToken();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: `1px dashed ${token.colorBorder}`,
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: 12,
|
||||||
|
background: token.colorFillQuaternary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||||
|
<PictureOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />
|
||||||
|
<VideoCameraOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
内容编排 — 描述动作 + 参考素材
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextArea
|
||||||
|
rows={4}
|
||||||
|
value={value as string}
|
||||||
|
onChange={(e) => onChange?.(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={
|
||||||
|
(config.placeholder as string) ||
|
||||||
|
'输入视频内容的文字描述(后续版本将支持拖拽编排图片/视频参考素材)'
|
||||||
|
}
|
||||||
|
maxLength={(config.maxLength as number) || 5000}
|
||||||
|
showCount
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text type="secondary" style={{ display: 'block', marginTop: 6, fontSize: 11 }}>
|
||||||
|
💡 更多内容编排能力即将上线:支持文本 + 参考图 + 参考视频自由组合
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/pages/home/controls/SpinBox.tsx
Normal file
23
src/pages/home/controls/SpinBox.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// ============================================================
|
||||||
|
// SpinBox — spinBox → InputNumber (step=1)
|
||||||
|
// 用于整数输入:混沌、风格化、怪异、时长、音调等
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { InputNumber } from 'antd';
|
||||||
|
import type { WidgetProps } from './types';
|
||||||
|
|
||||||
|
export function SpinBox({ value, onChange, disabled, config }: WidgetProps) {
|
||||||
|
return (
|
||||||
|
<InputNumber
|
||||||
|
value={value as number}
|
||||||
|
onChange={(v) => onChange?.(v != null ? v : undefined)}
|
||||||
|
disabled={disabled}
|
||||||
|
min={config.minValue as number | undefined}
|
||||||
|
max={config.maxValue as number | undefined}
|
||||||
|
step={1}
|
||||||
|
style={{ width: '100%', minWidth: 140 }}
|
||||||
|
placeholder={(config.placeholder as string) || undefined}
|
||||||
|
controls
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/pages/home/controls/TextInput.tsx
Normal file
23
src/pages/home/controls/TextInput.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// ============================================================
|
||||||
|
// TextInput — textEdit → Input.TextArea
|
||||||
|
// 用于提示词、文本合成等长文本输入场景
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { Input } from 'antd';
|
||||||
|
import type { WidgetProps } from './types';
|
||||||
|
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
export function TextInput({ value, onChange, disabled, config }: WidgetProps) {
|
||||||
|
return (
|
||||||
|
<TextArea
|
||||||
|
rows={4}
|
||||||
|
value={value as string}
|
||||||
|
onChange={(e) => onChange?.(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={(config.placeholder as string) || '请输入'}
|
||||||
|
maxLength={config.maxLength as number | undefined}
|
||||||
|
showCount={config.maxLength !== undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/pages/home/controls/index.ts
Normal file
55
src/pages/home/controls/index.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// ============================================================
|
||||||
|
// controls/index.ts — Widget 注册表
|
||||||
|
//
|
||||||
|
// 职责:
|
||||||
|
// 1. 定义 WidgetProps 统一接口
|
||||||
|
// 2. 维护 Qt 控件名 → React 组件的 WIDGET_MAP 映射
|
||||||
|
// 3. 导出 WidgetType 联合类型供外部使用
|
||||||
|
//
|
||||||
|
// 新增控件类型只需两步:
|
||||||
|
// ① 在 controls/ 下创建组件文件
|
||||||
|
// ② 在 WIDGET_MAP 中加一行
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import type { ComponentType } from 'react';
|
||||||
|
import type { WidgetProps } from './types';
|
||||||
|
|
||||||
|
import { TextInput } from './TextInput';
|
||||||
|
import { LineInput } from './LineInput';
|
||||||
|
import { ComboBox } from './ComboBox';
|
||||||
|
import { Checkbox } from './Checkbox';
|
||||||
|
import { SpinBox } from './SpinBox';
|
||||||
|
import { DoubleSpinBox } from './DoubleSpinBox';
|
||||||
|
import { ImageInput } from './ImageInput';
|
||||||
|
import { ImageListInput } from './ImageListInput';
|
||||||
|
import { AudioListInput } from './AudioListInput';
|
||||||
|
import { SeedanceContent } from './SeedanceContent';
|
||||||
|
|
||||||
|
export type { WidgetProps } from './types';
|
||||||
|
|
||||||
|
// ---------- 注册表 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Qt 控件名 → React 组件的映射表。
|
||||||
|
*
|
||||||
|
* 键来自 API param_schema[].ui.widget 字段(原 Python Qt 客户端的组件类名),
|
||||||
|
* 值为 useUI/ 下的对应 React 组件。
|
||||||
|
*/
|
||||||
|
export const WIDGET_MAP: Record<string, ComponentType<WidgetProps>> = {
|
||||||
|
textEdit: TextInput,
|
||||||
|
lineEdit: LineInput,
|
||||||
|
comboBox: ComboBox,
|
||||||
|
checkbox: Checkbox,
|
||||||
|
spinBox: SpinBox,
|
||||||
|
doubleSpinBox: DoubleSpinBox,
|
||||||
|
imageInput: ImageInput,
|
||||||
|
imageList: ImageListInput,
|
||||||
|
audioList: AudioListInput,
|
||||||
|
seedance2Content: SeedanceContent,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 已知的 widget 类型字面量联合 */
|
||||||
|
export type WidgetType = keyof typeof WIDGET_MAP;
|
||||||
|
|
||||||
|
/** 未知控件类型的兜底组件(渲染为普通 Input) */
|
||||||
|
export const FALLBACK_WIDGET: ComponentType<WidgetProps> = LineInput;
|
||||||
19
src/pages/home/controls/types.ts
Normal file
19
src/pages/home/controls/types.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// ============================================================
|
||||||
|
// controls/types.ts — Widget 类型定义(与 index.ts 分离以避免循环引用)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 所有 widget 组件的统一 Props(非泛型)。
|
||||||
|
*
|
||||||
|
* 不设类型参数的原因:WIDGET_MAP 需要统一存储不同值类型的组件,
|
||||||
|
* 带泛型的 WidgetProps<string> 和 WidgetProps<number> 因函数参数逆变
|
||||||
|
* 而互不兼容(ComponentType 协变检查失败),改为统一 unknown 接口,
|
||||||
|
* 各组件内部通过类型断言收窄到已知类型。
|
||||||
|
*/
|
||||||
|
export interface WidgetProps {
|
||||||
|
value?: unknown;
|
||||||
|
onChange?: (value: unknown) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** 从 param_schema 提取的控件专属配置(placeholder / options / min / max 等) */
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
}
|
||||||
112
src/pages/home/features/BannerCarousel.tsx
Normal file
112
src/pages/home/features/BannerCarousel.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
// ============================================================
|
||||||
|
// BannerCarousel — 首页顶部轮播图
|
||||||
|
// 使用 useBannerList() Hook 获取数据,组件仅负责渲染
|
||||||
|
//
|
||||||
|
// 关闭行为:
|
||||||
|
// - 关闭后当前会话不再显示(组件内 useState 驱动)
|
||||||
|
// - 刷新/重启应用 → state 重置 → 重新展示
|
||||||
|
// - 不做 localStorage 持久化,确保每次启动都可见
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useRef, useMemo, useState } from 'react';
|
||||||
|
import { Carousel, Skeleton, Button } from 'antd';
|
||||||
|
import { CloseOutlined } from '@ant-design/icons';
|
||||||
|
import type { CarouselRef } from 'antd/es/carousel';
|
||||||
|
|
||||||
|
import { useBannerList } from '@/hooks/use-api';
|
||||||
|
import type { Banner } from '@/services/modules/banner';
|
||||||
|
|
||||||
|
// ---------- 组件 Props ----------
|
||||||
|
|
||||||
|
interface BannerCarouselProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 组件 ----------
|
||||||
|
|
||||||
|
export function BannerCarousel({ className }: BannerCarouselProps) {
|
||||||
|
const carouselRef = useRef<CarouselRef>(null);
|
||||||
|
const { data: rawBanners, loading } = useBannerList();
|
||||||
|
|
||||||
|
/** 当前会话是否已关闭轮播图(组件卸载即重置,下次启动重新展示) */
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
|
||||||
|
// 过滤 + 排序(纯计算,无副作用)
|
||||||
|
const banners = useMemo<Banner[]>(() => {
|
||||||
|
if (!rawBanners) return [];
|
||||||
|
return rawBanners
|
||||||
|
.filter((b) => b.is_active)
|
||||||
|
.filter((b) => b.category === 'banner')
|
||||||
|
.sort((a, b) => a.sort_order - b.sort_order);
|
||||||
|
}, [rawBanners]);
|
||||||
|
|
||||||
|
// 用户已关闭或加载完成但无数据 → 不渲染
|
||||||
|
if (dismissed || (!loading && banners.length === 0)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载态 — 适配长条比例(2280×90)
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className={className} style={{ width: '100%' }}>
|
||||||
|
<Skeleton.Input active block style={{ height: 90 }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`banner-carousel ${className ?? ''}`}
|
||||||
|
style={{ position: 'relative', width: '100%', overflow: 'hidden', lineHeight: 0 }}
|
||||||
|
>
|
||||||
|
<Carousel
|
||||||
|
ref={carouselRef}
|
||||||
|
autoplay
|
||||||
|
autoplaySpeed={5000}
|
||||||
|
effect="fade"
|
||||||
|
pauseOnHover
|
||||||
|
adaptiveHeight={true}
|
||||||
|
dots={banners.length > 1}
|
||||||
|
>
|
||||||
|
{banners.map((banner) => (
|
||||||
|
<div key={banner.id}>
|
||||||
|
<img
|
||||||
|
src={banner.image_url}
|
||||||
|
alt={banner.title}
|
||||||
|
style={{ width: '100%', height: 'auto', display: 'block' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Carousel>
|
||||||
|
|
||||||
|
{/* 关闭按钮 — 右上角悬浮,半透明底色,hover 加深 */}
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<CloseOutlined />}
|
||||||
|
onClick={() => setDismissed(true)}
|
||||||
|
title="关闭轮播图"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
zIndex: 10,
|
||||||
|
color: '#fff',
|
||||||
|
background: 'rgba(0, 0, 0, 0.35)',
|
||||||
|
borderRadius: 4,
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = 'rgba(0, 0, 0, 0.55)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = 'rgba(0, 0, 0, 0.35)';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
535
src/pages/home/features/ModelInputForm.tsx
Normal file
535
src/pages/home/features/ModelInputForm.tsx
Normal file
@@ -0,0 +1,535 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ModelInputForm — 模型输入表单(纯布局外壳)
|
||||||
|
//
|
||||||
|
// 布局:Header(模型信息+meta) → Body(fields.map 渲染) → Footer(标签+重置+提交)
|
||||||
|
//
|
||||||
|
// 与旧版的区别:
|
||||||
|
// - 控件渲染逻辑已提取到 controls/ 组件体系
|
||||||
|
// - param_schema 解析已提取到 useModelUI Hook
|
||||||
|
// - 字段间条件依赖(constraints_config.requires)自动处理禁用
|
||||||
|
// - 本组件只负责:布局、Form 容器、提交逻辑、依赖禁用
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState, useLayoutEffect, useMemo, useCallback, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
Empty,
|
||||||
|
Tag,
|
||||||
|
Space,
|
||||||
|
theme as antTheme,
|
||||||
|
message,
|
||||||
|
Modal,
|
||||||
|
} from 'antd';
|
||||||
|
import type { FormInstance } from 'antd';
|
||||||
|
import { SendOutlined, ClockCircleOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
|
import type { UploadFile } from 'antd/es/upload';
|
||||||
|
|
||||||
|
import { useHomeContext } from '@/pages/home/HomeContext';
|
||||||
|
import { useAppContext } from '@/components/providers/AppProvider';
|
||||||
|
import { useSubmitTask } from '@/hooks/use-api';
|
||||||
|
import { emit, EVENTS } from '@/utils/event-bus';
|
||||||
|
import { OutputTypeMap } from '@/services/modules/task';
|
||||||
|
import type { OutputTypeString, TaskInfoItemResponseBody } from '@/services/modules/task';
|
||||||
|
import { useModelUI, isValueEmpty, type UIFieldConfig } from '../hooks/useModelUI';
|
||||||
|
import {
|
||||||
|
calculateCost,
|
||||||
|
formatYuan,
|
||||||
|
type ModelDefinition,
|
||||||
|
type ModelPricingConfig,
|
||||||
|
} from '@/utils/pricing';
|
||||||
|
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
|
// ---------- 工具 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Form.Item 的 getValueFromEvent,兼容两种 onChange 参数形态:
|
||||||
|
* 1. Upload 组件的 DOM 事件:{ fileList: UploadFile[] } → 提取 fileList
|
||||||
|
* 2. enable_switch 的裸值:undefined / [] / [{...}] → 直接透传
|
||||||
|
*/
|
||||||
|
function handleFileWidgetValue(e: unknown): unknown {
|
||||||
|
if (e && typeof e === 'object' && 'fileList' in e) {
|
||||||
|
return (e as { fileList: unknown }).fileList;
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 依赖字段包装组件 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DependentFormItem — 为单个字段处理条件依赖禁用。
|
||||||
|
*
|
||||||
|
* 通过 Form.useWatch 监听 dependsOn 列表中的字段值,
|
||||||
|
* 任一依赖为空时自动禁用本字段(注入 disabled: true 到 widgetConfig)。
|
||||||
|
*
|
||||||
|
* 每个 DependentFormItem 实例的 dependsOn 长度固定,
|
||||||
|
* 切换模型时通过外层 key 变化自动重新挂载,满足 React hooks 规则。
|
||||||
|
*/
|
||||||
|
function DependentFormItem({
|
||||||
|
field,
|
||||||
|
form,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled: formItemDisabled,
|
||||||
|
}: {
|
||||||
|
field: UIFieldConfig;
|
||||||
|
form: FormInstance;
|
||||||
|
/** Form.Item 通过 React.cloneElement 注入,必须透传给 widget */
|
||||||
|
value?: unknown;
|
||||||
|
onChange?: (value: unknown) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
// Form.useWatch 必须在组件顶层无条件调用(不能放 .map() 或条件分支中)。
|
||||||
|
// dependsOn 长度由 param_schema 约束(当前 API 最多 2 个),
|
||||||
|
// 预留 3 个固定槽位覆盖未来扩展;无依赖时 watch 空串 → 返回 undefined。
|
||||||
|
const deps = field.dependsOn || [];
|
||||||
|
const d0 = Form.useWatch(deps[0] || '', form);
|
||||||
|
const d1 = Form.useWatch(deps[1] || '', form);
|
||||||
|
const d2 = Form.useWatch(deps[2] || '', form);
|
||||||
|
const depValues = [d0, d1, d2].slice(0, deps.length);
|
||||||
|
|
||||||
|
const isDepDisabled =
|
||||||
|
deps.some((_, i) => isValueEmpty(depValues[i]));
|
||||||
|
|
||||||
|
const effectiveDisabled = formItemDisabled || isDepDisabled;
|
||||||
|
const effectiveConfig = effectiveDisabled
|
||||||
|
? { ...field.widgetConfig, disabled: true }
|
||||||
|
: field.widgetConfig;
|
||||||
|
|
||||||
|
const Component = field.component;
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={effectiveDisabled}
|
||||||
|
config={effectiveConfig}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 稳定空数组引用,避免 useMemo 重建 */
|
||||||
|
const EMPTY_ARRAY: never[] = [];
|
||||||
|
/** 稳定空对象引用,避免每次渲染创建新 {} 导致 useMemo 失效 */
|
||||||
|
const EMPTY_OBJ: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从自由格式 JSON 中安全读取数字(兼容 int / float / string)
|
||||||
|
*/
|
||||||
|
function safeNum(v: unknown): number | null {
|
||||||
|
if (v === null || v === undefined) return null;
|
||||||
|
if (typeof v === 'number' && !Number.isNaN(v)) return v;
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
const n = Number(v.trim());
|
||||||
|
return Number.isNaN(n) ? null : n;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 主组件 ----------
|
||||||
|
|
||||||
|
export function ModelInputForm() {
|
||||||
|
const { token } = antTheme.useToken();
|
||||||
|
const { isLoggedIn } = useAppContext();
|
||||||
|
const { selectedModel } = useHomeContext();
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [taskTag, setTaskTag] = useState('');
|
||||||
|
|
||||||
|
// 跟踪表单当前值(用于动态计价,每次字段变化时更新)
|
||||||
|
const [currentValues, setCurrentValues] = useState<Record<string, unknown>>({});
|
||||||
|
|
||||||
|
// 提交突变 Hook
|
||||||
|
const { execute: submitTask, loading: submitting, errorMessage } = useSubmitTask({
|
||||||
|
onSuccess: useCallback((_data: TaskInfoItemResponseBody) => {
|
||||||
|
void message.success('任务已提交');
|
||||||
|
emit(EVENTS.TASK_SUBMITTED);
|
||||||
|
}, []),
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------- 提交防抖 & 二次确认 Ref --------
|
||||||
|
|
||||||
|
/** 上次点击提交按钮的时间戳(毫秒),用于 2s 防抖节流 */
|
||||||
|
const lastClickTimeRef = useRef(0);
|
||||||
|
/** 已成功提交次数,用于判断是否需要"再次提交确认"弹窗 */
|
||||||
|
const submitCountRef = useRef(0);
|
||||||
|
/** 最新错误信息的 ref(避免 doSubmit useCallback 闭包过期) */
|
||||||
|
const errorMessageRef = useRef(errorMessage);
|
||||||
|
errorMessageRef.current = errorMessage;
|
||||||
|
|
||||||
|
// ======== useModelUI:param_schema → UIFieldConfig[] ========
|
||||||
|
|
||||||
|
const fields: UIFieldConfig[] = useModelUI(
|
||||||
|
selectedModel?.param_schema || EMPTY_ARRAY,
|
||||||
|
(selectedModel?.constraints_config as Record<string, unknown>) || EMPTY_OBJ,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 构建初始值(用作 setFieldsValue 的数据源,
|
||||||
|
// antd Form 在有外部 form 实例时会忽略 initialValues prop)
|
||||||
|
const initialValues = useMemo(() => {
|
||||||
|
const iv: Record<string, unknown> = {};
|
||||||
|
fields.forEach((f) => {
|
||||||
|
if (f.defaultValue !== undefined && f.defaultValue !== null) {
|
||||||
|
iv[f.name] = f.defaultValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return iv;
|
||||||
|
}, [fields]);
|
||||||
|
|
||||||
|
|
||||||
|
// 选中模型时加载默认值(useLayoutEffect 在 DOM 更新后、浏览器绘制前执行,避免闪烁)
|
||||||
|
// antd 有外部 form 实例时 initialValues prop 被忽略,必须手动 setFieldsValue
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (selectedModel && Object.keys(initialValues).length > 0) {
|
||||||
|
form.setFieldsValue(initialValues);
|
||||||
|
setTaskTag('');
|
||||||
|
// 同步价格计算:切换模型时立即用默认值更新预估
|
||||||
|
setCurrentValues(initialValues);
|
||||||
|
} else if (!selectedModel) {
|
||||||
|
setCurrentValues({});
|
||||||
|
}
|
||||||
|
}, [selectedModel?.id, initialValues, selectedModel, form]);
|
||||||
|
|
||||||
|
// 重置参数(回到默认值 + 清空标签)
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
form.resetFields();
|
||||||
|
form.setFieldsValue(initialValues);
|
||||||
|
setTaskTag('');
|
||||||
|
setCurrentValues(initialValues);
|
||||||
|
}, [form, initialValues]);
|
||||||
|
|
||||||
|
// ======== 提交 ========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实际提交逻辑(在防抖检查和二次确认之后执行)。
|
||||||
|
* 用 useCallback 稳定化,避免 handleSubmit 因闭包过期而读到旧 fields/values。
|
||||||
|
*/
|
||||||
|
const doSubmit = useCallback(async () => {
|
||||||
|
if (!selectedModel) return;
|
||||||
|
|
||||||
|
let values: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
values = await form.validateFields();
|
||||||
|
} catch {
|
||||||
|
// 表单校验失败 → antd 自动展示字段错误,静默返回
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const field of fields) {
|
||||||
|
const value = values[field.name];
|
||||||
|
if (value === undefined || value === null) continue;
|
||||||
|
|
||||||
|
if (field.isFileWidget) {
|
||||||
|
const fileList = value as UploadFile[];
|
||||||
|
const urls = fileList
|
||||||
|
.filter((f) => f.status === 'done')
|
||||||
|
.map((f) => f.response?.url || f.url || f.name)
|
||||||
|
.filter(Boolean) as string[];
|
||||||
|
if (urls.length > 0) {
|
||||||
|
params[field.name] = urls.length === 1 ? urls[0] : JSON.stringify(urls);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
params[field.name] = String(value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
params[field.name] = String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// useAsyncMutation.execute 内部 catch 所有错误,返回 undefined 表示失败
|
||||||
|
const result = await submitTask({
|
||||||
|
model_id: selectedModel.id,
|
||||||
|
params,
|
||||||
|
...(taskTag.trim() ? { tags: [taskTag.trim()] } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result !== undefined) {
|
||||||
|
submitCountRef.current += 1;
|
||||||
|
} else {
|
||||||
|
// 使用 ref 读取最新错误信息(避免闭包过期)
|
||||||
|
message.error(errorMessageRef.current || '任务提交失败,请重试');
|
||||||
|
}
|
||||||
|
}, [selectedModel, form, fields, taskTag, submitTask]);
|
||||||
|
|
||||||
|
/** 提交按钮点击入口:登录校验 → 防抖节流 → 二次确认 → 执行提交 */
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
message.warning('请先登录后再提交任务');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedModel) return;
|
||||||
|
|
||||||
|
// ---- 防抖节流:2s 内重复点击 ----
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastClickTimeRef.current < 2000) {
|
||||||
|
message.warning('你点击的太快了');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastClickTimeRef.current = now;
|
||||||
|
|
||||||
|
// ---- 已提交过 → 二次确认弹窗 ----
|
||||||
|
if (submitCountRef.current > 0) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认再次提交',
|
||||||
|
content: '您已提交,确认要再次提交吗?',
|
||||||
|
okText: '确认提交',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: () => {
|
||||||
|
// 弹窗确认后重置节流时间戳,确保确认提交不被防抖拦截
|
||||||
|
lastClickTimeRef.current = 0;
|
||||||
|
void doSubmit();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void doSubmit();
|
||||||
|
}, [isLoggedIn, selectedModel, doSubmit]);
|
||||||
|
|
||||||
|
// ======== 动态预估消费金额(必须在所有提前返回之前) ========
|
||||||
|
|
||||||
|
const estimatedCostDisplay: string = useMemo(() => {
|
||||||
|
const rawCfg = selectedModel?.pricing_config;
|
||||||
|
if (!rawCfg) return '消费';
|
||||||
|
|
||||||
|
const cfg = rawCfg as unknown as ModelPricingConfig;
|
||||||
|
|
||||||
|
// ═══ 第一层:完整动态计价(覆盖全部 6 种模式) ═══
|
||||||
|
const modelDef: ModelDefinition = {
|
||||||
|
id: selectedModel.id,
|
||||||
|
name: selectedModel.name,
|
||||||
|
pricing_config: cfg,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cost = calculateCost(modelDef, currentValues);
|
||||||
|
if (cost !== null && cost !== undefined) {
|
||||||
|
if (cost === 0) return '消费 ¥0(免费)';
|
||||||
|
return `消费 ¥${formatYuan(cost)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ 第二层:兜底提取基础价格(calculateCost 失败时) ═══
|
||||||
|
const rawObj = cfg.raw as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
const fallbackPrice =
|
||||||
|
safeNum(cfg.price) ??
|
||||||
|
safeNum(cfg.price_cent != null ? cfg.price_cent / 100 : null) ??
|
||||||
|
safeNum(rawObj?.price) ??
|
||||||
|
safeNum(rawObj?.base_price);
|
||||||
|
|
||||||
|
const unitName =
|
||||||
|
(rawObj?.unit_name as string) ||
|
||||||
|
(rawObj?.unit as string) ||
|
||||||
|
undefined;
|
||||||
|
|
||||||
|
if (fallbackPrice !== null) {
|
||||||
|
const formatted = formatYuan(fallbackPrice);
|
||||||
|
if (unitName) return `消费 ¥${formatted}/${unitName}`;
|
||||||
|
if (fallbackPrice > 0) return `消费约 ¥${formatted}`;
|
||||||
|
return `消费 ¥${formatted}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cfg.price_per_k != null) return `消费 ¥${formatYuan(cfg.price_per_k)}/千字符`;
|
||||||
|
if (cfg.price_per_k_cent != null) return `消费 ¥${formatYuan(cfg.price_per_k_cent / 100)}/千字符`;
|
||||||
|
|
||||||
|
const modeLabel = cfg.mode || cfg.price_type || cfg.billing_mode;
|
||||||
|
if (modeLabel) return `消费(${modeLabel})`;
|
||||||
|
|
||||||
|
return '消费';
|
||||||
|
}, [selectedModel, currentValues]);
|
||||||
|
|
||||||
|
// ======== 未选择模型 ========
|
||||||
|
|
||||||
|
if (!selectedModel) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
height: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Empty description="请在左侧选择一个模型" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== 提取 meta 信息 ========
|
||||||
|
|
||||||
|
const metaConfig = (selectedModel.meta_config || {}) as Record<string, unknown>;
|
||||||
|
const uiConfig = (selectedModel.ui_config || {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const outputType = metaConfig.output_type as OutputTypeString | undefined;
|
||||||
|
const estimatedDuration = metaConfig.estimated_duration_seconds as number | undefined;
|
||||||
|
const badge = uiConfig.badge as string | undefined;
|
||||||
|
const group = uiConfig.group as string | undefined;
|
||||||
|
const helpUrl = metaConfig.help_url as string | undefined;
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ======== Header:模型信息 ======== */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '16px 20px',
|
||||||
|
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||||
|
{badge && <span style={{ fontSize: 20 }}>{badge}</span>}
|
||||||
|
<Title level={4} style={{ margin: 0, fontSize: 18 }}>
|
||||||
|
{selectedModel.name}
|
||||||
|
</Title>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{selectedModel.provider_name}
|
||||||
|
{group
|
||||||
|
? ` · ${group}`
|
||||||
|
: selectedModel.category_name
|
||||||
|
? ` · ${selectedModel.category_name}`
|
||||||
|
: ''}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Space size={4} style={{ marginTop: 8 }} wrap>
|
||||||
|
{outputType && (
|
||||||
|
<Tag color="blue" style={{ fontSize: 11, lineHeight: '18px' }}>
|
||||||
|
{OutputTypeMap[outputType] || outputType}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
{estimatedDuration !== undefined && (
|
||||||
|
<Tag
|
||||||
|
icon={<ClockCircleOutlined />}
|
||||||
|
color="default"
|
||||||
|
style={{ fontSize: 11, lineHeight: '18px' }}
|
||||||
|
>
|
||||||
|
约 {estimatedDuration} 秒
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
{helpUrl && (
|
||||||
|
<a
|
||||||
|
href={helpUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ fontSize: 11 }}
|
||||||
|
>
|
||||||
|
使用帮助 ↗
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ======== Body:参数列表(useModelUI 驱动) ======== */}
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', padding: '16px 20px' }}>
|
||||||
|
{fields.length === 0 ? (
|
||||||
|
<Empty
|
||||||
|
description="该模型无需额外参数"
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
style={{ marginTop: 24 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onValuesChange={(_, allValues) => setCurrentValues(allValues)}
|
||||||
|
>
|
||||||
|
{fields.map((field) => (
|
||||||
|
<Form.Item
|
||||||
|
key={`${selectedModel.id}-${field.name}`}
|
||||||
|
name={field.name}
|
||||||
|
label={
|
||||||
|
<span>
|
||||||
|
<Text strong={field.must}>{field.label}</Text>
|
||||||
|
{field.tips && (
|
||||||
|
<Text
|
||||||
|
type="secondary"
|
||||||
|
style={{ fontSize: 12, marginLeft: 6 }}
|
||||||
|
>
|
||||||
|
{field.tips.length > 60
|
||||||
|
? `${field.tips.slice(0, 60)}...`
|
||||||
|
: field.tips}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
rules={
|
||||||
|
field.must
|
||||||
|
? [{ required: true, message: `请输入${field.label}` }]
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
valuePropName={field.valuePropName}
|
||||||
|
{...(field.isFileWidget
|
||||||
|
? { getValueFromEvent: handleFileWidgetValue }
|
||||||
|
: {})}
|
||||||
|
>
|
||||||
|
<DependentFormItem field={field} form={form} />
|
||||||
|
</Form.Item>
|
||||||
|
))}
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ======== Footer:标签 + 重置 + 提交 ======== */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '12px 20px',
|
||||||
|
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="自定义任务标签(可选)"
|
||||||
|
value={taskTag}
|
||||||
|
onChange={(e) => setTaskTag(e.target.value)}
|
||||||
|
allowClear
|
||||||
|
maxLength={50}
|
||||||
|
style={{ flex: 1, minWidth: 0 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={!selectedModel}
|
||||||
|
title="重置参数"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SendOutlined />}
|
||||||
|
onClick={() => void handleSubmit()}
|
||||||
|
loading={submitting}
|
||||||
|
disabled={!isLoggedIn || !selectedModel}
|
||||||
|
size="large"
|
||||||
|
style={{
|
||||||
|
background: token.colorPrimary,
|
||||||
|
height: 40,
|
||||||
|
fontSize: 14,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{estimatedCostDisplay}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
241
src/pages/home/features/ModelSelector.tsx
Normal file
241
src/pages/home/features/ModelSelector.tsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ModelSelector — 模型选择器(Tabs 标签页分组 + 平铺列表)
|
||||||
|
// 模型数据由父组件 LeftPanel 通过 props 传入,避免多个兄弟组件各自请求
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import React, { useState, useMemo, useCallback } from 'react';
|
||||||
|
import { Tabs, Spin, Empty, Tag, Typography, Button, Result, message, theme as antTheme } from 'antd';
|
||||||
|
import { AppstoreOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { useHomeContext } from '@/pages/home/HomeContext';
|
||||||
|
import { MODEL_CATEGORIES, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models';
|
||||||
|
import { EnumResolver } from '@/utils/enum-resolver';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
/** "全部模型" 的虚拟 tab key */
|
||||||
|
const ALL_TAB_KEY = '__all__';
|
||||||
|
|
||||||
|
// ---------- 单模型列表项(平铺) ----------
|
||||||
|
|
||||||
|
/** 单个模型行:hover 高亮 + 选中态 + 状态标签 + 禁用态处理 */
|
||||||
|
interface ModelItemProps {
|
||||||
|
model: ModelsListResponseBody;
|
||||||
|
isSelected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelItem({ model, isSelected, onSelect }: ModelItemProps) {
|
||||||
|
const { token } = antTheme.useToken();
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
|
// 维护中的模型不可选
|
||||||
|
const isDisabled = model.status === 'maintenance';
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
if (isDisabled) {
|
||||||
|
message.warning(`${model.name} 正在维护中,暂不可用`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSelect();
|
||||||
|
}, [isDisabled, model.name, onSelect]);
|
||||||
|
|
||||||
|
// 背景色 / 边框色 优先级:选中 > hover > 默认
|
||||||
|
let bgColor = 'transparent';
|
||||||
|
let borderColor = 'transparent';
|
||||||
|
if (isSelected) {
|
||||||
|
bgColor = token.colorPrimaryBg;
|
||||||
|
borderColor = token.colorPrimaryBorder;
|
||||||
|
} else if (isHovered && !isDisabled) {
|
||||||
|
bgColor = token.colorFillSecondary;
|
||||||
|
borderColor = token.colorBorderSecondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={handleClick}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: bgColor,
|
||||||
|
border: `1px solid ${borderColor}`,
|
||||||
|
opacity: isDisabled ? 0.5 : 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 8,
|
||||||
|
transition: 'background 0.15s linear, border-color 0.15s linear, opacity 0.15s linear',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
fontSize: 13,
|
||||||
|
color: isSelected ? token.colorPrimaryText : token.colorText,
|
||||||
|
}}
|
||||||
|
title={model.name}
|
||||||
|
>
|
||||||
|
{model.name}
|
||||||
|
</Text>
|
||||||
|
{model.status !== 'active' && (
|
||||||
|
<Tag
|
||||||
|
color={model.status === 'inactive' ? 'default' : 'orange'}
|
||||||
|
style={{ fontSize: 10, lineHeight: '16px', padding: '0 4px' }}
|
||||||
|
>
|
||||||
|
{model.status === 'maintenance' ? '维护' : '停用'}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 主组件 ----------
|
||||||
|
|
||||||
|
interface ModelSelectorProps {
|
||||||
|
models: ModelsListResponseBody[] | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
groupedModels: Map<string, ModelsListResponseBody[]>;
|
||||||
|
refetch: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelSelector({ models, loading, error, groupedModels, refetch }: ModelSelectorProps) {
|
||||||
|
const { token } = antTheme.useToken();
|
||||||
|
const { selectedModel, setSelectedModel } = useHomeContext();
|
||||||
|
const [activeTab, setActiveTab] = useState(ALL_TAB_KEY);
|
||||||
|
|
||||||
|
// 当前 Tab 对应的模型列表
|
||||||
|
const filteredModels:ModelsListResponseBody[] = useMemo(() => {
|
||||||
|
if (activeTab === ALL_TAB_KEY) return models ?? [];
|
||||||
|
return groupedModels.get(activeTab) ?? [];
|
||||||
|
}, [activeTab, models, groupedModels]);
|
||||||
|
|
||||||
|
// 构建 Tab 项(全部 + 各分类,顺序由 MODEL_CATEGORIES 控制)
|
||||||
|
const tabItems: { key: string; label: React.ReactNode }[] = useMemo(() => {
|
||||||
|
const items: { key: string; label: React.ReactNode }[] = [
|
||||||
|
{
|
||||||
|
key: ALL_TAB_KEY,
|
||||||
|
label: `全部 (${models?.length ?? 0})`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 按 MODEL_CATEGORIES 定义的顺序遍历,确保 Tab 顺序可控
|
||||||
|
const seenCategories = new Set<string>();
|
||||||
|
MODEL_CATEGORIES.forEach((cat: ModelCategoryStringEnum) => {
|
||||||
|
const groupModels = groupedModels.get(cat);
|
||||||
|
if (groupModels && groupModels.length > 0) {
|
||||||
|
seenCategories.add(cat);
|
||||||
|
items.push({
|
||||||
|
key: cat,
|
||||||
|
label: `${EnumResolver.model.category.label(cat)} (${groupModels.length})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 兜底:API 返回了 MODEL_CATEGORIES 中未注册的新分类,追加到末尾
|
||||||
|
groupedModels.forEach((groupModels, category) => {
|
||||||
|
if (!seenCategories.has(category) && groupModels.length > 0) {
|
||||||
|
items.push({
|
||||||
|
key: category,
|
||||||
|
label: `${EnumResolver.model.category.label(category as ModelCategoryStringEnum)} (${groupModels.length})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}, [models, groupedModels]);
|
||||||
|
|
||||||
|
// ---------- 加载态 ----------
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
|
||||||
|
<Spin size="small" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 错误态 ----------
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title="模型列表加载失败"
|
||||||
|
subTitle={error.message || '请检查网络连接后重试'}
|
||||||
|
extra={
|
||||||
|
<Button type="primary" size="small" onClick={refetch}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 空态 ----------
|
||||||
|
|
||||||
|
if (!models || models.length === 0) {
|
||||||
|
return <Empty description="暂无可用模型" image={Empty.PRESENTED_IMAGE_SIMPLE} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 正常渲染 ----------
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||||
|
{/* 标题栏 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AppstoreOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />
|
||||||
|
<Text strong style={{ fontSize: 13 }}>
|
||||||
|
选择模型
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分类 Tabs */}
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeTab}
|
||||||
|
onChange={setActiveTab}
|
||||||
|
items={tabItems}
|
||||||
|
size="small"
|
||||||
|
type="card"
|
||||||
|
tabBarStyle={{ margin: '0 0 4px 0', padding: '0 8px' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 模型列表 */}
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', padding: '0 8px 8px' }}>
|
||||||
|
{filteredModels.length === 0 ? (
|
||||||
|
<Empty
|
||||||
|
description="该分类暂无模型"
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
style={{ marginTop: 24 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{filteredModels.map((model) => (
|
||||||
|
<ModelItem
|
||||||
|
key={model.id}
|
||||||
|
model={model}
|
||||||
|
isSelected={selectedModel?.id === model.id}
|
||||||
|
onSelect={() => setSelectedModel(model)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
188
src/pages/home/features/OutputPreview.tsx
Normal file
188
src/pages/home/features/OutputPreview.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
// ============================================================
|
||||||
|
// OutputPreview — 任务输出预览(图片 / 视频)
|
||||||
|
// 使用 useTaskDetail() Hook 获取任务详情,组件仅负责渲染
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useMemo, useRef, useEffect } from 'react';
|
||||||
|
import { Image, Empty, Spin, Typography, Button, Tag, theme as antTheme } from 'antd';
|
||||||
|
import {
|
||||||
|
EyeOutlined,
|
||||||
|
DownloadOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { useHomeContext } from '@/pages/home/HomeContext';
|
||||||
|
import { useTaskDetail } from '@/hooks/use-api';
|
||||||
|
import { POLLING_STATUSES, type TaskStatusString } from '@/services/modules/task';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
// ---------- 状态标签配置 ----------
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<TaskStatusString, { color: string; label: string }> = {
|
||||||
|
pending: { color: 'blue', label: '排队中' },
|
||||||
|
submitted: { color: 'cyan', label: '已提交' },
|
||||||
|
processing: { color: 'orange', label: '处理中' },
|
||||||
|
success: { color: 'green', label: '已完成' },
|
||||||
|
failed: { color: 'red', label: '失败' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- 工具函数 ----------
|
||||||
|
|
||||||
|
function isVideoUrl(url: string): boolean {
|
||||||
|
return /\.(mp4|webm|mov|avi|mkv)(\?|$)/i.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitAssets(assets: Array<{ url: string }>): { images: string[]; videos: string[] } {
|
||||||
|
const images: string[] = [];
|
||||||
|
const videos: string[] = [];
|
||||||
|
assets.forEach(({ url }) => {
|
||||||
|
if (isVideoUrl(url)) videos.push(url);
|
||||||
|
else images.push(url);
|
||||||
|
});
|
||||||
|
return { images, videos };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 组件 ----------
|
||||||
|
|
||||||
|
export function OutputPreview() {
|
||||||
|
const { token } = antTheme.useToken();
|
||||||
|
const { selectedTask } = useHomeContext();
|
||||||
|
|
||||||
|
// 数据获取(Hook 自动处理:taskId 为 null 时不请求、竞态、loading/error)
|
||||||
|
const { data: detail, loading, refetch } = useTaskDetail(selectedTask?.id);
|
||||||
|
|
||||||
|
// ======== 状态转换监听:非终态→终态时触发详情刷新 ========
|
||||||
|
// 不再独立轮询 getTaskStatus(避免与 TaskHistory 的批量轮询重复请求),
|
||||||
|
// 而是依赖批量轮询通过 Context 同步过来的 selectedTask.status 变化。
|
||||||
|
|
||||||
|
const prevStatusRef = useRef<TaskStatusString | undefined>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const status = selectedTask?.status as TaskStatusString | undefined;
|
||||||
|
const prev = prevStatusRef.current;
|
||||||
|
|
||||||
|
// 从非终态变为终态 → 刷新详情获取完整数据(output_assets 等)
|
||||||
|
if (
|
||||||
|
prev &&
|
||||||
|
POLLING_STATUSES.includes(prev) &&
|
||||||
|
status &&
|
||||||
|
!POLLING_STATUSES.includes(status)
|
||||||
|
) {
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
prevStatusRef.current = status;
|
||||||
|
}, [selectedTask?.status, refetch]);
|
||||||
|
|
||||||
|
// 从 output_assets 提取图片/视频
|
||||||
|
const { images, videos } = useMemo(() => {
|
||||||
|
if (!detail?.output_assets || detail.output_assets.length === 0) {
|
||||||
|
return { images: [], videos: [] };
|
||||||
|
}
|
||||||
|
return splitAssets(detail.output_assets);
|
||||||
|
}, [detail]);
|
||||||
|
|
||||||
|
const status = selectedTask?.status as TaskStatusString | undefined;
|
||||||
|
const statusCfg = status
|
||||||
|
? (STATUS_CONFIG[status] || { color: 'default', label: status })
|
||||||
|
: { color: 'default', label: '未知' };
|
||||||
|
|
||||||
|
// ---- 无选中任务 ----
|
||||||
|
if (!selectedTask) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||||||
|
<Empty description="选择任务记录查看输出" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 处理中 ----
|
||||||
|
if (status && POLLING_STATUSES.includes(status)) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 16 }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
<Tag color={statusCfg.color}>{statusCfg.label}</Tag>
|
||||||
|
<Text type="secondary">任务正在处理中,请稍候...</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 失败 ----
|
||||||
|
if (status === 'failed') {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 12 }}>
|
||||||
|
<CloseCircleOutlined style={{ fontSize: 48, color: '#ff4d4f' }} />
|
||||||
|
<Text type="danger" strong>任务执行失败</Text>
|
||||||
|
{detail?.error_message && (
|
||||||
|
<Text type="secondary" style={{ maxWidth: 300, textAlign: 'center', fontSize: 12 }}>
|
||||||
|
{detail.error_message}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 加载中 ----
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 无结果 ----
|
||||||
|
if (!detail || images.length + videos.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||||||
|
<Empty description="暂无输出结果" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 展示 ----
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
|
<div style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<EyeOutlined style={{ fontSize: 14 }} />
|
||||||
|
<Text strong style={{ fontSize: 13 }}>输出预览</Text>
|
||||||
|
<Tag color={statusCfg.color} style={{ fontSize: 11 }}>{statusCfg.label}</Tag>
|
||||||
|
</div>
|
||||||
|
{videos.length > 0 && (
|
||||||
|
<Button size="small" type="link" icon={<DownloadOutlined />} href={videos[0]} target="_blank">
|
||||||
|
下载视频
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{videos.map((url, i) => (
|
||||||
|
<div key={i} style={{ borderRadius: 6, overflow: 'hidden', background: '#000' }}>
|
||||||
|
<video controls style={{ width: '100%', maxHeight: 360, display: 'block' }} src={url}>
|
||||||
|
您的浏览器不支持视频播放
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{images.length === 1 ? (
|
||||||
|
<Image src={images[0]} alt="输出结果" style={{ borderRadius: 6, width: '100%', objectFit: 'contain' }} />
|
||||||
|
) : (
|
||||||
|
<Image.PreviewGroup>
|
||||||
|
{images.map((url, i) => (
|
||||||
|
<Image key={i} src={url} alt={`输出结果 ${i + 1}`}
|
||||||
|
style={{ borderRadius: 6, width: '100%', objectFit: 'contain', marginBottom: 8 }} />
|
||||||
|
))}
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
765
src/pages/home/features/TaskHistory.tsx
Normal file
765
src/pages/home/features/TaskHistory.tsx
Normal file
@@ -0,0 +1,765 @@
|
|||||||
|
// ============================================================
|
||||||
|
// TaskHistory — 任务记录列表(分页 + 列头筛选 + 排序 + 滚动)
|
||||||
|
//
|
||||||
|
// 筛选策略(混合模式):
|
||||||
|
// - 服务端筛选(传 API):status、model_id、user_id(企业版)
|
||||||
|
// - 客户端筛选(antd onFilter):output_type、provider_name
|
||||||
|
// - 服务端不支持日期查询,时间范围筛选暂用 sorter 排序替代
|
||||||
|
// - 企业版才会在"用户"列显示筛选菜单
|
||||||
|
//
|
||||||
|
// 使用 antd Table onChange 统一管理 pagination + filters + sorter,
|
||||||
|
// 参考 table-test.tsx 示例 4 的模式。
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { Table, Tag, Typography, theme as antTheme, Popover, Checkbox, Button, Space } from 'antd';
|
||||||
|
import type { ColumnsType, TableProps } from 'antd/es/table';
|
||||||
|
import type { FilterValue, SorterResult } from 'antd/es/table/interface';
|
||||||
|
import { HistoryOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { useTaskList, useTaskPolling } from '@/hooks/use-api';
|
||||||
|
import type { ModelsListResponseBody } from '@/services/modules/models';
|
||||||
|
import { useAppContext } from '@/components/providers/AppProvider';
|
||||||
|
import { useHomeContext } from '@/pages/home/HomeContext';
|
||||||
|
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||||
|
import { on, off, EVENTS } from '@/utils/event-bus';
|
||||||
|
import type {
|
||||||
|
TaskInfoItemResponseBody,
|
||||||
|
TaskListRequestParams,
|
||||||
|
TaskStatusString,
|
||||||
|
} from '@/services/modules/task';
|
||||||
|
import { TaskStatusMap, OutputTypeMap } from '@/services/modules/task';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
// ---------- 辅助类型 ----------
|
||||||
|
|
||||||
|
/** antd Table onChange 中 filters 的类型 */
|
||||||
|
type TableFilters = Record<string, FilterValue | null>;
|
||||||
|
|
||||||
|
/** antd Table onChange 中 sorter 的类型(单列排序) */
|
||||||
|
interface TableSorter {
|
||||||
|
field?: React.Key | readonly React.Key[];
|
||||||
|
order?: 'ascend' | 'descend';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 表格参数(缓存 antd onChange 的结果,用于构建 API 请求) */
|
||||||
|
interface TableParams {
|
||||||
|
pagination: { current: number; pageSize: number };
|
||||||
|
filters: TableFilters;
|
||||||
|
sorter: TableSorter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 常量 ----------
|
||||||
|
|
||||||
|
/** 状态筛选选项(来自 API 枚举) */
|
||||||
|
const STATUS_FILTERS = Object.entries(TaskStatusMap).map(([value, label]) => ({
|
||||||
|
text: label,
|
||||||
|
value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** 输出类型筛选选项(从 OutputTypeMap 派生) */
|
||||||
|
const OUTPUT_TYPE_FILTERS = Object.entries(OutputTypeMap).map(([value, text]) => ({
|
||||||
|
text,
|
||||||
|
value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** 状态标签配置(渲染用,含未来未知状态的降级处理) */
|
||||||
|
const STATUS_CONFIG: Record<string, { color: string; label: string }> = {
|
||||||
|
pending: { color: 'blue', label: '排队中' },
|
||||||
|
submitted: { color: 'cyan', label: '已提交' },
|
||||||
|
processing: { color: 'orange', label: '处理中' },
|
||||||
|
success: { color: 'green', label: '已完成' },
|
||||||
|
failed: { color: 'red', label: '失败' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 未知状态降级:后端新增状态时自动回退显示 key 原文 */
|
||||||
|
function resolveStatus(s: string): { color: string; label: string } {
|
||||||
|
return STATUS_CONFIG[s] || { color: 'default', label: s };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 列可见性持久化 ----------
|
||||||
|
|
||||||
|
/** localStorage 键名(遵循 ele-heixiu-* 规范) */
|
||||||
|
const COLUMN_VISIBILITY_KEY = 'ele-heixiu-table-columns';
|
||||||
|
|
||||||
|
/** 列元数据(定义所有可用列 + 默认可见性) */
|
||||||
|
interface ColumnMeta { key: string; title: string; defaultVisible: boolean }
|
||||||
|
|
||||||
|
const ALL_COLUMNS: ColumnMeta[] = [
|
||||||
|
{ key: 'id', title: '任务 ID', defaultVisible: true },
|
||||||
|
{ key: 'created_at', title: '创建时间', defaultVisible: true },
|
||||||
|
{ key: 'model_name', title: '模型', defaultVisible: true },
|
||||||
|
{ key: 'output_type', title: '输出类型', defaultVisible: true },
|
||||||
|
{ key: 'status', title: '状态', defaultVisible: true },
|
||||||
|
{ key: 'prompt', title: '提示词', defaultVisible: true },
|
||||||
|
{ key: 'duration', title: '时长', defaultVisible: true },
|
||||||
|
{ key: 'cost_cent', title: '费用', defaultVisible: true },
|
||||||
|
{ key: 'user_display_name', title: '用户', defaultVisible: true },
|
||||||
|
{ key: 'provider_name', title: '供应商', defaultVisible: false },
|
||||||
|
{ key: 'fps', title: '帧率', defaultVisible: false },
|
||||||
|
{ key: 'file_size', title: '文件大小', defaultVisible: false },
|
||||||
|
{ key: 'tags', title: '标签', defaultVisible: false },
|
||||||
|
{ key: 'error_message', title: '错误信息', defaultVisible: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
function readStoredColumns(): Set<string> | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(COLUMN_VISIBILITY_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const arr: unknown = JSON.parse(raw);
|
||||||
|
if (Array.isArray(arr) && arr.every((v) => typeof v === 'string')) return new Set(arr);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStoredColumns(visible: Set<string>): void {
|
||||||
|
try { localStorage.setItem(COLUMN_VISIBILITY_KEY, JSON.stringify([...visible])); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 工具函数 ----------
|
||||||
|
|
||||||
|
/** 从 filters 中提取单值筛选(如 status 单选) */
|
||||||
|
function pickOne(filters: TableFilters, key: string): string | undefined {
|
||||||
|
const v = filters[key];
|
||||||
|
if (Array.isArray(v) && v.length > 0) return v[0] as string;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 filters 中提取多值筛选(如 model_ids、user_ids) */
|
||||||
|
function pickMany(filters: TableFilters, key: string): string[] | undefined {
|
||||||
|
const v = filters[key];
|
||||||
|
if (Array.isArray(v) && v.length > 0) return v as string[];
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 组件 ----------
|
||||||
|
|
||||||
|
interface TaskHistoryProps {
|
||||||
|
/** 模型全量数据,由父组件 LeftPanel 传入(避免兄弟组件各自请求) */
|
||||||
|
allModels: ModelsListResponseBody[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskHistory({ allModels }: TaskHistoryProps) {
|
||||||
|
const { token } = antTheme.useToken();
|
||||||
|
const { edition } = useAppContext();
|
||||||
|
const {
|
||||||
|
selectedTask,
|
||||||
|
setSelectedTask,
|
||||||
|
taskPage,
|
||||||
|
taskPageSize,
|
||||||
|
setTaskPage,
|
||||||
|
setTaskPageSize,
|
||||||
|
} = useHomeContext();
|
||||||
|
|
||||||
|
const isEnterprise = edition === 'enterprise';
|
||||||
|
|
||||||
|
// -------- 列可见性 --------
|
||||||
|
|
||||||
|
const [visibleColumns, setVisibleColumns] = useState<Set<string>>(() => {
|
||||||
|
const stored = readStoredColumns();
|
||||||
|
if (stored) return stored;
|
||||||
|
return new Set(ALL_COLUMNS.filter((c) => c.defaultVisible).map((c) => c.key));
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleColumnToggle = useCallback((key: string, checked: boolean) => {
|
||||||
|
setVisibleColumns((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (checked) next.add(key); else next.delete(key);
|
||||||
|
writeStoredColumns(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// -------- 主题感知斑马纹 / hover / 选中行 CSS --------
|
||||||
|
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
|
||||||
|
const tableCss = useMemo(() => {
|
||||||
|
const searchIconColor = token.colorPrimary;
|
||||||
|
if (isDark) {
|
||||||
|
return `
|
||||||
|
.task-table .task-row-odd > td { background: rgba(255,255,255,0.02) !important; }
|
||||||
|
.task-table .task-row-even > td { background: transparent !important; }
|
||||||
|
.task-table .task-row-selected > td { background: ${token.blue}1A !important; }
|
||||||
|
.task-table .task-row-selected.task-row-odd > td { background: ${token.blue}22 !important; }
|
||||||
|
.task-table .ant-table-row:hover > td { background: rgba(255,255,255,0.05) !important; }
|
||||||
|
.task-table .task-row-selected:hover > td { background: ${token.blue}28 !important; }
|
||||||
|
.task-table .prompt-search-icon.filtered { color: ${searchIconColor} !important; }
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
.task-table .task-row-odd > td { background: #fafafa !important; }
|
||||||
|
.task-table .task-row-even > td { background: #ffffff !important; }
|
||||||
|
.task-table .task-row-selected > td { background: #e6f4ff !important; }
|
||||||
|
.task-table .task-row-selected.task-row-odd > td { background: #dceeff !important; }
|
||||||
|
.task-table .ant-table-row:hover > td { background: #f0f5ff !important; }
|
||||||
|
.task-table .task-row-selected:hover > td { background: #d6ebff !important; }
|
||||||
|
.task-table .prompt-search-icon.filtered { color: ${searchIconColor} !important; }
|
||||||
|
`;
|
||||||
|
}, [isDark, token.blue, token.colorPrimary]);
|
||||||
|
|
||||||
|
// -------- antd Table onChange 参数缓存 --------
|
||||||
|
|
||||||
|
const [tableParams, setTableParams] = useState<TableParams>({
|
||||||
|
pagination: { current: taskPage, pageSize: taskPageSize },
|
||||||
|
filters: {},
|
||||||
|
sorter: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 同步 context 分页 ↔ tableParams
|
||||||
|
useEffect(() => {
|
||||||
|
setTableParams((prev:TableParams) => ({
|
||||||
|
...prev,
|
||||||
|
pagination: { current: taskPage, pageSize: taskPageSize },
|
||||||
|
}));
|
||||||
|
}, [taskPage, taskPageSize]);
|
||||||
|
|
||||||
|
// -------- 模型列表(用于模型列筛选选项 + 模型名→ID 映射,由父组件传入)--------
|
||||||
|
|
||||||
|
const modelFilters:{text:string, value:string}[] = useMemo(() => {
|
||||||
|
if (!allModels) return [];
|
||||||
|
return allModels.map((m) => ({ text: m.name, value: m.id }));
|
||||||
|
}, [allModels]);
|
||||||
|
|
||||||
|
// -------- 供应商列表(从模型数据中提取)--------
|
||||||
|
|
||||||
|
const providerFilters = useMemo(() => {
|
||||||
|
if (!allModels) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
allModels.forEach((m) => {
|
||||||
|
if (m.provider_name) seen.add(m.provider_name);
|
||||||
|
});
|
||||||
|
return Array.from(seen).sort().map((name) => ({ text: name, value: name }));
|
||||||
|
}, [allModels]);
|
||||||
|
|
||||||
|
// -------- 用户列表(从当前任务数据中提取,仅企业版使用)--------
|
||||||
|
// TODO: 替换为独立的用户列表 API
|
||||||
|
|
||||||
|
const [userFilters, setUserFilters] = useState<Array<{ text: string; value: string }>>([]);
|
||||||
|
|
||||||
|
// -------- 构建 API 请求参数 --------
|
||||||
|
|
||||||
|
const requestParams = useMemo<TaskListRequestParams>(() => {
|
||||||
|
const req: TaskListRequestParams = {
|
||||||
|
page: tableParams.pagination.current,
|
||||||
|
page_size: tableParams.pagination.pageSize,
|
||||||
|
};
|
||||||
|
const { filters } = tableParams;
|
||||||
|
|
||||||
|
// 服务端筛选字段
|
||||||
|
const status = pickOne(filters, 'status');
|
||||||
|
if (status) req.status = status as TaskStatusString;
|
||||||
|
|
||||||
|
const modelIds = pickMany(filters, 'model_name');
|
||||||
|
if (modelIds) req.model_ids = modelIds;
|
||||||
|
|
||||||
|
// user_ids:仅企业版传入
|
||||||
|
if (isEnterprise) {
|
||||||
|
const userIds = pickMany(filters, 'user_id');
|
||||||
|
if (userIds) req.user_ids = userIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
return req;
|
||||||
|
}, [tableParams, isEnterprise]);
|
||||||
|
|
||||||
|
// -------- 数据获取 --------
|
||||||
|
|
||||||
|
const [refreshTick, setRefreshTick] = useState(0);
|
||||||
|
const handleTaskSubmitted = useCallback(() => {
|
||||||
|
setRefreshTick((t) => t + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
on(EVENTS.TASK_SUBMITTED, handleTaskSubmitted);
|
||||||
|
return () => { off(EVENTS.TASK_SUBMITTED, handleTaskSubmitted); };
|
||||||
|
}, [handleTaskSubmitted]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: taskData,
|
||||||
|
loading,
|
||||||
|
errorMessage,
|
||||||
|
} = useTaskList({
|
||||||
|
requestParams,
|
||||||
|
refreshSignal: refreshTick,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tasks = taskData?.items ?? [];
|
||||||
|
|
||||||
|
// 批量轮询非终态任务状态
|
||||||
|
const handleTaskTerminal = useCallback(() => {
|
||||||
|
setRefreshTick((t) => t + 1);
|
||||||
|
}, []);
|
||||||
|
const polledTasks = useTaskPolling(tasks, handleTaskTerminal);
|
||||||
|
|
||||||
|
// 轮询更新后同步选中的任务到 Context
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedTask || polledTasks.length === 0) return;
|
||||||
|
const updated = polledTasks.find(t => t.id === selectedTask.id);
|
||||||
|
if (updated && updated.status !== selectedTask.status) {
|
||||||
|
setSelectedTask(updated);
|
||||||
|
}
|
||||||
|
}, [polledTasks, selectedTask, setSelectedTask]);
|
||||||
|
|
||||||
|
const total = taskData?.total ?? 0;
|
||||||
|
|
||||||
|
// 从任务数据中提取用户列表(企业版筛选用)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEnterprise || !taskData?.items) return;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
taskData.items.forEach((t) => {
|
||||||
|
if (t.user_id && t.user_display_name) {
|
||||||
|
seen.add(JSON.stringify({ text: t.user_display_name, value: t.user_id }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const newList = Array.from(seen).map((s) => JSON.parse(s) as { text: string; value: string });
|
||||||
|
setUserFilters((prev) => {
|
||||||
|
if (prev.length === newList.length) return prev; // 避免不必要的重渲染
|
||||||
|
return newList;
|
||||||
|
});
|
||||||
|
}, [isEnterprise, taskData]);
|
||||||
|
|
||||||
|
// -------- antd Table onChange --------
|
||||||
|
|
||||||
|
const handleTableChange: TableProps<TaskInfoItemResponseBody>['onChange'] = (
|
||||||
|
pagination,
|
||||||
|
filters,
|
||||||
|
sorter,
|
||||||
|
) => {
|
||||||
|
// 同步分页到 Context
|
||||||
|
if (pagination.current && pagination.current !== taskPage) setTaskPage(pagination.current);
|
||||||
|
if (pagination.pageSize && pagination.pageSize !== taskPageSize) setTaskPageSize(pagination.pageSize);
|
||||||
|
|
||||||
|
// 缓存筛选和排序参数
|
||||||
|
const s = sorter as SorterResult<TaskInfoItemResponseBody>;
|
||||||
|
setTableParams({
|
||||||
|
pagination: {
|
||||||
|
current: pagination.current || taskPage,
|
||||||
|
pageSize: pagination.pageSize || taskPageSize,
|
||||||
|
},
|
||||||
|
filters: filters as TableFilters,
|
||||||
|
sorter: !Array.isArray(s) && s.field
|
||||||
|
? { field: s.field, order: s.order as 'ascend' | 'descend' | undefined }
|
||||||
|
: {},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------- 表格列定义(含列头筛选/排序 + 可见性过滤)--------
|
||||||
|
//
|
||||||
|
// 列顺序:任务 ID(固定) → 创建时间 → 模型 → 输出类型 → 状态 → 提示词 →
|
||||||
|
// 时长(视频) → 费用 → 供应商 → 帧率/文件大小/标签/错误信息 → 用户(企业版)
|
||||||
|
//
|
||||||
|
// 可见性:通过标题栏齿轮图标控制,持久化到 localStorage
|
||||||
|
//
|
||||||
|
// ⚠️ 重要:columns 依赖中不出 token.colorPrimary,否则主题切换时 columns 重新生成
|
||||||
|
// 会导致 Table 内部状态重置(页码、筛选、排序等丢失)。
|
||||||
|
// 主题色通过 searchIconColorRef 传递,不触发 columns 重建。
|
||||||
|
|
||||||
|
/** 提示词搜索图标颜色(通过 CSS class 承载,避免 columns 依赖 token 导致主题切换重置状态) */
|
||||||
|
|
||||||
|
/** 提取视频时长(秒),非视频输出类型返回 null → 渲染 "-" */
|
||||||
|
function getVideoDuration(record: TaskInfoItemResponseBody): number | null {
|
||||||
|
if (record.output_type === 'video') {
|
||||||
|
return record.ui_params.duration ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化时长:<60s 显示秒,≥60s 显示分秒 */
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
if (seconds < 60) return `${seconds} 秒`;
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
return s > 0 ? `${m} 分 ${s} 秒` : `${m} 分钟`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allColumns = useMemo<ColumnsType<TaskInfoItemResponseBody>>(() => {
|
||||||
|
const cols: ColumnsType<TaskInfoItemResponseBody> = [
|
||||||
|
// ---- 任务 ID(固定列) ----
|
||||||
|
{
|
||||||
|
title: '任务 ID',
|
||||||
|
dataIndex: 'id',
|
||||||
|
key: 'id',
|
||||||
|
width: 140,
|
||||||
|
ellipsis: true,
|
||||||
|
fixed: 'left',
|
||||||
|
render: (id: string) => (
|
||||||
|
<Text copyable={{ text: id }} style={{ fontSize: 12, fontFamily: 'monospace' }}>
|
||||||
|
{id.slice(0, 10)}...
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// ---- 创建时间 ----
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
key: 'created_at',
|
||||||
|
width: 155,
|
||||||
|
sorter: (a, b) => {
|
||||||
|
const da = a.created_at ? new Date(a.created_at).getTime() : 0;
|
||||||
|
const db = b.created_at ? new Date(b.created_at).getTime() : 0;
|
||||||
|
return da - db;
|
||||||
|
},
|
||||||
|
sortOrder: tableParams.sorter.field === 'created_at' ? tableParams.sorter.order : null,
|
||||||
|
render: (v: Date) => (
|
||||||
|
<Text style={{ fontSize: 11 }}>
|
||||||
|
{v ? new Date(v).toLocaleString('zh-CN', {
|
||||||
|
month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
|
}) : '-'}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// ---- 模型 ----
|
||||||
|
{
|
||||||
|
title: '模型',
|
||||||
|
dataIndex: 'model_name',
|
||||||
|
key: 'model_name',
|
||||||
|
width: 130,
|
||||||
|
ellipsis: true,
|
||||||
|
filters: modelFilters,
|
||||||
|
filteredValue: (tableParams.filters.model_name as string[]) || null,
|
||||||
|
onFilter: () => true, // 服务端筛选
|
||||||
|
filterSearch: true,
|
||||||
|
filterMode: 'menu' as const,
|
||||||
|
},
|
||||||
|
// ---- 输出类型 ----
|
||||||
|
{
|
||||||
|
title: '输出类型',
|
||||||
|
dataIndex: 'output_type',
|
||||||
|
key: 'output_type',
|
||||||
|
width: 90,
|
||||||
|
filters: OUTPUT_TYPE_FILTERS,
|
||||||
|
filteredValue: (tableParams.filters.output_type as string[]) || null,
|
||||||
|
onFilter: (value, record) => record.output_type === value, // 客户端筛选
|
||||||
|
render: (t: string) => (
|
||||||
|
<Tag style={{ fontSize: 10 }}>
|
||||||
|
{OutputTypeMap[t as keyof typeof OutputTypeMap] || t}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// ---- 状态 ----
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
width: 80,
|
||||||
|
filters: STATUS_FILTERS,
|
||||||
|
filteredValue: (tableParams.filters.status as string[]) || null,
|
||||||
|
onFilter: () => true, // 服务端筛选
|
||||||
|
render: (s: string) => {
|
||||||
|
const cfg = resolveStatus(s);
|
||||||
|
return <Tag color={cfg.color} style={{ fontSize: 10 }}>{cfg.label}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// ---- 提示词 ----
|
||||||
|
{
|
||||||
|
title: '提示词',
|
||||||
|
dataIndex: 'prompt',
|
||||||
|
key: 'prompt',
|
||||||
|
width: 160,
|
||||||
|
ellipsis: true,
|
||||||
|
filterIcon: (filtered: boolean) => (
|
||||||
|
<SearchOutlined className={`prompt-search-icon${filtered ? ' filtered' : ''}`} style={{ fontSize: 12 }} />
|
||||||
|
),
|
||||||
|
filterDropdown: undefined, // TODO: 后续加自定义搜索框
|
||||||
|
render: (p: string | undefined) => (
|
||||||
|
<Text style={{ fontSize: 11 }} title={p}>{p || '-'}</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// ---- 时长(仅视频有数据,否则 "-") ----
|
||||||
|
{
|
||||||
|
title: '时长',
|
||||||
|
key: 'duration',
|
||||||
|
width: 75,
|
||||||
|
sorter: (a, b) => {
|
||||||
|
const da = getVideoDuration(a) ?? -1;
|
||||||
|
const db = getVideoDuration(b) ?? -1;
|
||||||
|
return da - db;
|
||||||
|
},
|
||||||
|
sortOrder: tableParams.sorter.field === 'duration' ? tableParams.sorter.order : null,
|
||||||
|
render: (_: unknown, record: TaskInfoItemResponseBody) => {
|
||||||
|
const sec = getVideoDuration(record);
|
||||||
|
return <Text style={{ fontSize: 11 }}>{sec !== null ? formatDuration(sec) : '-'}</Text>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// ---- 费用 ----
|
||||||
|
{
|
||||||
|
title: '费用',
|
||||||
|
dataIndex: 'cost_cent',
|
||||||
|
key: 'cost_cent',
|
||||||
|
width: 75,
|
||||||
|
sorter: (a, b) => a.cost_cent - b.cost_cent,
|
||||||
|
sortOrder: tableParams.sorter.field === 'cost_cent' ? tableParams.sorter.order : null,
|
||||||
|
render: (c: number) => (
|
||||||
|
<Text style={{ fontSize: 11 }}>¥{(c / 100).toFixed(2)}</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// ---- 供应商(默认隐藏) ----
|
||||||
|
{
|
||||||
|
title: '供应商',
|
||||||
|
dataIndex: 'provider_name',
|
||||||
|
key: 'provider_name',
|
||||||
|
width: 100,
|
||||||
|
ellipsis: true,
|
||||||
|
filters: providerFilters,
|
||||||
|
filteredValue: (tableParams.filters.provider_name as string[]) || null,
|
||||||
|
onFilter: (value, record) => record.provider_name === value, // 客户端筛选
|
||||||
|
filterSearch: true,
|
||||||
|
},
|
||||||
|
// ---- 帧率(占位列,待后端支持) ----
|
||||||
|
{
|
||||||
|
title: '帧率',
|
||||||
|
key: 'fps',
|
||||||
|
width: 70,
|
||||||
|
render: () => <Text type="secondary" style={{ fontSize: 11 }}>-</Text>,
|
||||||
|
},
|
||||||
|
// ---- 文件大小(占位列,待后端支持) ----
|
||||||
|
{
|
||||||
|
title: '文件大小',
|
||||||
|
key: 'file_size',
|
||||||
|
width: 85,
|
||||||
|
render: () => <Text type="secondary" style={{ fontSize: 11 }}>-</Text>,
|
||||||
|
},
|
||||||
|
// ---- 标签 ----
|
||||||
|
{
|
||||||
|
title: '标签',
|
||||||
|
dataIndex: 'tags',
|
||||||
|
key: 'tags',
|
||||||
|
width: 100,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (tags: Array<string | null>) => {
|
||||||
|
const filtered = tags?.filter((t): t is string => t !== null) ?? [];
|
||||||
|
if (filtered.length === 0) return <Text type="secondary" style={{ fontSize: 11 }}>-</Text>;
|
||||||
|
return (
|
||||||
|
<Space size={2} wrap>
|
||||||
|
{filtered.slice(0, 2).map((t) => (
|
||||||
|
<Tag key={t} style={{ fontSize: 9, lineHeight: '14px', margin: 0 }}>{t}</Tag>
|
||||||
|
))}
|
||||||
|
{filtered.length > 2 && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 9 }}>+{filtered.length - 2}</Text>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// ---- 错误信息(失败任务时展示) ----
|
||||||
|
{
|
||||||
|
title: '错误信息',
|
||||||
|
dataIndex: 'error_message',
|
||||||
|
key: 'error_message',
|
||||||
|
width: 140,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (msg: string) => msg
|
||||||
|
? <Text type="danger" style={{ fontSize: 11 }} title={msg}>{msg}</Text>
|
||||||
|
: <Text type="secondary" style={{ fontSize: 11 }}>-</Text>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 用户列:仅企业版可筛选
|
||||||
|
if (isEnterprise) {
|
||||||
|
cols.push({
|
||||||
|
title: '用户',
|
||||||
|
dataIndex: 'user_display_name',
|
||||||
|
key: 'user_id',
|
||||||
|
width: 100,
|
||||||
|
ellipsis: true,
|
||||||
|
filters: userFilters,
|
||||||
|
filteredValue: (tableParams.filters.user_id as string[]) || null,
|
||||||
|
onFilter: () => true, // 服务端筛选(传 user_ids)
|
||||||
|
render: (n: string | null) => (
|
||||||
|
<Text style={{ fontSize: 11 }}>{n || '-'}</Text>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
cols.push({
|
||||||
|
title: '用户',
|
||||||
|
dataIndex: 'user_display_name',
|
||||||
|
key: 'user_id',
|
||||||
|
width: 100,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (n: string | null) => (
|
||||||
|
<Text style={{ fontSize: 11 }}>{n || '-'}</Text>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return cols;
|
||||||
|
}, [
|
||||||
|
modelFilters,
|
||||||
|
providerFilters,
|
||||||
|
userFilters,
|
||||||
|
tableParams.filters,
|
||||||
|
tableParams.sorter,
|
||||||
|
isEnterprise,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 按 visibleColumns 过滤实际展示的列
|
||||||
|
const columns = useMemo(
|
||||||
|
() => allColumns.filter((c) => visibleColumns.has(c.key as string)),
|
||||||
|
[allColumns, visibleColumns],
|
||||||
|
);
|
||||||
|
|
||||||
|
// -------- 表格高度 --------
|
||||||
|
//
|
||||||
|
// 核心策略:固定预留 pagination 区域高度,确保无论数据加载时机如何,
|
||||||
|
// 分页器始终有空间渲染,不会溢出可视区。
|
||||||
|
//
|
||||||
|
// scroll.y = wrapper高度 - thead高度 - PAGINATION_RESERVE - 水平滚动条高度
|
||||||
|
//
|
||||||
|
// 为什么固定预留 pagination 而不是动态测量?
|
||||||
|
// - 无数据/加载中时 pagination DOM 尚不存在 → offsetHeight=0 → scroll.y 偏大
|
||||||
|
// - 翻页/切换 pageSize 时 pagination 异步渲染 → 测量时机不可控
|
||||||
|
// - 固定预留值(48px 覆盖 small-size pagination + 上下间距)避免上述所有时序问题
|
||||||
|
|
||||||
|
/** 预留 pagination 高度(small-size: ~32px + 上下 padding/margin 8px × 2) */
|
||||||
|
const PAGINATION_RESERVE = 48;
|
||||||
|
|
||||||
|
const tableWrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [tableBodyHeight, setTableBodyHeight] = useState(400);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const wrapper = tableWrapperRef.current;
|
||||||
|
if (!wrapper) return;
|
||||||
|
|
||||||
|
const calcHeight = () => {
|
||||||
|
const thead = wrapper.querySelector('.ant-table-thead') as HTMLElement | null;
|
||||||
|
const body = wrapper.querySelector('.ant-table-body') as HTMLElement | null;
|
||||||
|
const theadH = thead?.offsetHeight ?? 0;
|
||||||
|
|
||||||
|
// 水平滚动条占用高度(antd Table scroll.x 触发时出现)
|
||||||
|
let hScrollH = 0;
|
||||||
|
if (body && body.scrollWidth > body.clientWidth + 1) {
|
||||||
|
hScrollH = body.offsetHeight - body.clientHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const available = wrapper.clientHeight - theadH - PAGINATION_RESERVE - hScrollH;
|
||||||
|
setTableBodyHeight(Math.max(120, available));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ResizeObserver:容器尺寸变化(窗口拉伸、面板拖拽)
|
||||||
|
const resizeObserver = new ResizeObserver(calcHeight);
|
||||||
|
resizeObserver.observe(wrapper);
|
||||||
|
|
||||||
|
// MutationObserver:内部 DOM 变化(thead 高度变化如筛选菜单弹出、
|
||||||
|
// 数据加载后水平滚动条出现等)
|
||||||
|
const mutationObserver = new MutationObserver(calcHeight);
|
||||||
|
mutationObserver.observe(wrapper, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
calcHeight();
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
mutationObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, [taskPageSize]);
|
||||||
|
|
||||||
|
// -------- 行样式:斑马纹 + 选中行 --------
|
||||||
|
|
||||||
|
const rowClassName = (_record: TaskInfoItemResponseBody, index: number) => {
|
||||||
|
const base = index % 2 === 1 ? 'task-row-odd' : 'task-row-even';
|
||||||
|
return selectedTask?.id === _record.id ? `${base} task-row-selected` : base;
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------- 是否有活跃筛选 --------
|
||||||
|
|
||||||
|
const hasActiveFilters = Object.values(tableParams.filters).some(
|
||||||
|
(v) => Array.isArray(v) && v.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ======== 渲染 ========
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
|
||||||
|
{/* 标题栏 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HistoryOutlined style={{ fontSize: 14 }} />
|
||||||
|
<Text strong style={{ fontSize: 13 }}>任务记录</Text>
|
||||||
|
{total > 0 && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>共 {total} 条</Text>
|
||||||
|
)}
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<Tag color="blue" style={{ fontSize: 10, lineHeight: '16px', padding: '0 4px' }}>
|
||||||
|
已筛选
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
{errorMessage && (
|
||||||
|
<Text type="danger" style={{ fontSize: 11, marginLeft: 'auto' }}>{errorMessage}</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 列可见性控制 — 齿轮图标 */}
|
||||||
|
<Popover
|
||||||
|
trigger="click"
|
||||||
|
placement="bottomRight"
|
||||||
|
title="显示/隐藏列"
|
||||||
|
content={
|
||||||
|
<Checkbox.Group
|
||||||
|
value={[...visibleColumns]}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: 2 }}
|
||||||
|
>
|
||||||
|
{ALL_COLUMNS.map((col) => (
|
||||||
|
<Checkbox
|
||||||
|
key={col.key}
|
||||||
|
value={col.key}
|
||||||
|
disabled={col.key === 'id'}
|
||||||
|
onChange={(e) => handleColumnToggle(col.key, e.target.checked)}
|
||||||
|
>
|
||||||
|
{col.title}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</Checkbox.Group>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<SettingOutlined style={{ fontSize: 13 }} />}
|
||||||
|
style={{ marginLeft: 'auto' }}
|
||||||
|
title="列设置"
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 表格 — 横向 + 纵向滚动,筛选/排序通过列头操作 */}
|
||||||
|
<div ref={tableWrapperRef} style={{ flex: 1, overflow: 'hidden', minHeight: Math.min(window.outerHeight, window.outerHeight, 630) }}>
|
||||||
|
<style>{tableCss}</style>
|
||||||
|
<Table<TaskInfoItemResponseBody>
|
||||||
|
className="task-table"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={polledTasks}
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
loading={loading}
|
||||||
|
showHeader={true}
|
||||||
|
scroll={{ x: isEnterprise ? 1350 : 1250, y: tableBodyHeight }}
|
||||||
|
rowClassName={rowClassName}
|
||||||
|
onRow={(record) => ({
|
||||||
|
onClick: () => setSelectedTask(record),
|
||||||
|
style: { cursor: 'pointer' },
|
||||||
|
})}
|
||||||
|
onChange={handleTableChange}
|
||||||
|
pagination={{
|
||||||
|
current: tableParams.pagination.current,
|
||||||
|
pageSize: tableParams.pagination.pageSize,
|
||||||
|
total,
|
||||||
|
size: 'small',
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: ['10', '20', '50'],
|
||||||
|
placement: ['bottomCenter'],
|
||||||
|
style: { marginBottom: 0 },
|
||||||
|
}}
|
||||||
|
locale={{ emptyText: '暂无任务记录', filterReset: '重置', filterConfirm: '确定' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
src/pages/home/hooks/useModelUI.ts
Normal file
184
src/pages/home/hooks/useModelUI.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
// ============================================================
|
||||||
|
// useModelUI — param_schema → UIFieldConfig[] 转换 Hook
|
||||||
|
//
|
||||||
|
// 职责:
|
||||||
|
// - 遍历模型的 param_schema,解析 spec/ui 字段
|
||||||
|
// - 查 WIDGET_MAP 获取对应的 React 组件
|
||||||
|
// - 解析 constraints_config.requires 计算字段间依赖
|
||||||
|
// - 组装出可直接渲染的 UIFieldConfig 数组
|
||||||
|
//
|
||||||
|
// ModelInputForm 只需遍历 fields 渲染 Form.Item 即可,
|
||||||
|
// 不需要关心 param_schema 的解析逻辑。
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { ComponentType } from 'react';
|
||||||
|
|
||||||
|
import type { ModelsListResponseBody } from '@/services/modules/models';
|
||||||
|
import { WIDGET_MAP, FALLBACK_WIDGET } from '../controls';
|
||||||
|
import type { WidgetProps } from '../controls';
|
||||||
|
|
||||||
|
// ---------- 输出类型 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个字段的 UI 配置。
|
||||||
|
*
|
||||||
|
* ModelInputForm 遍历此数组,每一项渲染一个 Form.Item。
|
||||||
|
*/
|
||||||
|
export interface UIFieldConfig {
|
||||||
|
/** 渲染顺序(param_schema 中的索引,保证稳定) */
|
||||||
|
index: number;
|
||||||
|
/** 表单字段名(map_to),对应 Form.Item name */
|
||||||
|
name: string;
|
||||||
|
/** 要渲染的 React 组件 */
|
||||||
|
component: ComponentType<WidgetProps>;
|
||||||
|
/** 显示标签(ui.label → Form.Item label) */
|
||||||
|
label: string;
|
||||||
|
/** 是否必填(spec.required → Form.Item rules) */
|
||||||
|
must: boolean;
|
||||||
|
/** 默认值(spec.default → Form initialValues) */
|
||||||
|
defaultValue?: unknown;
|
||||||
|
/** 提示文本(ui.tips) */
|
||||||
|
tips?: string;
|
||||||
|
/** Form.Item 的 valuePropName(checkbox 用 'checked',其他用默认 'value') */
|
||||||
|
valuePropName?: string;
|
||||||
|
/** 是否为文件上传类控件(需要 getValueFromEvent 提取 fileList) */
|
||||||
|
isFileWidget: boolean;
|
||||||
|
/**
|
||||||
|
* 条件依赖:当这些字段的值为空时,本字段应被禁用。
|
||||||
|
*
|
||||||
|
* 来源于 constraints_config.requires 的逆向推导:
|
||||||
|
* { if: "sref", then: ["sw", "sv"] }
|
||||||
|
* → sw.dependsOn = ["sref"], sv.dependsOn = ["sref"]
|
||||||
|
*/
|
||||||
|
dependsOn?: string[];
|
||||||
|
/** 传给 widget 组件的配置(placeholder/options/min/max 等) */
|
||||||
|
widgetConfig: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 文件类型 widget 集合 ----------
|
||||||
|
|
||||||
|
const FILE_WIDGETS = new Set(['imageInput', 'imageList', 'audioList']);
|
||||||
|
|
||||||
|
// ---------- 约束解析 ----------
|
||||||
|
|
||||||
|
interface ConstraintRule {
|
||||||
|
if: string;
|
||||||
|
then: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 constraints_config.requires 构建反向依赖表。
|
||||||
|
*
|
||||||
|
* 输入:
|
||||||
|
* [{ if: "sref", then: ["sw", "sv"] }]
|
||||||
|
*
|
||||||
|
* 输出:
|
||||||
|
* { sw: ["sref"], sv: ["sref"] }
|
||||||
|
*
|
||||||
|
* 含义:sw 和 sv 依赖 sref,sref 为空时它们应被禁用。
|
||||||
|
*/
|
||||||
|
function buildDependencyMap(
|
||||||
|
constraintsConfig: Record<string, unknown>,
|
||||||
|
): Map<string, string[]> {
|
||||||
|
const depMap = new Map<string, string[]>();
|
||||||
|
const requires = constraintsConfig.requires as ConstraintRule[] | undefined;
|
||||||
|
|
||||||
|
if (!requires || !Array.isArray(requires)) return depMap;
|
||||||
|
|
||||||
|
for (const rule of requires) {
|
||||||
|
if (!rule.if || !rule.then || !Array.isArray(rule.then)) continue;
|
||||||
|
for (const target of rule.then) {
|
||||||
|
const existing = depMap.get(target) || [];
|
||||||
|
existing.push(rule.if);
|
||||||
|
depMap.set(target, existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return depMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Hook ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据模型的 param_schema 和 constraints_config 构建 UI 字段配置数组。
|
||||||
|
*
|
||||||
|
* @param paramSchema — 来自 selectedModel.param_schema
|
||||||
|
* @param constraintsConfig — 来自 selectedModel.constraints_config
|
||||||
|
* @returns UIFieldConfig[] — 渲染就绪的字段配置列表
|
||||||
|
*/
|
||||||
|
export function useModelUI(
|
||||||
|
paramSchema: ModelsListResponseBody['param_schema'],
|
||||||
|
constraintsConfig: Record<string, unknown> = {},
|
||||||
|
): UIFieldConfig[] {
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!paramSchema || paramSchema.length === 0) return [];
|
||||||
|
|
||||||
|
const depMap = buildDependencyMap(constraintsConfig);
|
||||||
|
|
||||||
|
return paramSchema.map((item, index) => {
|
||||||
|
const spec = (item.spec || {}) as Record<string, unknown>;
|
||||||
|
const ui = (item.ui || {}) as Record<string, unknown>;
|
||||||
|
const widget = (ui.widget as string) || 'lineEdit';
|
||||||
|
|
||||||
|
const component = WIDGET_MAP[widget] || FALLBACK_WIDGET;
|
||||||
|
|
||||||
|
const widgetConfig: Record<string, unknown> = {
|
||||||
|
placeholder: ui.placeholder || `请输入${ui.label || item.map_to}`,
|
||||||
|
required: spec.required,
|
||||||
|
enumValues: spec.enum,
|
||||||
|
minValue: spec.min_value,
|
||||||
|
maxValue: spec.max_value,
|
||||||
|
step: spec.single_step,
|
||||||
|
minLength: spec.min_length,
|
||||||
|
maxLength: spec.max_length,
|
||||||
|
// imageInput / imageList / audioList
|
||||||
|
fileFilter: spec.file_filter,
|
||||||
|
maxFileSizeMb: spec.max_file_size_mb,
|
||||||
|
minFiles: spec.min_files,
|
||||||
|
maxFiles: spec.max_files,
|
||||||
|
enableSwitch: ui.enable_switch,
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFile = FILE_WIDGETS.has(widget);
|
||||||
|
const isCheckbox = widget === 'checkbox';
|
||||||
|
const name = item.map_to;
|
||||||
|
const dependsOn = depMap.get(name);
|
||||||
|
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
name,
|
||||||
|
component,
|
||||||
|
label: (ui.label as string) || name,
|
||||||
|
must: (spec.required as boolean) || false,
|
||||||
|
defaultValue: spec.default,
|
||||||
|
tips: (ui.tips as string) || undefined,
|
||||||
|
valuePropName: isCheckbox ? 'checked' : undefined,
|
||||||
|
isFileWidget: isFile,
|
||||||
|
dependsOn: dependsOn && dependsOn.length > 0 ? dependsOn : undefined,
|
||||||
|
widgetConfig,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [paramSchema, constraintsConfig]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断 widget 类型是否为文件上传类控件 */
|
||||||
|
export function isFileWidget(widgetName: string): boolean {
|
||||||
|
return FILE_WIDGETS.has(widgetName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 值判空工具 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断表单值是否为"空"(用于依赖禁用判断)。
|
||||||
|
*
|
||||||
|
* 注意:空数组 [] 不视为空。
|
||||||
|
* 对于 enable_switch 控件:undefined = 开关关闭, [] = 开关开启但未选文件。
|
||||||
|
* 配合 constraints_config.requires:开关开启即解锁依赖字段,无需等待文件上传。
|
||||||
|
*/
|
||||||
|
export function isValueEmpty(value: unknown): boolean {
|
||||||
|
if (value === undefined || value === null) return true;
|
||||||
|
if (typeof value === 'string' && value.trim() === '') return true;
|
||||||
|
if (typeof value === 'boolean' && !value) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
23
src/pages/home/panels/CenterPanel.tsx
Normal file
23
src/pages/home/panels/CenterPanel.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// ============================================================
|
||||||
|
// CenterPanel — 中列容器(模型输入表单)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { theme as antTheme } from 'antd';
|
||||||
|
import { ModelInputForm } from '../features/ModelInputForm';
|
||||||
|
|
||||||
|
export function CenterPanel() {
|
||||||
|
const { token } = antTheme.useToken();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModelInputForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
189
src/pages/home/panels/LeftPanel.tsx
Normal file
189
src/pages/home/panels/LeftPanel.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
// ============================================================
|
||||||
|
// LeftPanel — 左列容器(模型选择 + 可拖拽分割线 + 任务搜索 + 任务记录)
|
||||||
|
// 纵向拖拽分隔线支持鼠标调整上下区域高度比例
|
||||||
|
//
|
||||||
|
// 职责:布局 + edition 分发,子组件自行管理数据和搜索状态
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import { theme as antTheme } from 'antd';
|
||||||
|
import { ModelSelector } from '../features/ModelSelector';
|
||||||
|
import { TaskHistory } from '../features/TaskHistory';
|
||||||
|
import { useModelList } from '@/hooks/use-api';
|
||||||
|
|
||||||
|
// ---------- 常量 ----------
|
||||||
|
|
||||||
|
/** 模型选择区最小高度 */
|
||||||
|
const TOP_MIN = 120;
|
||||||
|
/** 任务列表区最小高度 */
|
||||||
|
const BOTTOM_MIN = 200;
|
||||||
|
/** 默认模型选择区百分比 */
|
||||||
|
const TOP_DEFAULT_PCT = 0.40;
|
||||||
|
/** 拖拽分隔条高度 */
|
||||||
|
const DIVIDER_HEIGHT = 6;
|
||||||
|
|
||||||
|
// ---------- 组件 ----------
|
||||||
|
|
||||||
|
export function LeftPanel() {
|
||||||
|
const { token } = antTheme.useToken();
|
||||||
|
|
||||||
|
// 面板容器引用
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// ======== 模型数据(提升到父组件,避免两个子组件重复请求) ========
|
||||||
|
const { data: models, loading, error, groupedModels, refetch } = useModelList();
|
||||||
|
|
||||||
|
// 模型选择区高度(像素)
|
||||||
|
const [topHeight, setTopHeight] = useState<number | null>(null);
|
||||||
|
// 拖拽状态
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
// hover 状态
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
const dragState = useRef<{
|
||||||
|
startY: number;
|
||||||
|
startHeight: number;
|
||||||
|
}>({ startY: 0, startHeight: 0 });
|
||||||
|
|
||||||
|
// 初始化:按百分比计算顶部高度
|
||||||
|
const initHeight = useCallback(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const total = el.clientHeight;
|
||||||
|
setTopHeight(Math.max(TOP_MIN, Math.round(total * TOP_DEFAULT_PCT)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initHeight();
|
||||||
|
}, [initHeight]);
|
||||||
|
|
||||||
|
// 响应容器 resize(窗口大小变化时重新按比例计算)
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
if (!dragging) {
|
||||||
|
const total = el.clientHeight;
|
||||||
|
setTopHeight((prev) => {
|
||||||
|
if (prev === null) return Math.max(TOP_MIN, Math.round(total * TOP_DEFAULT_PCT));
|
||||||
|
// 保持原有比例
|
||||||
|
const ratio = prev / total;
|
||||||
|
return Math.max(TOP_MIN, Math.round(total * Math.min(ratio, 0.8)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [dragging]);
|
||||||
|
|
||||||
|
// 拖拽事件
|
||||||
|
const handleDividerMouseDown = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragState.current = { startY: e.clientY, startHeight: topHeight ?? 200 };
|
||||||
|
setDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!dragging) return;
|
||||||
|
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const total = el.clientHeight;
|
||||||
|
const ds = dragState.current;
|
||||||
|
const delta = e.clientY - ds.startY;
|
||||||
|
const newTop = Math.max(TOP_MIN, Math.min(total - BOTTOM_MIN, ds.startHeight + delta));
|
||||||
|
setTopHeight(newTop);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [dragging]);
|
||||||
|
|
||||||
|
// 分隔条样式
|
||||||
|
const isActive = dragging;
|
||||||
|
const isHovered = hovered;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 上方:模型选择(高度由拖拽控制) */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: topHeight ?? '40%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModelSelector
|
||||||
|
models={models}
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
groupedModels={groupedModels}
|
||||||
|
refetch={refetch}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 纵向拖拽分隔条 */}
|
||||||
|
<div
|
||||||
|
onMouseDown={handleDividerMouseDown}
|
||||||
|
style={{
|
||||||
|
height: DIVIDER_HEIGHT,
|
||||||
|
flexShrink: 0,
|
||||||
|
cursor: 'row-resize',
|
||||||
|
background: isActive
|
||||||
|
? token.colorPrimary
|
||||||
|
: isHovered
|
||||||
|
? `${token.colorPrimary}60`
|
||||||
|
: token.colorBorderSecondary,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
transition: 'background 0.15s linear',
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
>
|
||||||
|
{/* 拖拽手柄横线 */}
|
||||||
|
<div style={{
|
||||||
|
height: 2,
|
||||||
|
width: 32,
|
||||||
|
borderRadius: 1,
|
||||||
|
background: isActive
|
||||||
|
? '#fff'
|
||||||
|
: isHovered
|
||||||
|
? token.colorPrimary
|
||||||
|
: token.colorBorder,
|
||||||
|
transition: 'background 0.15s linear',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 下方:任务记录(剩余高度) */}
|
||||||
|
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||||
|
<TaskHistory allModels={models} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/pages/home/panels/RightPanel.tsx
Normal file
23
src/pages/home/panels/RightPanel.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// ============================================================
|
||||||
|
// RightPanel — 右列容器(输出预览)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { theme as antTheme } from 'antd';
|
||||||
|
import { OutputPreview } from '../features/OutputPreview';
|
||||||
|
|
||||||
|
export function RightPanel() {
|
||||||
|
const { token } = antTheme.useToken();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<OutputPreview />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// useAuthState — 记住账号 / 自动登录 状态管理
|
|
||||||
//
|
|
||||||
// "记住密码":下次打开页面自动回填用户名(不再存储密码)
|
|
||||||
// "自动登录":登录成功时标记,AppProvider 启动时用 refresh_token 静默续期
|
|
||||||
//
|
|
||||||
// 安全:
|
|
||||||
// - 密码从不持久化存储(access_token / refresh_token 由 safeStorage 加密保护)
|
|
||||||
// - 安全性由 refresh_token + access_token 的 JWT 双重机制 + OS 密钥链保障
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'ele-heixiu-auth';
|
|
||||||
|
|
||||||
interface StoredAuth {
|
|
||||||
/** 上次登录的用户名(表单回填用) */
|
|
||||||
username: string;
|
|
||||||
/** 是否记住用户名(勾选后表单自动回填用户名) */
|
|
||||||
remember: boolean;
|
|
||||||
/** 是否勾选了自动登录(AppProvider 据此决定启动时是否刷新 token) */
|
|
||||||
autoLogin: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readAuth(): StoredAuth | null {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (!raw) return null;
|
|
||||||
return JSON.parse(raw) as StoredAuth;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeAuth(auth: StoredAuth): void {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(auth));
|
|
||||||
} catch {
|
|
||||||
/* 静默 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 工具函数(不依赖 React,AppProvider 可直接调用)----------
|
|
||||||
|
|
||||||
/** 读取自动登录标记 */
|
|
||||||
export function getAutoLoginFlag(): boolean {
|
|
||||||
return readAuth()?.autoLogin || false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 仅清除自动登录标记(保留用户名,下次还能回填表单) */
|
|
||||||
export function clearAutoLoginFlag(): void {
|
|
||||||
const auth = readAuth();
|
|
||||||
if (!auth) return;
|
|
||||||
writeAuth({ ...auth, autoLogin: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 清除所有保存的认证信息 */
|
|
||||||
export function clearSavedAuth(): void {
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
|
||||||
} catch {
|
|
||||||
/* 静默 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Hook ----------
|
|
||||||
|
|
||||||
export function useAuthState() {
|
|
||||||
const saved = readAuth();
|
|
||||||
|
|
||||||
const [username, setUsername] = useState(saved?.username || '');
|
|
||||||
const [remember, setRemember] = useState(saved?.remember || false);
|
|
||||||
const [autoLogin, setAutoLogin] = useState(saved?.autoLogin || false);
|
|
||||||
|
|
||||||
const handleRememberChange = useCallback((checked: boolean) => {
|
|
||||||
setRemember(checked);
|
|
||||||
if (!checked) {
|
|
||||||
setAutoLogin(false);
|
|
||||||
clearSavedAuth();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAutoLoginChange = useCallback((checked: boolean) => {
|
|
||||||
setAutoLogin(checked);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 登录成功后调用。
|
|
||||||
* 参数由调用方(LoginPage)直接从表单值传入,避免 useCallback 的 stale closure。
|
|
||||||
*
|
|
||||||
* @param user - 用户名
|
|
||||||
* @param _password - 明文密码(不再持久化存储,保留参数以兼容调用方)
|
|
||||||
* @param doRemember - 是否勾选"记住用户名"
|
|
||||||
* @param doAutoLogin - 是否勾选"自动登录"
|
|
||||||
*/
|
|
||||||
const saveAuth = useCallback(
|
|
||||||
(user: string, _password: string, doRemember: boolean, doAutoLogin: boolean) => {
|
|
||||||
setUsername(user);
|
|
||||||
if (doRemember || doAutoLogin) {
|
|
||||||
const payload: StoredAuth = {
|
|
||||||
username: user,
|
|
||||||
remember: doRemember,
|
|
||||||
autoLogin: doAutoLogin,
|
|
||||||
};
|
|
||||||
writeAuth(payload);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
username,
|
|
||||||
remember,
|
|
||||||
autoLogin,
|
|
||||||
setUsername,
|
|
||||||
handleRememberChange,
|
|
||||||
handleAutoLoginChange,
|
|
||||||
saveAuth,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
203
src/pages/panels/AccountInfo.tsx
Normal file
203
src/pages/panels/AccountInfo.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Descriptions,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
Skeleton,
|
||||||
|
Result,
|
||||||
|
Space,
|
||||||
|
Tag,
|
||||||
|
Divider,
|
||||||
|
theme as antTheme,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
UserOutlined,
|
||||||
|
WalletOutlined,
|
||||||
|
CrownOutlined,
|
||||||
|
BarChartOutlined,
|
||||||
|
RightOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import {EnumResolver} from '@/utils/enum-resolver';
|
||||||
|
import type { VipLevelsItem } from '@/services/modules/vip-api';
|
||||||
|
import { useAccountInfo } from '@/hooks/use-api';
|
||||||
|
import { centToYuan, formatDate } from '@/utils/display';
|
||||||
|
|
||||||
|
const {Text, Title} = Typography;
|
||||||
|
|
||||||
|
export function AccountInfo() {
|
||||||
|
const {token} = antTheme.useToken();
|
||||||
|
const { data, loading, error, errorMessage, refetch } = useAccountInfo();
|
||||||
|
|
||||||
|
// ======== 加载态 ========
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{padding: 24}}>
|
||||||
|
<Skeleton active paragraph={{rows: 6}} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== 错误态 ========
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title="加载失败"
|
||||||
|
subTitle={errorMessage || '未能获取账户信息'}
|
||||||
|
extra={
|
||||||
|
<Button type="primary" onClick={refetch}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== 数据展示 ========
|
||||||
|
const [userInfo, vipsInfo] = data;
|
||||||
|
const totalBalance: number = userInfo.balance_cent + userInfo.gift_balance_cent;
|
||||||
|
|
||||||
|
// 匹配当前 VIP 等级(vip_level_id → VipLevelsItem)
|
||||||
|
const matchedVip: VipLevelsItem | null =
|
||||||
|
userInfo.vip_level_id != null && vipsInfo
|
||||||
|
? (vipsInfo.find((v) => v.id === userInfo.vip_level_id) ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const isExpired = new Date(userInfo.software_expires_at) < new Date();
|
||||||
|
return (
|
||||||
|
<div style={{padding: 24, maxWidth: 640}}>
|
||||||
|
{/* ======== 标题:账户 ======== */}
|
||||||
|
<div style={{display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16}}>
|
||||||
|
<Space align="center" size={12}>
|
||||||
|
<UserOutlined style={{fontSize: 22, color: token.colorPrimary}} />
|
||||||
|
<div>
|
||||||
|
<Title level={4} style={{margin: 0}}>
|
||||||
|
{userInfo.display_name || userInfo.real_name || userInfo.username || userInfo.id}
|
||||||
|
{userInfo.account_type && (
|
||||||
|
<Tag color="gold" style={{lineHeight: '18px', fontSize: 11}}>
|
||||||
|
{EnumResolver.auth.accountType.label(userInfo.account_type)}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{fontSize: 12}}>
|
||||||
|
ID: {userInfo.id}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
<Button type="primary" ghost size="small" icon={<WalletOutlined />}>
|
||||||
|
充值
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ======== 积分栏 ======== */}
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
styles={{
|
||||||
|
body: {padding: '16px 20px'},
|
||||||
|
}}
|
||||||
|
style={{marginBottom: 16}}
|
||||||
|
>
|
||||||
|
<Text type="secondary" style={{fontSize: 12, marginBottom: 8, display: 'block'}}>
|
||||||
|
账户总积分
|
||||||
|
</Text>
|
||||||
|
<Title level={3} style={{margin: '0 0 12px 0'}}>
|
||||||
|
{centToYuan(totalBalance)}
|
||||||
|
<Text type="secondary" style={{fontSize: 14, marginLeft: 4}}>积分</Text>
|
||||||
|
</Title>
|
||||||
|
<Space size={32}>
|
||||||
|
<div>
|
||||||
|
<Text type="secondary" style={{fontSize: 12}}>主积分</Text>
|
||||||
|
<br />
|
||||||
|
<Text strong>{centToYuan(userInfo.balance_cent)}</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text type="secondary" style={{fontSize: 12}}>赠送积分</Text>
|
||||||
|
<br />
|
||||||
|
<Text strong>{centToYuan(userInfo.gift_balance_cent)}</Text>
|
||||||
|
</div>
|
||||||
|
{userInfo.discount_rate && userInfo.discount_rate > 0 && userInfo.discount_rate < 1 && (
|
||||||
|
<div>
|
||||||
|
<Text type="secondary" style={{fontSize: 12}}>折扣率</Text>
|
||||||
|
<br />
|
||||||
|
<Tag color="green">{Math.round(userInfo.discount_rate * 100)}%</Tag>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ======== 套餐信息 ======== */}
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
styles={{
|
||||||
|
body: {padding: '16px 20px'},
|
||||||
|
}}
|
||||||
|
style={{marginBottom: 16}}
|
||||||
|
>
|
||||||
|
<div style={{display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12}}>
|
||||||
|
<Space size={8}>
|
||||||
|
<CrownOutlined style={{color: token.colorWarning}} />
|
||||||
|
<Text strong>当前套餐</Text>
|
||||||
|
</Space>
|
||||||
|
<Button type="link" size="small" icon={<RightOutlined />} iconPlacement="end">
|
||||||
|
续费 / 升级
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Descriptions column={1} size="small" colon={false}>
|
||||||
|
<Descriptions.Item label="套餐名称">
|
||||||
|
<Space size={8}>
|
||||||
|
<Text strong>
|
||||||
|
{matchedVip?.name || userInfo.subscription_plan || '免费版'}
|
||||||
|
</Text>
|
||||||
|
{matchedVip?.level != null && (
|
||||||
|
<Tag color="gold" style={{fontSize: 11, lineHeight: '18px'}}>
|
||||||
|
Lv.{matchedVip.level}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Descriptions.Item>
|
||||||
|
{matchedVip?.description && (
|
||||||
|
<Descriptions.Item label="套餐说明">
|
||||||
|
<Text type="secondary" style={{fontSize: 12}}>
|
||||||
|
{matchedVip.description}
|
||||||
|
</Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
<Descriptions.Item label="套餐周期">
|
||||||
|
{matchedVip?.duration_days ? `${matchedVip.duration_days} 天` : '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="到期时间">
|
||||||
|
<Text type={isExpired ? 'danger' : undefined}>
|
||||||
|
{formatDate(userInfo.software_expires_at)}
|
||||||
|
</Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="席位上限">
|
||||||
|
{matchedVip?.seat_limit ?? userInfo.seat_limit ?? '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ======== 今日统计 ======== */}
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
styles={{
|
||||||
|
body: {padding: '16px 20px'},
|
||||||
|
}}
|
||||||
|
style={{marginBottom: 16}}
|
||||||
|
>
|
||||||
|
<Space size={8} style={{marginBottom: 12}}>
|
||||||
|
<BarChartOutlined style={{color: token.colorSuccess}} />
|
||||||
|
<Text strong>今日使用</Text>
|
||||||
|
</Space>
|
||||||
|
<Text type="secondary" style={{display: 'block', textAlign: 'center', padding: '12px 0', fontSize: 13}}>
|
||||||
|
统计数据将在次日更新
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ======== 查看更多权益 ======== */}
|
||||||
|
<Divider style={{margin: '8px 0'}} />
|
||||||
|
<Button type="link" block icon={<RightOutlined />} iconPlacement="end" style={{color: token.colorPrimary}}>
|
||||||
|
查看更多权益
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
258
src/pages/panels/BillingInfo.tsx
Normal file
258
src/pages/panels/BillingInfo.tsx
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import {useState, useMemo} from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
Skeleton,
|
||||||
|
Result,
|
||||||
|
Space,
|
||||||
|
Tag,
|
||||||
|
theme as antTheme,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
WalletOutlined,
|
||||||
|
RiseOutlined,
|
||||||
|
ApiOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import {useBillingBalance, useBillingLedger} from '@/hooks/use-api';
|
||||||
|
import type {EntryTypeValue, LedgerItemResponseBody, LedgerReqeustParam} from '@/services/modules/billing';
|
||||||
|
import {EnumResolver} from '@/utils/enum-resolver';
|
||||||
|
import {centToYuan, formatDate} from '@/utils/display';
|
||||||
|
|
||||||
|
const {Text, Title} = Typography;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 余额卡片
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function BalanceCard() {
|
||||||
|
const {token} = antTheme.useToken();
|
||||||
|
const {data, loading, error, errorMessage, refetch} = useBillingBalance();
|
||||||
|
|
||||||
|
// ======== 加载态 ========
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card size="small" className="rounded-md!">
|
||||||
|
<Skeleton active paragraph={{rows: 3}} />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== 错误态 ========
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<Card size="small" className="rounded-md!">
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title="余额加载失败"
|
||||||
|
subTitle={errorMessage}
|
||||||
|
extra={
|
||||||
|
<Button type="primary" size="small" onClick={refetch}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== 正常展示 ========
|
||||||
|
const totalBalance = (data.balance_cent ?? 0) + (data.gift_balance_cent ?? 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
styles={{body: {padding: '16px 20px'}}}
|
||||||
|
className="rounded-md!"
|
||||||
|
>
|
||||||
|
<Text type="secondary" style={{fontSize: 12, marginBottom: 8, display: 'block'}}>
|
||||||
|
<WalletOutlined style={{marginRight: 6}} />
|
||||||
|
账户总积分
|
||||||
|
</Text>
|
||||||
|
<Title level={3} style={{margin: '0 0 12px 0'}}>
|
||||||
|
{centToYuan(totalBalance)}
|
||||||
|
<Text type="secondary" style={{fontSize: 14, marginLeft: 4}}>
|
||||||
|
{data.currency}
|
||||||
|
</Text>
|
||||||
|
</Title>
|
||||||
|
<Space size={32}>
|
||||||
|
<div>
|
||||||
|
<Text type="secondary" style={{fontSize: 12}}>主积分</Text>
|
||||||
|
<br />
|
||||||
|
<Text strong>{centToYuan(data.balance_cent ?? 0)}</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text type="secondary" style={{fontSize: 12}}>赠送积分</Text>
|
||||||
|
<br />
|
||||||
|
<Text strong>{centToYuan(data.gift_balance_cent ?? 0)}</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Space size={4}>
|
||||||
|
<RiseOutlined style={{color: token.colorWarning}} />
|
||||||
|
<Text type="secondary" style={{fontSize: 12}}>今日消耗</Text>
|
||||||
|
</Space>
|
||||||
|
<br />
|
||||||
|
<Text strong>{centToYuan(data.today_spent_cent)}</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Space size={4}>
|
||||||
|
<ApiOutlined style={{color: token.colorPrimary}} />
|
||||||
|
<Text type="secondary" style={{fontSize: 12}}>今日调用</Text>
|
||||||
|
</Space>
|
||||||
|
<br />
|
||||||
|
<Text strong>{data.today_calls} 次</Text>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function LedgerTable() {
|
||||||
|
const {token} = antTheme.useToken();
|
||||||
|
const [pagination, setPagination] = useState({page: 1, page_size: 10});
|
||||||
|
|
||||||
|
const params: LedgerReqeustParam = useMemo(() => ({
|
||||||
|
month: null,
|
||||||
|
page: pagination.page,
|
||||||
|
page_size: pagination.page_size,
|
||||||
|
}), [pagination]);
|
||||||
|
|
||||||
|
const {data, loading, error, errorMessage, refetch} = useBillingLedger(params);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
key: 'created_at',
|
||||||
|
width: 120,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (_: unknown, record: LedgerItemResponseBody) => (
|
||||||
|
<Text style={{fontSize: 12, whiteSpace: 'nowrap'}}>
|
||||||
|
{formatDate(record.created_at)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
dataIndex: 'entry_type',
|
||||||
|
key: 'entry_type',
|
||||||
|
width: 100,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (type: EntryTypeValue) => (
|
||||||
|
<Tag color={EnumResolver.billing.entryType.color(type)} style={{fontSize: 11, lineHeight: '18px'}}>
|
||||||
|
{EnumResolver.billing.entryType.label(type)}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '金额',
|
||||||
|
dataIndex: 'amount_cent',
|
||||||
|
key: 'amount_cent',
|
||||||
|
width: 100,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (amount: number): React.ReactNode => {
|
||||||
|
const isNegative = amount < 0;
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: isNegative ? token.colorError : token.colorSuccess,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isNegative ? '-' : '+'}
|
||||||
|
{centToYuan(Math.abs(amount))}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '余额',
|
||||||
|
dataIndex: 'balance_after_cent',
|
||||||
|
key: 'balance_after_cent',
|
||||||
|
width: 100,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (val: number) => (
|
||||||
|
<Text style={{fontSize: 12}}>{centToYuan(val)}</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '描述',
|
||||||
|
dataIndex: 'description',
|
||||||
|
key: 'description',
|
||||||
|
ellipsis: true,
|
||||||
|
align: 'left' as const,
|
||||||
|
render: (desc: string) => (
|
||||||
|
<Text style={{fontSize: 12}}>{desc || '—'}</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ======== 错误态 ========
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card size="small" className="rounded-md!">
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title="账单加载失败"
|
||||||
|
subTitle={errorMessage}
|
||||||
|
extra={
|
||||||
|
<Button type="primary" size="small" onClick={refetch}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== 正常 / 加载态 ========
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
styles={{body: {padding: 0}}}
|
||||||
|
className="rounded-md!"
|
||||||
|
>
|
||||||
|
<Table<LedgerItemResponseBody>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data ?? []}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
size="small"
|
||||||
|
pagination={{
|
||||||
|
current: pagination.page,
|
||||||
|
pageSize: pagination.page_size,
|
||||||
|
total: (data ?? []).length >= pagination.page_size
|
||||||
|
? pagination.page * pagination.page_size + 1
|
||||||
|
: pagination.page * pagination.page_size,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: ['10', '20', '50'],
|
||||||
|
showTotal: (total: number, range: [number, number]) =>
|
||||||
|
`${range[0]}-${range[1]},共 ${total}+ 条`,
|
||||||
|
onChange: (page: number, pageSize: number) => {
|
||||||
|
setPagination({page, page_size: pageSize});
|
||||||
|
},
|
||||||
|
position: ['bottomCenter'],
|
||||||
|
}}
|
||||||
|
locale={{emptyText: '暂无消费记录'}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 页面入口
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function BillingInfo() {
|
||||||
|
return (
|
||||||
|
<div style={{padding: '8px 0', width: '100%'}}>
|
||||||
|
<div className="flex flex-col gap-4" style={{width: '100%'}}>
|
||||||
|
<BalanceCard />
|
||||||
|
<LedgerTable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
541
src/pages/panels/OrdersPage.tsx
Normal file
541
src/pages/panels/OrdersPage.tsx
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
Tag,
|
||||||
|
Segmented,
|
||||||
|
Result,
|
||||||
|
App,
|
||||||
|
theme as antTheme,
|
||||||
|
} from 'antd';
|
||||||
|
import { DollarOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import {
|
||||||
|
PaymentAPI,
|
||||||
|
PAYMENT_STATUS,
|
||||||
|
type PaymentStatus,
|
||||||
|
type OrderListResponseBody,
|
||||||
|
type OrderInfoResponseBody,
|
||||||
|
} from '@/services/modules/payments';
|
||||||
|
import {
|
||||||
|
VipAPI,
|
||||||
|
VipOrderStatus,
|
||||||
|
type VipOrderStatusValue,
|
||||||
|
type VipOrderRead,
|
||||||
|
} from '@/services/modules/vip-api';
|
||||||
|
import { centToYuan, formatDate } from '@/utils/display';
|
||||||
|
import { EnumResolver } from '@/utils/enum-resolver';
|
||||||
|
import { RequestError, RequestErrorType } from '@/services/request';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
// ---------- 常量 ----------
|
||||||
|
|
||||||
|
/** 订单状态轮询间隔(毫秒) */
|
||||||
|
const POLL_INTERVAL_MS = 3000;
|
||||||
|
|
||||||
|
/** 状态筛选项 */
|
||||||
|
const STATUS_FILTERS = [
|
||||||
|
{ label: '全部', value: 'all' },
|
||||||
|
{ label: '待支付', value: PAYMENT_STATUS.Pending },
|
||||||
|
{ label: '已支付', value: PAYMENT_STATUS.Paid },
|
||||||
|
{ label: '已过期', value: PAYMENT_STATUS.Expired },
|
||||||
|
{ label: '支付失败', value: PAYMENT_STATUS.Failed },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 订单类型 */
|
||||||
|
type OrderType = 'recharge' | 'vip';
|
||||||
|
|
||||||
|
const ORDER_TYPE_OPTIONS = [
|
||||||
|
{ label: '充值订单', value: 'recharge' as OrderType },
|
||||||
|
{ label: 'VIP订单', value: 'vip' as OrderType },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------- 组件 ----------
|
||||||
|
|
||||||
|
export function OrdersPage() {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const { token } = antTheme.useToken();
|
||||||
|
|
||||||
|
// ======== 筛选 & 分页 ========
|
||||||
|
|
||||||
|
const [orderType, setOrderType] = useState<OrderType>('recharge');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||||
|
const [pagination, setPagination] = useState({ page: 1, page_size: 10 });
|
||||||
|
|
||||||
|
// ======== 数据状态 ========
|
||||||
|
|
||||||
|
const [rechargeOrders, setRechargeOrders] = useState<OrderListResponseBody[]>([]);
|
||||||
|
const [vipOrders, setVipOrders] = useState<VipOrderRead[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 轮询定时器 Map(orderId → timer)
|
||||||
|
const pollingRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||||
|
// 组件是否已挂载
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
// fetchOrders 的 AbortController(切换筛选/分页时取消旧请求)
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
// ======== 数据获取 ========
|
||||||
|
|
||||||
|
const fetchOrders = useCallback(async () => {
|
||||||
|
// 取消上一次未完成的请求(防竞态)
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
if (orderType === 'recharge') {
|
||||||
|
const params: { page: number; page_size: number; status?: PaymentStatus } = {
|
||||||
|
page: pagination.page,
|
||||||
|
page_size: pagination.page_size,
|
||||||
|
};
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
params.status = statusFilter as PaymentStatus;
|
||||||
|
}
|
||||||
|
const data = await PaymentAPI.getOrdersListInfo(params, controller.signal);
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setRechargeOrders(data);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const params: { page?: number; page_size?: number; status?: VipOrderStatusValue } = {
|
||||||
|
page: pagination.page,
|
||||||
|
page_size: pagination.page_size,
|
||||||
|
};
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
params.status = statusFilter as VipOrderStatusValue;
|
||||||
|
}
|
||||||
|
const data = await VipAPI.getVipOrders(params);
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setVipOrders(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// axios 拦截器将取消错误转为 RequestError(CANCELLED),非原始 AbortError
|
||||||
|
if (err instanceof RequestError && err.type === RequestErrorType.CANCELLED) return;
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setError((err as Error)?.message || '加载订单列表失败');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [pagination, statusFilter, orderType]);
|
||||||
|
|
||||||
|
// 筛选或分页变化 → 重新获取
|
||||||
|
useEffect(() => {
|
||||||
|
fetchOrders();
|
||||||
|
}, [fetchOrders]);
|
||||||
|
|
||||||
|
// 挂载/卸载管理
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
const polling = pollingRef.current;
|
||||||
|
const abort = abortRef.current;
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
// 清理所有轮询定时器
|
||||||
|
polling.forEach((timer) => clearTimeout(timer));
|
||||||
|
polling.clear();
|
||||||
|
// 取消进行中的列表请求
|
||||||
|
abort?.abort();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ======== 单订单轮询 ========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单订单轮询:对指定 orderId 启动轮询,每 POLL_INTERVAL_MS 查一次
|
||||||
|
* - 幂等启动:取消同一订单的旧轮询再创建新的
|
||||||
|
* - 局部更新 orders state,终态时调 fetchOrders 保证一致性
|
||||||
|
* - 网络异常时延长间隔(2×)重试
|
||||||
|
* - 通过 mountedRef 防止卸载后 setState
|
||||||
|
*/
|
||||||
|
const startPolling = useCallback((orderId: string) => {
|
||||||
|
// ① 取消同一订单的旧轮询(幂等启动,避免重复轮询)
|
||||||
|
const existing = pollingRef.current.get(orderId);
|
||||||
|
if (existing) {
|
||||||
|
clearTimeout(existing);
|
||||||
|
pollingRef.current.delete(orderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
if (cancelled || !mountedRef.current) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const order: OrderInfoResponseBody = await PaymentAPI.getOrderInfo({ order_id: orderId });
|
||||||
|
if (cancelled || !mountedRef.current) return;
|
||||||
|
|
||||||
|
const newStatus = order.status;
|
||||||
|
|
||||||
|
// 局部更新 orders state(merge 最新订单信息)
|
||||||
|
setRechargeOrders((prev) =>
|
||||||
|
prev.map((o) => (o.id === orderId ? { ...o, ...order } : o)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newStatus === PAYMENT_STATUS.Pending) {
|
||||||
|
// 仍待支付 → 继续轮询
|
||||||
|
const timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||||||
|
pollingRef.current.set(orderId, timer);
|
||||||
|
} else {
|
||||||
|
// 终态 → 停止轮询,刷新列表保证整体一致性
|
||||||
|
pollingRef.current.delete(orderId);
|
||||||
|
fetchOrders();
|
||||||
|
|
||||||
|
if (newStatus === PAYMENT_STATUS.Paid) {
|
||||||
|
message.success('支付成功!');
|
||||||
|
} else if (newStatus === PAYMENT_STATUS.Failed) {
|
||||||
|
message.error('支付失败,请重试');
|
||||||
|
} else if (newStatus === PAYMENT_STATUS.Expired) {
|
||||||
|
message.warning('订单已过期');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 网络异常 → 延长间隔后重试
|
||||||
|
if (!cancelled && mountedRef.current) {
|
||||||
|
const timer = setTimeout(poll, POLL_INTERVAL_MS * 2);
|
||||||
|
pollingRef.current.set(orderId, timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 启动首次轮询
|
||||||
|
const timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||||||
|
pollingRef.current.set(orderId, timer);
|
||||||
|
}, [message, fetchOrders]);
|
||||||
|
|
||||||
|
/** VIP 订单轮询(与充值订单轮询模式一致,使用 VipAPI.getVipOrder) */
|
||||||
|
const startVipPolling = useCallback((orderId: string) => {
|
||||||
|
const existing = pollingRef.current.get(orderId);
|
||||||
|
if (existing) {
|
||||||
|
clearTimeout(existing);
|
||||||
|
pollingRef.current.delete(orderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
if (cancelled || !mountedRef.current) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const order = await VipAPI.getVipOrder(orderId);
|
||||||
|
if (cancelled || !mountedRef.current) return;
|
||||||
|
|
||||||
|
const newStatus = order.status;
|
||||||
|
|
||||||
|
setVipOrders((prev) =>
|
||||||
|
prev.map((o) => (o.id === orderId ? { ...o, ...order } : o)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newStatus === VipOrderStatus.Pending) {
|
||||||
|
const timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||||||
|
pollingRef.current.set(orderId, timer);
|
||||||
|
} else {
|
||||||
|
pollingRef.current.delete(orderId);
|
||||||
|
fetchOrders();
|
||||||
|
|
||||||
|
if (newStatus === VipOrderStatus.Paid) {
|
||||||
|
message.success('支付成功!VIP 权益已生效');
|
||||||
|
} else if (newStatus === VipOrderStatus.Expired) {
|
||||||
|
message.warning('订单已过期');
|
||||||
|
} else if (newStatus === VipOrderStatus.Cancelled) {
|
||||||
|
message.info('订单已取消');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled && mountedRef.current) {
|
||||||
|
const timer = setTimeout(poll, POLL_INTERVAL_MS * 2);
|
||||||
|
pollingRef.current.set(orderId, timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||||||
|
pollingRef.current.set(orderId, timer);
|
||||||
|
}, [message, fetchOrders]);
|
||||||
|
|
||||||
|
// ======== 操作回调 ========
|
||||||
|
|
||||||
|
/** "继续支付":打开支付页面 + 启动轮询 */
|
||||||
|
const handleContinuePay = useCallback(
|
||||||
|
(orderId: string, payUrl: string) => {
|
||||||
|
window.open(payUrl, '_blank');
|
||||||
|
if (orderType === 'recharge') {
|
||||||
|
startPolling(orderId);
|
||||||
|
} else {
|
||||||
|
startVipPolling(orderId);
|
||||||
|
}
|
||||||
|
message.info('已打开支付页面,请完成支付');
|
||||||
|
},
|
||||||
|
[orderType, startPolling, startVipPolling, message],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ======== 表格列定义 ========
|
||||||
|
|
||||||
|
/** 充值订单列 */
|
||||||
|
const rechargeColumns = useMemo(() => [
|
||||||
|
{
|
||||||
|
title: '订单编号',
|
||||||
|
dataIndex: 'id',
|
||||||
|
key: 'id',
|
||||||
|
width: 200,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (id: string) => (
|
||||||
|
<Text copyable style={{ fontSize: 12 }}>{id}</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '金额',
|
||||||
|
dataIndex: 'amount_cent',
|
||||||
|
key: 'amount_cent',
|
||||||
|
width: 90,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (amount: number) => (
|
||||||
|
<Text strong style={{ fontSize: 13, color: token.colorPrimary }}>¥{centToYuan(amount)}</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '支付方式',
|
||||||
|
dataIndex: 'payment_channel',
|
||||||
|
key: 'payment_channel',
|
||||||
|
width: 90,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (channel: string) => (
|
||||||
|
<Text style={{ fontSize: 12 }}>{EnumResolver.payments.method.label(channel)}</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
width: 90,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (status: string) => (
|
||||||
|
<Tag
|
||||||
|
color={EnumResolver.payments.status.color(status as PaymentStatus)}
|
||||||
|
style={{ fontSize: 11, lineHeight: '18px' }}
|
||||||
|
>
|
||||||
|
{EnumResolver.payments.status.label(status as PaymentStatus)}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
key: 'created_at',
|
||||||
|
width: 120,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (date: Date) => (
|
||||||
|
<Text style={{ fontSize: 12, whiteSpace: 'nowrap' }}>{formatDate(date)}</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 110,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (_: unknown, record: OrderListResponseBody) => {
|
||||||
|
if (record.status === PAYMENT_STATUS.Pending && record.pay_url) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
icon={<DollarOutlined />}
|
||||||
|
onClick={() => handleContinuePay(record.id, record.pay_url)}
|
||||||
|
>
|
||||||
|
继续支付
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Text type="secondary" style={{ fontSize: 12 }}>—</Text>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
], [token.colorPrimary, handleContinuePay]);
|
||||||
|
|
||||||
|
/** VIP 订单列 */
|
||||||
|
const vipColumns = useMemo(() => [
|
||||||
|
{
|
||||||
|
title: '订单编号',
|
||||||
|
dataIndex: 'id',
|
||||||
|
key: 'id',
|
||||||
|
width: 220,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (id: string) => (
|
||||||
|
<Text copyable style={{ fontSize: 12 }}>{id}</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '金额',
|
||||||
|
dataIndex: 'amount_cent',
|
||||||
|
key: 'amount_cent',
|
||||||
|
width: 90,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (amount: number) => (
|
||||||
|
<Text strong style={{ fontSize: 13, color: token.colorPrimary }}>¥{centToYuan(amount)}</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
width: 90,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (status: string) => (
|
||||||
|
<Tag
|
||||||
|
color={EnumResolver.vip.orderStatus.color(status as VipOrderStatusValue)}
|
||||||
|
style={{ fontSize: 11, lineHeight: '18px' }}
|
||||||
|
>
|
||||||
|
{EnumResolver.vip.orderStatus.label(status as VipOrderStatusValue)}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
key: 'created_at',
|
||||||
|
width: 130,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (date: Date) => (
|
||||||
|
<Text style={{ fontSize: 12, whiteSpace: 'nowrap' }}>{formatDate(date)}</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 110,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (_: unknown, record: VipOrderRead) => {
|
||||||
|
if (record.status === VipOrderStatus.Pending && record.pay_url) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
icon={<DollarOutlined />}
|
||||||
|
onClick={() => handleContinuePay(record.id, record.pay_url ?? '')}
|
||||||
|
>
|
||||||
|
继续支付
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Text type="secondary" style={{ fontSize: 12 }}>—</Text>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
], [token.colorPrimary, handleContinuePay]);
|
||||||
|
|
||||||
|
/** 当前类型对应的列和数据 */
|
||||||
|
const columns = orderType === 'recharge' ? rechargeColumns : vipColumns;
|
||||||
|
const orders = orderType === 'recharge' ? rechargeOrders : vipOrders;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 渲染:错误态(无数据时)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
if (error && orders.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '8px 0' }}>
|
||||||
|
<Card size="small" className="rounded-md!">
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title="订单加载失败"
|
||||||
|
subTitle={error}
|
||||||
|
extra={
|
||||||
|
<Button type="primary" size="small" onClick={fetchOrders}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 渲染:正常
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** 当前订单类型对应的状态筛选项 */
|
||||||
|
const currentStatusFilters = orderType === 'recharge'
|
||||||
|
? STATUS_FILTERS
|
||||||
|
: [
|
||||||
|
{ label: '全部', value: 'all' },
|
||||||
|
{ label: '待支付', value: VipOrderStatus.Pending },
|
||||||
|
{ label: '已支付', value: VipOrderStatus.Paid },
|
||||||
|
{ label: '已过期', value: VipOrderStatus.Expired },
|
||||||
|
{ label: '已取消', value: VipOrderStatus.Cancelled },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '8px 0', width: '100%' }}>
|
||||||
|
<div className="flex flex-col gap-3" style={{ width: '100%' }}>
|
||||||
|
{/* ---- 订单类型 + 状态筛选 ---- */}
|
||||||
|
<Segmented
|
||||||
|
options={ORDER_TYPE_OPTIONS}
|
||||||
|
value={orderType}
|
||||||
|
onChange={(val) => {
|
||||||
|
setOrderType(val as OrderType);
|
||||||
|
setStatusFilter('all');
|
||||||
|
setPagination((prev) => ({ ...prev, page: 1 }));
|
||||||
|
}}
|
||||||
|
block
|
||||||
|
style={{ backgroundColor: token.colorFillSecondary }}
|
||||||
|
/>
|
||||||
|
<Segmented
|
||||||
|
options={currentStatusFilters}
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(val) => {
|
||||||
|
setStatusFilter(val as string);
|
||||||
|
setPagination((prev) => ({ ...prev, page: 1 }));
|
||||||
|
}}
|
||||||
|
block
|
||||||
|
style={{ backgroundColor: token.colorFillSecondary }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ---- 手动刷新 ---- */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<Button size="small" icon={<ReloadOutlined />} onClick={fetchOrders} loading={loading}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- 订单表格 ---- */}
|
||||||
|
<Card size="small" styles={{ body: { padding: 0 } }} className="rounded-md!">
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
|
<Table<any>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={orders}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
size="small"
|
||||||
|
pagination={{
|
||||||
|
current: pagination.page,
|
||||||
|
pageSize: pagination.page_size,
|
||||||
|
total:
|
||||||
|
orders.length >= pagination.page_size
|
||||||
|
? pagination.page * pagination.page_size + 1
|
||||||
|
: pagination.page * pagination.page_size,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: ['10', '20', '50'],
|
||||||
|
showTotal: (total: number, range: [number, number]) =>
|
||||||
|
`${range[0]}-${range[1]},共 ${total}+ 条`,
|
||||||
|
onChange: (page: number, pageSize: number) => {
|
||||||
|
setPagination({ page, page_size: pageSize });
|
||||||
|
},
|
||||||
|
position: ['bottomCenter'],
|
||||||
|
}}
|
||||||
|
locale={{ emptyText: '暂无订单记录' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
524
src/pages/panels/RechargePage.tsx
Normal file
524
src/pages/panels/RechargePage.tsx
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
// ============================================================
|
||||||
|
// RechargePage — 积分充值表单 + 支付状态跟踪
|
||||||
|
//
|
||||||
|
// 通过 PageDispatcher 调度(Modal 容器),导航栏"充值"按钮触发。
|
||||||
|
// 两阶段视图:
|
||||||
|
// form — 金额选择 → 自定义输入 → 支付方式 → 协议 → 提交
|
||||||
|
// pending — 订单已创建,轮询支付状态,可重新打开支付页面
|
||||||
|
// paid — 支付成功
|
||||||
|
// expired — 订单过期,可重新下单
|
||||||
|
// failed — 支付失败,可重新下单
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Radio,
|
||||||
|
InputNumber,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Typography,
|
||||||
|
App,
|
||||||
|
Space,
|
||||||
|
Divider,
|
||||||
|
theme as antTheme,
|
||||||
|
Result,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
AlipayOutlined,
|
||||||
|
WechatOutlined,
|
||||||
|
WalletOutlined,
|
||||||
|
DollarOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||||
|
import { LegalTextModal } from '@/components/ui/LegalTextModal';
|
||||||
|
import {
|
||||||
|
PaymentAPI,
|
||||||
|
PAYMENT_METHODS,
|
||||||
|
PAYMENT_STATUS,
|
||||||
|
getPaymentStatusText,
|
||||||
|
type PaymentStatus,
|
||||||
|
} from '@/services/modules/payments';
|
||||||
|
import { emit, EVENTS } from '@/utils/event-bus';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
// ---------- 常量 ----------
|
||||||
|
|
||||||
|
const PRESET_AMOUNTS = [10, 100, 500, 1000, 5000, 10000] as const;
|
||||||
|
|
||||||
|
/** 单次充值上限(元) */
|
||||||
|
const MAX_AMOUNT_YUAN = 100000;
|
||||||
|
|
||||||
|
/** 订单状态轮询间隔(毫秒) */
|
||||||
|
const POLL_INTERVAL_MS = 3000;
|
||||||
|
|
||||||
|
/** 页面阶段 */
|
||||||
|
type RechargePhase = 'form' | PaymentStatus;
|
||||||
|
|
||||||
|
// ---------- 组件 ----------
|
||||||
|
|
||||||
|
export function RechargePage() {
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const { token } = antTheme.useToken();
|
||||||
|
|
||||||
|
// ======== 表单状态 ========
|
||||||
|
|
||||||
|
const [selectedAmount, setSelectedAmount] = useState<number | null>(null);
|
||||||
|
const [customAmount, setCustomAmount] = useState<number | null>(null);
|
||||||
|
const [paymentMethod, setPaymentMethod] = useState<string>(PAYMENT_METHODS.Alipay);
|
||||||
|
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const [legalOpen, setLegalOpen] = useState(false);
|
||||||
|
|
||||||
|
// ======== 订单状态 ========
|
||||||
|
|
||||||
|
const [phase, setPhase] = useState<RechargePhase>('form');
|
||||||
|
const [orderId, setOrderId] = useState<string | null>(null);
|
||||||
|
const [orderStatus, setOrderStatus] = useState<PaymentStatus | null>(null);
|
||||||
|
const [payUrl, setPayUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// ======== 派生值 ========
|
||||||
|
|
||||||
|
/** 最终充值金额(元):自定义金额优先,否则预设金额,否则 0 */
|
||||||
|
const finalAmountYuan = useMemo(() => {
|
||||||
|
if (customAmount !== null && customAmount !== undefined) {
|
||||||
|
return customAmount;
|
||||||
|
}
|
||||||
|
return selectedAmount ?? 0;
|
||||||
|
}, [customAmount, selectedAmount]);
|
||||||
|
|
||||||
|
/** 最终充值金额(分):元 × 100,用于 API 调用 */
|
||||||
|
const finalAmountCent = finalAmountYuan * 100;
|
||||||
|
|
||||||
|
// ======== 工具函数 ========
|
||||||
|
|
||||||
|
const clearError = useCallback((name: string) => {
|
||||||
|
setErrors((prev) => {
|
||||||
|
if (!(name in prev)) return prev;
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[name];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ======== 校验 ========
|
||||||
|
|
||||||
|
const validate = useCallback((): Record<string, string> => {
|
||||||
|
const errs: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!finalAmountYuan || finalAmountYuan <= 0) {
|
||||||
|
errs.amount = '请选择或输入充值金额';
|
||||||
|
} else if (!Number.isInteger(finalAmountYuan) || finalAmountYuan % 10 !== 0) {
|
||||||
|
errs.customAmount = '金额必须为 10 的倍数';
|
||||||
|
} else if (finalAmountYuan > MAX_AMOUNT_YUAN) {
|
||||||
|
errs.customAmount = `单次充值上限为 ${MAX_AMOUNT_YUAN.toLocaleString()} 元`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!agreedToTerms) {
|
||||||
|
errs.agreedToTerms = '请阅读并同意充值协议';
|
||||||
|
}
|
||||||
|
|
||||||
|
return errs;
|
||||||
|
}, [finalAmountYuan, agreedToTerms]);
|
||||||
|
|
||||||
|
// ======== 支付方式切换 ========
|
||||||
|
|
||||||
|
const handlePaymentChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
if (value === PAYMENT_METHODS.WeChat) {
|
||||||
|
void message.info('微信支付正在接入中,敬请期待');
|
||||||
|
return; // 不更新 state,保持支付宝选中
|
||||||
|
}
|
||||||
|
setPaymentMethod(value);
|
||||||
|
},
|
||||||
|
[message],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ======== 提交流程 ========
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
const validationErrors = validate();
|
||||||
|
if (Object.keys(validationErrors).length > 0) {
|
||||||
|
setErrors(validationErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const res = await PaymentAPI.createOrder({
|
||||||
|
amount_cent: finalAmountCent,
|
||||||
|
payment_channel: PAYMENT_METHODS.Alipay,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保存订单信息,进入等待支付阶段
|
||||||
|
setOrderId(res.id);
|
||||||
|
setOrderStatus(PAYMENT_STATUS.Pending);
|
||||||
|
setPayUrl(res.pay_url);
|
||||||
|
setPhase(PAYMENT_STATUS.Pending);
|
||||||
|
|
||||||
|
window.open(res.pay_url, '_blank');
|
||||||
|
void message.success('订单已创建,请在新页面中完成支付');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
message.error((err as Error)?.message || '创建订单失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [finalAmountCent, validate, message]);
|
||||||
|
|
||||||
|
// ======== 订单状态轮询 ========
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== PAYMENT_STATUS.Pending || !orderId) return;
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const order = await PaymentAPI.getOrderInfo({ order_id: orderId });
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const newStatus = order.status;
|
||||||
|
setOrderStatus(newStatus);
|
||||||
|
|
||||||
|
if (newStatus === PAYMENT_STATUS.Pending) {
|
||||||
|
// 仍待支付 → 继续轮询
|
||||||
|
timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||||||
|
} else {
|
||||||
|
// 终态 → 停止轮询,切换阶段
|
||||||
|
setPhase(newStatus);
|
||||||
|
if (newStatus === PAYMENT_STATUS.Paid) {
|
||||||
|
void message.success('支付成功,积分已到账');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 网络异常 → 延长间隔后继续轮询
|
||||||
|
if (!cancelled) timer = setTimeout(poll, POLL_INTERVAL_MS * 2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [phase, orderId, message]);
|
||||||
|
|
||||||
|
// ======== 页面锁定(待支付时禁止关闭容器) ========
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const locked = phase === PAYMENT_STATUS.Pending;
|
||||||
|
emit(EVENTS.PAGE_LOCK, { locked });
|
||||||
|
// 组件卸载时确保解锁(防止锁定残留)
|
||||||
|
return () => {
|
||||||
|
if (locked) emit(EVENTS.PAGE_LOCK, { locked: false });
|
||||||
|
};
|
||||||
|
}, [phase]);
|
||||||
|
|
||||||
|
// ======== 操作回调 ========
|
||||||
|
|
||||||
|
/** 重新下单:回到表单,保留之前填写的金额 */
|
||||||
|
const handleRetry = useCallback(() => {
|
||||||
|
setPhase('form');
|
||||||
|
setOrderId(null);
|
||||||
|
setOrderStatus(null);
|
||||||
|
setPayUrl(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 关闭充值页面 */
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
emit(EVENTS.CLOSE_PAGE);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 渲染:支付状态视图(非 form 阶段)
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
if (phase !== 'form') {
|
||||||
|
const statusText = orderStatus ? getPaymentStatusText(orderStatus) : '';
|
||||||
|
const isPending = phase === PAYMENT_STATUS.Pending;
|
||||||
|
const isPaid = phase === PAYMENT_STATUS.Paid;
|
||||||
|
const isTerminal = !isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ padding: '16px 0' }}>
|
||||||
|
<Result
|
||||||
|
status={
|
||||||
|
isPaid
|
||||||
|
? 'success'
|
||||||
|
: isPending
|
||||||
|
? 'info'
|
||||||
|
: 'error'
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
isPaid
|
||||||
|
? '支付成功'
|
||||||
|
: isPending
|
||||||
|
? `等待支付(${statusText})`
|
||||||
|
: statusText
|
||||||
|
}
|
||||||
|
subTitle={
|
||||||
|
isPaid
|
||||||
|
? `已到账积分:${finalAmountYuan.toLocaleString()} 积分`
|
||||||
|
: isPending
|
||||||
|
? '请在新打开的浏览器页面中完成支付'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* ---- 订单信息卡片 ---- */}
|
||||||
|
{isPending && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: isDark ? '#2E2A5A' : '#F5F3FF',
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 16,
|
||||||
|
textAlign: 'left',
|
||||||
|
maxWidth: 360,
|
||||||
|
margin: '0 auto 16px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space orientation="vertical" size={6} style={{ width: '100%' }}>
|
||||||
|
<Text style={{ fontSize: 12 }} type="secondary">
|
||||||
|
订单编号:<Text copyable style={{ fontSize: 12 }}>{orderId}</Text>
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 13 }}>
|
||||||
|
<DollarOutlined style={{ marginRight: 6, color: token.colorPrimary }} />
|
||||||
|
充值金额:
|
||||||
|
<Text strong>¥{finalAmountYuan.toLocaleString()} 元</Text>
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 13 }}>
|
||||||
|
<WalletOutlined style={{ marginRight: 6, color: token.colorSuccess }} />
|
||||||
|
到账积分:
|
||||||
|
<Text strong>{finalAmountYuan.toLocaleString()}</Text> 积分
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- 操作按钮 ---- */}
|
||||||
|
<Space size={12}>
|
||||||
|
{isPending && payUrl && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<DollarOutlined />}
|
||||||
|
onClick={() => window.open(payUrl, '_blank')}
|
||||||
|
>
|
||||||
|
打开支付页面
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isTerminal && !isPaid && (
|
||||||
|
<Button type="primary" onClick={handleRetry}>
|
||||||
|
重新下单
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={handleClose}>
|
||||||
|
{isPaid ? '完成' : '关闭(取消支付)'}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Result>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- 协议弹窗(待支付阶段仍可查看) ---- */}
|
||||||
|
<LegalTextModal
|
||||||
|
open={legalOpen}
|
||||||
|
type="points"
|
||||||
|
onClose={() => setLegalOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 渲染:表单视图(form 阶段)
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ padding: '4px 0' }}>
|
||||||
|
{/* ---- 页面副标题 ---- */}
|
||||||
|
<Text
|
||||||
|
type="secondary"
|
||||||
|
style={{ fontSize: 13, marginBottom: 20, display: 'block' }}
|
||||||
|
>
|
||||||
|
选择充值金额或输入自定义金额(元)
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* ---- Part 1:预设金额单选组 ---- */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Radio.Group
|
||||||
|
value={selectedAmount}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedAmount(e.target.value);
|
||||||
|
setCustomAmount(null);
|
||||||
|
clearError('amount');
|
||||||
|
}}
|
||||||
|
optionType="button"
|
||||||
|
buttonStyle="solid"
|
||||||
|
style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}
|
||||||
|
>
|
||||||
|
{PRESET_AMOUNTS.map((amt) => (
|
||||||
|
<Radio.Button key={amt} value={amt}>
|
||||||
|
{amt} 元
|
||||||
|
</Radio.Button>
|
||||||
|
))}
|
||||||
|
</Radio.Group>
|
||||||
|
{errors.amount && (
|
||||||
|
<Text type="danger" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
|
||||||
|
{errors.amount}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- Part 2:自定义金额输入 ---- */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
|
<InputNumber
|
||||||
|
value={customAmount}
|
||||||
|
onChange={(val) => {
|
||||||
|
setCustomAmount(val);
|
||||||
|
setSelectedAmount(null);
|
||||||
|
clearError('customAmount');
|
||||||
|
}}
|
||||||
|
min={10}
|
||||||
|
step={10}
|
||||||
|
max={MAX_AMOUNT_YUAN}
|
||||||
|
placeholder="输入自定义金额(10的倍数)"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
size="large"
|
||||||
|
precision={0}
|
||||||
|
/>
|
||||||
|
<Button size="large" disabled style={{ minWidth: 44 }}>
|
||||||
|
元
|
||||||
|
</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
{errors.customAmount && (
|
||||||
|
<Text type="danger" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
|
||||||
|
{errors.customAmount}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- Part 3:到账信息 + 支付金额 ---- */}
|
||||||
|
{finalAmountYuan > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: isDark ? '#2E2A5A' : '#F5F3FF',
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space orientation="vertical" size={6}>
|
||||||
|
<Text style={{ fontSize: 13 }}>
|
||||||
|
<WalletOutlined style={{ marginRight: 6, color: token.colorPrimary }} />
|
||||||
|
到账积分:
|
||||||
|
<Text strong>{finalAmountYuan.toLocaleString()}</Text> 积分
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 13 }}>
|
||||||
|
<DollarOutlined style={{ marginRight: 6, color: token.colorSuccess }} />
|
||||||
|
支付金额:
|
||||||
|
<Text strong style={{ color: token.colorPrimary, fontSize: 15 }}>
|
||||||
|
¥{finalAmountYuan.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
{' '}元
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider style={{ margin: '12px 0' }} />
|
||||||
|
|
||||||
|
{/* ---- Part 4:支付方式 ---- */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Text
|
||||||
|
type="secondary"
|
||||||
|
style={{ fontSize: 12, marginBottom: 8, display: 'block' }}
|
||||||
|
>
|
||||||
|
选择支付方式
|
||||||
|
</Text>
|
||||||
|
<Radio.Group
|
||||||
|
value={paymentMethod}
|
||||||
|
onChange={(e) => handlePaymentChange(e.target.value)}
|
||||||
|
optionType="button"
|
||||||
|
buttonStyle="solid"
|
||||||
|
>
|
||||||
|
<Radio.Button value={PAYMENT_METHODS.Alipay}>
|
||||||
|
<Space size={4}>
|
||||||
|
<AlipayOutlined />
|
||||||
|
支付宝
|
||||||
|
</Space>
|
||||||
|
</Radio.Button>
|
||||||
|
<Radio.Button value={PAYMENT_METHODS.WeChat}>
|
||||||
|
<Space size={4}>
|
||||||
|
<WechatOutlined />
|
||||||
|
微信支付
|
||||||
|
</Space>
|
||||||
|
</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '12px 0' }} />
|
||||||
|
|
||||||
|
{/* ---- Part 5:充值协议 ---- */}
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<Checkbox
|
||||||
|
checked={agreedToTerms}
|
||||||
|
onChange={(e) => {
|
||||||
|
setAgreedToTerms(e.target.checked);
|
||||||
|
clearError('agreedToTerms');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 12 }}>
|
||||||
|
我已阅读并同意
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={() => setLegalOpen(true)}
|
||||||
|
style={{
|
||||||
|
padding: '0 2px',
|
||||||
|
fontSize: 12,
|
||||||
|
height: 'auto',
|
||||||
|
lineHeight: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
《积分充值服务协议》
|
||||||
|
</Button>
|
||||||
|
</Text>
|
||||||
|
</Checkbox>
|
||||||
|
{errors.agreedToTerms && (
|
||||||
|
<Text type="danger" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
|
||||||
|
{errors.agreedToTerms}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- Part 6:操作按钮 ---- */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||||
|
<Button onClick={handleClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
loading={submitting}
|
||||||
|
onClick={() => void handleSubmit()}
|
||||||
|
disabled={finalAmountYuan <= 0}
|
||||||
|
>
|
||||||
|
提交
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- 协议弹窗 ---- */}
|
||||||
|
<LegalTextModal
|
||||||
|
open={legalOpen}
|
||||||
|
type="points"
|
||||||
|
onClose={() => setLegalOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
636
src/pages/panels/VipPage.tsx
Normal file
636
src/pages/panels/VipPage.tsx
Normal file
@@ -0,0 +1,636 @@
|
|||||||
|
// ============================================================
|
||||||
|
// VipPage — VIP 会员开通页面
|
||||||
|
//
|
||||||
|
// 通过 PageDispatcher 调度(Modal 容器),导航栏"开会员/激活"按钮触发。
|
||||||
|
// 四部分布局:
|
||||||
|
// 激活码 — Space.Compact 输入框 + 激活按钮
|
||||||
|
// 套餐选择 — Radio.Group 卡片式单选,API 获取套餐列表
|
||||||
|
// 支付协议 — Checkbox + LegalTextModal(用户服务协议) + 确认开通
|
||||||
|
// 权益说明 — 选中套餐的大图展示
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import {useState, useCallback, useEffect, useRef} from 'react';
|
||||||
|
import {
|
||||||
|
Radio,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Input,
|
||||||
|
Image,
|
||||||
|
Typography,
|
||||||
|
App,
|
||||||
|
Space,
|
||||||
|
Divider,
|
||||||
|
theme as antTheme,
|
||||||
|
Spin,
|
||||||
|
Result,
|
||||||
|
} from 'antd';
|
||||||
|
import {CrownOutlined, GiftOutlined, DollarOutlined} from '@ant-design/icons';
|
||||||
|
|
||||||
|
import {useTheme} from '@/components/providers/ThemeProvider';
|
||||||
|
import {LegalTextModal} from '@/components/ui/LegalTextModal';
|
||||||
|
import {
|
||||||
|
VipAPI,
|
||||||
|
VipOrderStatus,
|
||||||
|
type VipLevelsItem,
|
||||||
|
type VipOrderStatusValue,
|
||||||
|
} from '@/services/modules/vip-api';
|
||||||
|
import {centToYuan} from '@/utils/display';
|
||||||
|
import {EnumResolver} from '@/utils/enum-resolver';
|
||||||
|
import {emit, EVENTS} from '@/utils/event-bus';
|
||||||
|
import {RequestError, RequestErrorType} from '@/services/request';
|
||||||
|
|
||||||
|
const {Text} = Typography;
|
||||||
|
|
||||||
|
// ---------- 常量 ----------
|
||||||
|
|
||||||
|
/** 订单状态轮询间隔(毫秒) */
|
||||||
|
const POLL_INTERVAL_MS = 3000;
|
||||||
|
|
||||||
|
/** 页面阶段 */
|
||||||
|
type VipPhase = 'form' | 'activate' | VipOrderStatusValue;
|
||||||
|
|
||||||
|
/** 状态 → Result status 映射 */
|
||||||
|
function getResultStatus(status: VipOrderStatusValue): 'success' | 'info' | 'error' {
|
||||||
|
if (status === VipOrderStatus.Paid) return 'success';
|
||||||
|
if (status === VipOrderStatus.Pending) return 'info';
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 组件 ----------
|
||||||
|
|
||||||
|
export function VipPage() {
|
||||||
|
const {isDark} = useTheme();
|
||||||
|
const {message} = App.useApp();
|
||||||
|
const {token} = antTheme.useToken();
|
||||||
|
|
||||||
|
// ======== 套餐列表 ========
|
||||||
|
|
||||||
|
const [levels, setLevels] = useState<VipLevelsItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
|
||||||
|
// ======== 表单状态 ========
|
||||||
|
|
||||||
|
const [selectedLevelId, setSelectedLevelId] = useState<number | null>(null);
|
||||||
|
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const [legalOpen, setLegalOpen] = useState(false);
|
||||||
|
|
||||||
|
// ======== 激活码 ========
|
||||||
|
|
||||||
|
const [activateCode, setActivateCode] = useState('');
|
||||||
|
const [activating, setActivating] = useState(false);
|
||||||
|
|
||||||
|
// ======== 支付订单状态 ========
|
||||||
|
|
||||||
|
const [phase, setPhase] = useState<VipPhase>('form');
|
||||||
|
const [vipOrderId, setVipOrderId] = useState<string | null>(null);
|
||||||
|
const [vipOrderStatus, setVipOrderStatus] = useState<VipOrderStatusValue | null>(null);
|
||||||
|
const [vipPayUrl, setVipPayUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// ======== 工具函数 ========
|
||||||
|
|
||||||
|
const clearError = useCallback((name: string) => {
|
||||||
|
setErrors((prev) => {
|
||||||
|
if (!(name in prev)) return prev;
|
||||||
|
const next = {...prev};
|
||||||
|
delete next[name];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ======== 获取套餐列表 ========
|
||||||
|
|
||||||
|
const fetchLevels = useCallback(async () => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setFetchError(null);
|
||||||
|
try {
|
||||||
|
const data = await VipAPI.getVipLevels(controller.signal);
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setLevels(data.filter((l) => l.is_active && l.image));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// axios 拦截器将取消错误转为 RequestError(CANCELLED),非原始 AbortError
|
||||||
|
if (err instanceof RequestError && err.type === RequestErrorType.CANCELLED) return;
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setFetchError((err as Error)?.message || '加载套餐列表失败');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
void fetchLevels();
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
abortRef.current?.abort();
|
||||||
|
};
|
||||||
|
}, [fetchLevels]);
|
||||||
|
|
||||||
|
// ======== 校验 ========
|
||||||
|
|
||||||
|
const validate = useCallback((): Record<string, string> => {
|
||||||
|
const errs: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (selectedLevelId === null) {
|
||||||
|
errs.selectedLevel = '请选择会员套餐';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!agreedToTerms) {
|
||||||
|
errs.agreedToTerms = '请阅读并同意用户服务协议';
|
||||||
|
}
|
||||||
|
|
||||||
|
return errs;
|
||||||
|
}, [selectedLevelId, agreedToTerms]);
|
||||||
|
|
||||||
|
// ======== 提交 ========
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
const validationErrors = validate();
|
||||||
|
if (Object.keys(validationErrors).length > 0) {
|
||||||
|
setErrors(validationErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const res = await VipAPI.createVipOrder({
|
||||||
|
vip_level_id: selectedLevelId!,
|
||||||
|
payment_channel: 'alipay',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保存订单信息,进入待支付阶段
|
||||||
|
setVipOrderId(res.id);
|
||||||
|
setVipOrderStatus(VipOrderStatus.Pending);
|
||||||
|
setVipPayUrl(res.pay_url);
|
||||||
|
setPhase(VipOrderStatus.Pending);
|
||||||
|
|
||||||
|
window.open(res.pay_url ?? '', '_blank');
|
||||||
|
void message.success('订单已创建,请在新页面中完成支付');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
message.error((err as Error)?.message || '创建订单失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [selectedLevelId, validate, message]);
|
||||||
|
|
||||||
|
// ======== 激活码提交 ========
|
||||||
|
|
||||||
|
const handleActivate = useCallback(async () => {
|
||||||
|
const code = activateCode.trim();
|
||||||
|
if (!code) {
|
||||||
|
message.warning('请输入激活码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActivating(true);
|
||||||
|
try {
|
||||||
|
await VipAPI.activateVipCode({code});
|
||||||
|
void message.success('激活成功!VIP 权益已生效');
|
||||||
|
emit(EVENTS.CLOSE_PAGE);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
message.error((err as Error)?.message || '激活失败,请检查激活码是否正确');
|
||||||
|
} finally {
|
||||||
|
setActivating(false);
|
||||||
|
}
|
||||||
|
}, [activateCode, message]);
|
||||||
|
|
||||||
|
// ======== 操作回调 ========
|
||||||
|
|
||||||
|
// ======== 订单状态轮询 ========
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== VipOrderStatus.Pending || !vipOrderId) return;
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const order = await VipAPI.getVipOrder(vipOrderId);
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const newStatus = order.status;
|
||||||
|
setVipOrderStatus(newStatus);
|
||||||
|
|
||||||
|
if (newStatus === VipOrderStatus.Pending) {
|
||||||
|
timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||||||
|
} else {
|
||||||
|
setPhase(newStatus);
|
||||||
|
if (newStatus === VipOrderStatus.Paid) {
|
||||||
|
void message.success('支付成功!VIP 权益已生效');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) timer = setTimeout(poll, POLL_INTERVAL_MS * 2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [phase, vipOrderId, message]);
|
||||||
|
|
||||||
|
// ======== 页面锁定(待支付时禁止关闭容器) ========
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const locked = phase === VipOrderStatus.Pending;
|
||||||
|
emit(EVENTS.PAGE_LOCK, { locked });
|
||||||
|
return () => {
|
||||||
|
if (locked) emit(EVENTS.PAGE_LOCK, { locked: false });
|
||||||
|
};
|
||||||
|
}, [phase]);
|
||||||
|
|
||||||
|
// ======== 操作回调 ========
|
||||||
|
|
||||||
|
/** 重新下单:回到表单,保留套餐选择 */
|
||||||
|
const handleRetry = useCallback(() => {
|
||||||
|
setPhase('form');
|
||||||
|
setVipOrderId(null);
|
||||||
|
setVipOrderStatus(null);
|
||||||
|
setVipPayUrl(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
emit(EVENTS.CLOSE_PAGE);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 渲染:加载态
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{display: 'flex', justifyContent: 'center', padding: '48px 0'}}>
|
||||||
|
<Spin size="default">
|
||||||
|
<div style={{padding: 24, textAlign: 'center'}}>
|
||||||
|
<Text type="secondary" style={{fontSize: 13}}>加载套餐列表…</Text>
|
||||||
|
</div>
|
||||||
|
</Spin>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 渲染:错误态
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
if (fetchError) {
|
||||||
|
return (
|
||||||
|
<div style={{padding: '8px 0'}}>
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title="套餐加载失败"
|
||||||
|
subTitle={fetchError}
|
||||||
|
extra={
|
||||||
|
<Button type="primary" size="small" onClick={fetchLevels}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 渲染:支付状态视图(非 form / activate 阶段)
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
if (phase !== 'form' && phase !== 'activate') {
|
||||||
|
const statusText = vipOrderStatus ? EnumResolver.vip.orderStatus.label(vipOrderStatus) : '';
|
||||||
|
const isPending = phase === VipOrderStatus.Pending;
|
||||||
|
const isPaid = phase === VipOrderStatus.Paid;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ padding: '16px 0' }}>
|
||||||
|
<Result
|
||||||
|
status={vipOrderStatus ? getResultStatus(vipOrderStatus) : 'info'}
|
||||||
|
title={
|
||||||
|
isPaid
|
||||||
|
? '支付成功'
|
||||||
|
: isPending
|
||||||
|
? `等待支付(${statusText})`
|
||||||
|
: statusText
|
||||||
|
}
|
||||||
|
subTitle={
|
||||||
|
isPaid
|
||||||
|
? 'VIP 会员权益已生效'
|
||||||
|
: isPending
|
||||||
|
? '请在新打开的浏览器页面中完成支付'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* ---- 订单信息卡片 ---- */}
|
||||||
|
{isPending && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: isDark ? '#2E2A5A' : '#F5F3FF',
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 16,
|
||||||
|
textAlign: 'left',
|
||||||
|
maxWidth: 360,
|
||||||
|
margin: '0 auto 16px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space orientation="vertical" size={6} style={{ width: '100%' }}>
|
||||||
|
<Text style={{ fontSize: 12 }} type="secondary">
|
||||||
|
订单编号:<Text copyable style={{ fontSize: 12 }}>{vipOrderId}</Text>
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 13 }}>
|
||||||
|
<DollarOutlined style={{ marginRight: 6, color: token.colorPrimary }} />
|
||||||
|
支付金额:
|
||||||
|
<Text strong>
|
||||||
|
¥{(() => {
|
||||||
|
const lvl = levels.find((l) => l.id === selectedLevelId);
|
||||||
|
return lvl ? centToYuan(lvl.price_cent) : '—';
|
||||||
|
})()}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 13 }}>
|
||||||
|
<CrownOutlined style={{ marginRight: 6, color: token.colorSuccess }} />
|
||||||
|
套餐:
|
||||||
|
<Text strong>{levels.find((l) => l.id === selectedLevelId)?.name ?? '—'}</Text>
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- 操作按钮 ---- */}
|
||||||
|
<Space size={12}>
|
||||||
|
{isPending && vipPayUrl && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<DollarOutlined />}
|
||||||
|
onClick={() => window.open(vipPayUrl, '_blank')}
|
||||||
|
>
|
||||||
|
打开支付页面
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!isPending && !isPaid && (
|
||||||
|
<Button type="primary" onClick={handleRetry}>
|
||||||
|
重新下单
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={handleClose}>
|
||||||
|
{isPaid ? '完成' : '关闭'}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Result>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- 协议弹窗(待支付阶段仍可查看) ---- */}
|
||||||
|
<LegalTextModal
|
||||||
|
open={legalOpen}
|
||||||
|
type="service"
|
||||||
|
onClose={() => setLegalOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// 渲染:正常
|
||||||
|
// ================================================================
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{padding: '4px 0'}}>
|
||||||
|
<div>
|
||||||
|
<Text type="secondary" style={{fontSize: 13, marginBottom: 10, display: 'block'}}>
|
||||||
|
<GiftOutlined style={{marginRight: 4}} />
|
||||||
|
使用激活码
|
||||||
|
</Text>
|
||||||
|
<Space.Compact style={{width: '100%'}}>
|
||||||
|
<Input
|
||||||
|
value={activateCode}
|
||||||
|
onChange={(e) => setActivateCode(e.target.value)}
|
||||||
|
placeholder="输入 VIP 激活码"
|
||||||
|
allowClear
|
||||||
|
onPressEnter={() => void handleActivate()}
|
||||||
|
style={{flex: 1}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<GiftOutlined />}
|
||||||
|
loading={activating}
|
||||||
|
onClick={() => void handleActivate()}
|
||||||
|
disabled={!activateCode.trim()}
|
||||||
|
>
|
||||||
|
激活
|
||||||
|
</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
</div>
|
||||||
|
<Divider style={{margin: '20px 0 16px'}} />
|
||||||
|
<Text type="secondary" style={{fontSize: 13, marginBottom: 12, display: 'block'}}>
|
||||||
|
选择会员套餐
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 双列布局:左列套餐选择 | 右列权益图库 */}
|
||||||
|
<div style={{display: 'flex', gap: 12, marginBottom: 16}}>
|
||||||
|
{/* ---- 左列:套餐选择 ---- */}
|
||||||
|
<div style={{flex: '0 0 52%', minWidth: 0}}>
|
||||||
|
<Radio.Group
|
||||||
|
value={selectedLevelId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedLevelId(e.target.value);
|
||||||
|
clearError('selectedLevel');
|
||||||
|
}}
|
||||||
|
style={{width: '100%'}}
|
||||||
|
>
|
||||||
|
<Space orientation="vertical" style={{width: '100%'}} size={6}>
|
||||||
|
{levels.map((level) => (
|
||||||
|
<Radio
|
||||||
|
key={level.id}
|
||||||
|
value={level.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
padding: '10px 12px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: `1px solid ${
|
||||||
|
selectedLevelId === level.id
|
||||||
|
? token.colorPrimary
|
||||||
|
: isDark
|
||||||
|
? '#2E2A5A'
|
||||||
|
: '#E5E7EB'
|
||||||
|
}`,
|
||||||
|
background:
|
||||||
|
selectedLevelId === level.id
|
||||||
|
? isDark
|
||||||
|
? '#2E2A5A'
|
||||||
|
: '#F5F3FF'
|
||||||
|
: isDark
|
||||||
|
? '#1A1730'
|
||||||
|
: '#FFFFFF',
|
||||||
|
width: '100%',
|
||||||
|
margin: 0,
|
||||||
|
transition: 'border-color 0.2s, background 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{flex: 1, marginLeft: 8, minWidth: 0}}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text strong style={{fontSize: 13}}>
|
||||||
|
{level.name}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: token.colorPrimary,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
¥{centToYuan(level.price_cent)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{display: 'flex', gap: 8, flexWrap: 'wrap'}}>
|
||||||
|
<Text type="secondary" style={{fontSize: 11}}>
|
||||||
|
{level.duration_days}天
|
||||||
|
</Text>
|
||||||
|
{level.gift_balance_cent > 0 && (
|
||||||
|
<Text type="secondary" style={{fontSize: 11}}>
|
||||||
|
赠{centToYuan(level.gift_balance_cent)}积分
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text type="secondary" style={{fontSize: 11}}>
|
||||||
|
{EnumResolver.vip.targetAccountType.label(level.target_account_type)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
{level.description && (
|
||||||
|
<Text type="secondary" style={{fontSize: 10, display: 'block', marginTop: 2}}>
|
||||||
|
{level.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Radio>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Radio.Group>
|
||||||
|
|
||||||
|
{errors.selectedLevel && (
|
||||||
|
<Text type="danger" style={{fontSize: 12, marginTop: 4, display: 'block'}}>
|
||||||
|
{errors.selectedLevel}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- 右列:权益图库 ---- */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text type="secondary" style={{fontSize: 11, textAlign: 'center'}}>
|
||||||
|
权益说明(点击放大)
|
||||||
|
</Text>
|
||||||
|
{levels.map((level) => (
|
||||||
|
<div
|
||||||
|
key={level.id}
|
||||||
|
style={{
|
||||||
|
borderRadius: 6,
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: `2px solid ${
|
||||||
|
selectedLevelId === level.id
|
||||||
|
? token.colorPrimary
|
||||||
|
: 'transparent'
|
||||||
|
}`,
|
||||||
|
opacity: selectedLevelId === null || selectedLevelId === level.id ? 1 : 0.45,
|
||||||
|
transition: 'border-color 0.2s, opacity 0.2s',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={level.image!}
|
||||||
|
alt={`${level.name} 权益`}
|
||||||
|
width="100%"
|
||||||
|
style={{display: 'block'}}
|
||||||
|
preview={{mask: '查看大图'}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- Part 2:支付协议 + 确认开通 ---- */}
|
||||||
|
<div style={{marginBottom: 16}}>
|
||||||
|
<Checkbox
|
||||||
|
checked={agreedToTerms}
|
||||||
|
onChange={(e) => {
|
||||||
|
setAgreedToTerms(e.target.checked);
|
||||||
|
clearError('agreedToTerms');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{fontSize: 12}}>
|
||||||
|
我已阅读并同意
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={() => setLegalOpen(true)}
|
||||||
|
style={{
|
||||||
|
padding: '0 2px',
|
||||||
|
fontSize: 12,
|
||||||
|
height: 'auto',
|
||||||
|
lineHeight: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
《用户服务协议》
|
||||||
|
</Button>
|
||||||
|
</Text>
|
||||||
|
</Checkbox>
|
||||||
|
{errors.agreedToTerms && (
|
||||||
|
<Text type="danger" style={{fontSize: 12, marginTop: 4, display: 'block'}}>
|
||||||
|
{errors.agreedToTerms}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div style={{display: 'flex', justifyContent: 'flex-end', gap: 8}}>
|
||||||
|
<Button onClick={handleClose}>取消</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<CrownOutlined />}
|
||||||
|
loading={submitting}
|
||||||
|
onClick={() => void handleSubmit()}
|
||||||
|
disabled={selectedLevelId === null}
|
||||||
|
>
|
||||||
|
确认开通
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- 协议弹窗 ---- */}
|
||||||
|
<LegalTextModal
|
||||||
|
open={legalOpen}
|
||||||
|
type="service"
|
||||||
|
onClose={() => setLegalOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
src/pages/panels/WechatWorkPage.tsx
Normal file
98
src/pages/panels/WechatWorkPage.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
// ============================================================
|
||||||
|
// WechatWorkPage — 企业微信二维码 Modal 内容
|
||||||
|
//
|
||||||
|
// 点击导航栏"企业微信"后以 Modal 形式展示二维码。
|
||||||
|
// 数据来源:GET /api/v1/creatives(Banner API)
|
||||||
|
//
|
||||||
|
// 识别逻辑:Banner 中 category === 'promotion' 的条目,
|
||||||
|
// 其 image_url 为企业微信二维码图片
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { Spin, Empty } from 'antd';
|
||||||
|
import { WechatOutlined } from '@ant-design/icons';
|
||||||
|
import { fetchBanners, type Banner } from '@/services/modules/banner';
|
||||||
|
|
||||||
|
export function WechatWorkPage() {
|
||||||
|
const [banner, setBanner] = useState<Banner | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const list = await fetchBanners();
|
||||||
|
// category === 'promotion' → 企业微信二维码
|
||||||
|
const promotion = list.find(
|
||||||
|
(item) => item.is_active && item.category === 'promotion',
|
||||||
|
);
|
||||||
|
setBanner(promotion ?? null);
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message || '加载失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
// 加载态
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 48 }}>
|
||||||
|
<Spin description="加载中..." />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无数据 / 错误
|
||||||
|
if (error || !banner || !banner.image_url) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 48 }}>
|
||||||
|
<Empty
|
||||||
|
image={<WechatOutlined style={{ fontSize: 48, color: '#9CA3AF' }} />}
|
||||||
|
description={error || '暂未获取到企业微信二维码,请联系管理员'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16, padding: 8 }}>
|
||||||
|
{/* 二维码图片 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 240,
|
||||||
|
height: 240,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: '#fff',
|
||||||
|
border: '1px solid #E5E7EB',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={banner.image_url}
|
||||||
|
alt="企业微信二维码"
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 标题(如果 Banner 有标题则显示) */}
|
||||||
|
{banner.title && (
|
||||||
|
<p style={{ margin: 0, fontSize: 14, fontWeight: 500, color: 'inherit' }}>
|
||||||
|
{banner.title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 提示文字 */}
|
||||||
|
<p style={{ margin: 0, fontSize: 13, color: 'inherit', opacity: 0.5 }}>
|
||||||
|
请使用微信扫描二维码添加企业微信
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,55 +1,72 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// SettingsPage — 设置页面(路由命中 /settings 时以居中 Modal 叠加展示)
|
// SettingsPage — 设置页面(Modal 居中叠加,事件驱动显隐)
|
||||||
//
|
//
|
||||||
// 核心设计:
|
// 核心设计:
|
||||||
// - 独立于 <Routes> 之外,通过 useLocation 判断是否渲染
|
// - 通过 open/onClose props 控制显隐,不使用路由(避免主页面被卸载)
|
||||||
// - 命中 /settings → Modal 居中弹出,首页内容保持在背景不动
|
// - Modal 居中弹出 + 遮罩层,首页内容始终保持挂载不动
|
||||||
// - 关闭 Modal → navigate(-1),仅可通过 X 按钮关闭
|
// - destroyOnHidden → 关闭即销毁 DOM,不堆内存
|
||||||
// - destroyOnClose → 关闭即销毁 DOM,不堆内存
|
// - Esc 键可关闭(跨平台统一)
|
||||||
|
// - macOS:关闭按钮移至标题左侧(遵循 macOS HIG)
|
||||||
|
//
|
||||||
|
// 对应 QT 原版:设置窗口为独立窗口叠加在主窗口之上,主窗口不销毁
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import {Modal} from 'antd';
|
import { useCallback } from 'react';
|
||||||
import {SettingOutlined} from '@ant-design/icons';
|
import { Modal } from 'antd';
|
||||||
import {useLocation, useNavigate} from 'react-router-dom';
|
import { SettingOutlined, CloseOutlined } from '@ant-design/icons';
|
||||||
import {useAppContext} from '@/contexts/app-context';
|
import { useAppContext } from '@/components/providers/AppProvider';
|
||||||
import {ThemeSetting} from './ThemeSetting';
|
import { isMacOS } from '@/utils/platform';
|
||||||
import {StoragePathSetting} from './StoragePathSetting';
|
import { ThemeSetting } from './sections/ThemeSetting';
|
||||||
import {SystemInfoSetting} from './SystemInfoSetting';
|
import { StoragePathSetting } from './sections/StoragePathSetting';
|
||||||
import {UpdateSetting} from './UpdateSetting';
|
import { SystemInfoSetting } from './sections/SystemInfoSetting';
|
||||||
|
import { UpdateSetting } from './sections/UpdateSetting';
|
||||||
|
import { PasswordSetting } from './sections/PasswordSetting';
|
||||||
|
|
||||||
export function SettingsPage() {
|
interface SettingsPageProps {
|
||||||
const location = useLocation();
|
open: boolean;
|
||||||
const navigate = useNavigate();
|
onClose: () => void;
|
||||||
const {edition} = useAppContext();
|
}
|
||||||
const open = location.pathname === '/settings';
|
|
||||||
|
|
||||||
return (
|
export function SettingsPage({ open, onClose }: SettingsPageProps) {
|
||||||
<Modal
|
const { edition } = useAppContext();
|
||||||
open={open}
|
const isMac = isMacOS();
|
||||||
width={480}
|
|
||||||
onCancel={() => navigate(-1)}
|
// 关闭后移除焦点,避免跑到导航栏设置按钮上
|
||||||
destroyOnHidden={true}
|
const handleAfterClose = useCallback(() => {
|
||||||
mask={
|
(document.activeElement as HTMLElement)?.blur();
|
||||||
{
|
}, []);
|
||||||
closable: false
|
|
||||||
}
|
return (
|
||||||
}
|
<Modal
|
||||||
keyboard={false}
|
open={open}
|
||||||
footer={null}
|
width={480}
|
||||||
title={
|
onCancel={onClose}
|
||||||
<span className="flex items-center gap-2">
|
afterClose={handleAfterClose}
|
||||||
|
destroyOnHidden={true}
|
||||||
|
mask={{ closable: false }}
|
||||||
|
closable={!isMac}
|
||||||
|
footer={null}
|
||||||
|
title={
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{isMac && (
|
||||||
|
<CloseOutlined
|
||||||
|
onClick={onClose}
|
||||||
|
className="cursor-pointer text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<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} />
|
<SystemInfoSetting />
|
||||||
<SystemInfoSetting />
|
<UpdateSetting />
|
||||||
<UpdateSetting />
|
<StoragePathSetting edition={edition} />
|
||||||
</div>
|
<PasswordSetting />
|
||||||
</Modal>
|
</div>
|
||||||
);
|
</Modal>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
|
||||||
|
|||||||
52
src/pages/settings/sections/PasswordSetting.tsx
Normal file
52
src/pages/settings/sections/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/auth/components/ChangePasswordModal';
|
||||||
|
import { ForgotPasswordModal } from '@/pages/auth/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)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import { useState, useCallback } from 'react';
|
|||||||
import { Card, Button, Input, Typography, Space, App } from 'antd';
|
import { Card, Button, Input, Typography, Space, App } from 'antd';
|
||||||
import { FolderOpenOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
import { FolderOpenOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
||||||
import type { Edition } from '@shared/types';
|
import type { Edition } from '@shared/types';
|
||||||
import { useSettings } from '@/contexts/settings-context';
|
import { useSettings } from '@/components/providers/SettingsProvider';
|
||||||
import { hasIpc, safeIpcInvoke } from '@/utils/ipc';
|
import { hasIpc, safeIpcInvoke } from '@/utils/ipc';
|
||||||
import { BIDIRECTIONAL } from '@shared/constants/ipc-channels';
|
import { BIDIRECTIONAL } from '@shared/constants/ipc-channels';
|
||||||
|
|
||||||
@@ -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';
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
InfoCircleOutlined,
|
InfoCircleOutlined,
|
||||||
EnvironmentOutlined,
|
EnvironmentOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useAppContext } from '@/contexts/app-context';
|
import { useAppContext } from '@/components/providers/AppProvider';
|
||||||
import { getPlatformName } from '@/utils/platform';
|
import { getPlatformName } from '@/utils/platform';
|
||||||
import { APP_VERSION } from '@shared/constants/version';
|
import { APP_VERSION } from '@shared/constants/version';
|
||||||
import { getEditionName } from '@shared/constants/app';
|
import { getEditionName } from '@shared/constants/app';
|
||||||
@@ -46,14 +46,14 @@ export function SystemInfoSetting() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card size="small" title="系统信息" className="rounded-md!">
|
<Card size="small" title="系统信息(开发显示信息)" className="rounded-md!">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{infoItems.map((item) => (
|
{infoItems.map((item) => (
|
||||||
<div key={item.label} className="flex items-center gap-2">
|
<div key={item.label} className="flex items-center gap-2">
|
||||||
<span style={{ color: 'var(--color-primary, #4F46E5)', fontSize: 14 }}>
|
<span style={{ color: 'var(--color-primary, #4F46E5)', fontSize: 14 }}>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
</span>
|
</span>
|
||||||
<Text type="secondary" className="text-sm min-w-[64px]">
|
<Text type="secondary" className="text-sm min-w-16">
|
||||||
{item.label}:
|
{item.label}:
|
||||||
</Text>
|
</Text>
|
||||||
<Tag color={item.color}>{item.value}</Tag>
|
<Tag color={item.color}>{item.value}</Tag>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import { Card, Segmented, Typography } from 'antd';
|
import { Card, Segmented, Typography } from 'antd';
|
||||||
import { BulbOutlined, BulbFilled } from '@ant-design/icons';
|
import { BulbOutlined, BulbFilled } from '@ant-design/icons';
|
||||||
import { useTheme } from '@/hooks/use-theme';
|
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -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>
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// AppRoutes — 应用路由配置(由 App.tsx 在 HashRouter 内使用)
|
|
||||||
//
|
|
||||||
// 核心设计:
|
|
||||||
// - SettingsPage 在 <Routes> 外部,路由命中时以 Drawer 叠加首页
|
|
||||||
// - 首页始终保持挂载,不会因导航而卸载
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { Routes, Route } from 'react-router-dom';
|
|
||||||
import { Layout } from '@/components/Layout';
|
|
||||||
import { HomePage } from '@/pages/home/HomePage';
|
|
||||||
import { SettingsPage } from '@/pages/settings';
|
|
||||||
|
|
||||||
export function AppRoutes() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/*" element={<Layout />}>
|
|
||||||
<Route index element={<HomePage />} />
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
{/* 独立于 Routes:根据 URL /settings 自动显隐 Drawer */}
|
|
||||||
<SettingsPage />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
84
src/services/auth-persistence.ts
Normal file
84
src/services/auth-persistence.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// ============================================================
|
||||||
|
// auth-persistence — 本地认证偏好持久化(记住账号 / 自动登录)
|
||||||
|
//
|
||||||
|
// 职责:
|
||||||
|
// - 存储/读取登录表单偏好(用户名回填、记住密码、自动登录标记)
|
||||||
|
// - 纯 localStorage 操作,不依赖 React
|
||||||
|
// - 密码从不持久化(安全性由 JWT + safeStorage 保障)
|
||||||
|
//
|
||||||
|
// 与 auth-token.ts 的分工:
|
||||||
|
// auth-token.ts → JWT token 生命周期(refresh/access,safeStorage 加密)
|
||||||
|
// auth-persistence.ts → 登录表单偏好(localStorage 明文,仅用户名/标记)
|
||||||
|
// ============================================================ */
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'ele-heixiu-auth';
|
||||||
|
|
||||||
|
interface StoredAuth {
|
||||||
|
/** 上次登录的用户名(表单回填用) */
|
||||||
|
username: string;
|
||||||
|
/** 是否记住用户名(勾选后表单自动回填用户名) */
|
||||||
|
remember: boolean;
|
||||||
|
/** 是否勾选了自动登录(AppProvider 据此决定启动时是否刷新 token) */
|
||||||
|
autoLogin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAuth(): StoredAuth | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
return JSON.parse(raw) as StoredAuth;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAuth(auth: StoredAuth): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(auth));
|
||||||
|
} catch {
|
||||||
|
/* 静默 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 公共 API ----------
|
||||||
|
|
||||||
|
/** 读取自动登录标记(AppProvider 启动时调用) */
|
||||||
|
export function getAutoLoginFlag(): boolean {
|
||||||
|
return readAuth()?.autoLogin || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅清除自动登录标记(保留用户名,下次还能回填表单) */
|
||||||
|
export function clearAutoLoginFlag(): void {
|
||||||
|
const auth = readAuth();
|
||||||
|
if (!auth) return;
|
||||||
|
writeAuth({ ...auth, autoLogin: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清除所有保存的认证偏好 */
|
||||||
|
export function clearSavedAuth(): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
/* 静默 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存认证偏好(登录成功时调用) */
|
||||||
|
export function saveAuth(username: string, remember: boolean, autoLogin: boolean): void {
|
||||||
|
writeAuth({ username, remember, autoLogin });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取已保存的用户名(表单回填用) */
|
||||||
|
export function getSavedUsername(): string {
|
||||||
|
return readAuth()?.username || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取记住密码标记 */
|
||||||
|
export function getSavedRemember(): boolean {
|
||||||
|
return readAuth()?.remember || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取自动登录标记 */
|
||||||
|
export function getSavedAutoLogin(): boolean {
|
||||||
|
return readAuth()?.autoLogin || false;
|
||||||
|
}
|
||||||
@@ -1,19 +1,24 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// auth-token — Token 生命周期管理(存取 refresh_token + 注册刷新回调)
|
// auth-token — Token 生命周期管理(存取 refresh_token + 刷新回调 + 主动续期)
|
||||||
//
|
//
|
||||||
// 职责:
|
// 职责:
|
||||||
// - 持久化 / 清除 refresh_token(通过 safeStorage 加密)
|
// - 持久化 / 清除 refresh_token(通过 safeStorage 加密)
|
||||||
// - 向 request.ts 注册 handle401 触发的 token 刷新回调
|
// - 向 request.ts 注册 handle401 触发的 token 刷新回调
|
||||||
|
// - 主动刷新定时器:在 access_token 过期前自动续期,避免被动 401
|
||||||
// - request.ts 不依赖此模块(通过 setTokenRefreshHandler 解耦)
|
// - request.ts 不依赖此模块(通过 setTokenRefreshHandler 解耦)
|
||||||
//
|
//
|
||||||
|
// 刷新失败策略(关键设计):
|
||||||
|
// - 永久失败(refresh_token 已过期/无效)→ THROW → handle401 清 token + 弹登录
|
||||||
|
// - 临时失败(网络超时/无连接) → return null → handle401 仅拒绝当前请求,不登出
|
||||||
|
//
|
||||||
// 安全:
|
// 安全:
|
||||||
// - refresh_token 由 Electron safeStorage 加密后存入 localStorage
|
// - refresh_token 由 Electron safeStorage 加密后存入 localStorage
|
||||||
// - 内存缓存供同步读取(setTokenRefreshHandler 回调内使用)
|
// - 内存缓存供同步读取(setTokenRefreshHandler 回调内使用)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { setToken, setTokenRefreshHandler } from './request';
|
import { setToken, setTokenRefreshHandler, RequestError, RequestErrorType } from './request';
|
||||||
import { AuthAPI } from './modules';
|
import { AuthAPI, type RefreshTokenResponseBody } from './modules/auth';
|
||||||
import type { RefreshTokenResponseBody } from './modules';
|
import { isRefreshError } from './types';
|
||||||
import {
|
import {
|
||||||
getSafeStore,
|
getSafeStore,
|
||||||
setSafeStore,
|
setSafeStore,
|
||||||
@@ -23,7 +28,24 @@ import {
|
|||||||
|
|
||||||
const REFRESH_KEY = 'ele-heixiu-refresh-token';
|
const REFRESH_KEY = 'ele-heixiu-refresh-token';
|
||||||
|
|
||||||
// ---------- 初始化 ----------
|
// ============================================================
|
||||||
|
// 自定义错误:refresh_token 永久失效
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当 refresh_token 已过期或被服务端拒绝时抛出。
|
||||||
|
* handle401 据此区分"需要重新登录"和"网络暂时不通"。
|
||||||
|
*/
|
||||||
|
export class RefreshTokenExpiredError extends Error {
|
||||||
|
constructor(message = '登录已过期,请重新登录') {
|
||||||
|
super(message);
|
||||||
|
this.name = 'RefreshTokenExpiredError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 初始化
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动时初始化 refresh_token:从加密存储解密到内存缓存。
|
* 启动时初始化 refresh_token:从加密存储解密到内存缓存。
|
||||||
@@ -33,7 +55,9 @@ export async function initRefreshTokenStore(): Promise<void> {
|
|||||||
await initSafeStore(REFRESH_KEY);
|
await initSafeStore(REFRESH_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 存取 ----------
|
// ============================================================
|
||||||
|
// 存取
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
/** 获取当前内存缓存中的 refresh_token(同步,需先调用 initRefreshTokenStore) */
|
/** 获取当前内存缓存中的 refresh_token(同步,需先调用 initRefreshTokenStore) */
|
||||||
export function getStoredRefreshToken(): string | null {
|
export function getStoredRefreshToken(): string | null {
|
||||||
@@ -50,19 +74,92 @@ export function clearStoredRefreshToken(): void {
|
|||||||
clearSafeStore(REFRESH_KEY);
|
clearSafeStore(REFRESH_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 初始化(App 启动时调用一次) ----------
|
// ============================================================
|
||||||
|
// JWT 解码(用于主动刷新定时器)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** 从 JWT access_token 中提取过期时间(毫秒时间戳),解码失败返回 0 */
|
||||||
|
function getTokenExpiryMs(token: string): number {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
return (payload.exp as number) * 1000;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 主动刷新定时器
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** 主动刷新定时器句柄(模块级单例) */
|
||||||
|
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安排在 access_token 过期前自动刷新。
|
||||||
|
* 在 75% 过期时间点触发,确保在被动 401 之前完成续期。
|
||||||
|
*
|
||||||
|
* @param accessToken - 当前有效的 access_token(JWT 格式)
|
||||||
|
*/
|
||||||
|
export function scheduleProactiveRefresh(accessToken: string): void {
|
||||||
|
// 先清除旧定时器
|
||||||
|
clearProactiveRefresh();
|
||||||
|
|
||||||
|
const expiryMs = getTokenExpiryMs(accessToken);
|
||||||
|
if (!expiryMs) return;
|
||||||
|
|
||||||
|
const remainingMs = expiryMs - Date.now();
|
||||||
|
if (remainingMs <= 0) return; // 已经过期,等被动刷新
|
||||||
|
|
||||||
|
// 在 75% 过期时间点触发
|
||||||
|
const delayMs = Math.max(60_000, remainingMs * 0.75); // 至少 60s 后再刷新
|
||||||
|
|
||||||
|
refreshTimer = setTimeout(async () => {
|
||||||
|
refreshTimer = null;
|
||||||
|
try {
|
||||||
|
const refreshToken = getStoredRefreshToken();
|
||||||
|
if (!refreshToken) return;
|
||||||
|
|
||||||
|
const res: RefreshTokenResponseBody = await AuthAPI.refreshToken({ refresh_token: refreshToken });
|
||||||
|
await setToken(res.access_token);
|
||||||
|
await setStoredRefreshToken(res.refresh_token);
|
||||||
|
|
||||||
|
// 成功 → 安排下一次刷新
|
||||||
|
scheduleProactiveRefresh(res.access_token);
|
||||||
|
} catch {
|
||||||
|
// 主动刷新失败 → 不弹登录,等请求 401 时走被动刷新
|
||||||
|
}
|
||||||
|
}, delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清除主动刷新定时器(退出登录时调用) */
|
||||||
|
export function clearProactiveRefresh(): void {
|
||||||
|
if (refreshTimer !== null) {
|
||||||
|
clearTimeout(refreshTimer);
|
||||||
|
refreshTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 向 request.ts 注册 401 刷新回调(被动刷新)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 向 request.ts 注册 token 刷新回调。
|
* 向 request.ts 注册 token 刷新回调。
|
||||||
* 当任意请求收到 401 时,request.ts 会调用此回调尝试换新 token,
|
|
||||||
* 成功后自动重试原请求,失败则 emit AUTH_REQUIRED。
|
|
||||||
*
|
*
|
||||||
* 应在 AppProvider 挂载时调用。
|
* 返回约定(由 handle401 解释):
|
||||||
|
* - 成功返回新 access_token → handle401 重试原请求
|
||||||
|
* - return null → 临时失败(网络问题),handle401 拒绝当前请求但不登出
|
||||||
|
* - throw RefreshTokenExpiredError → 永久失败,handle401 清 token + 弹登录
|
||||||
|
*
|
||||||
|
* 应在 AppProvider 挂载时调用一次。
|
||||||
*/
|
*/
|
||||||
export function initAuthRefresh(): void {
|
export function initAuthRefresh(): void {
|
||||||
setTokenRefreshHandler(async (): Promise<string | null> => {
|
setTokenRefreshHandler(async (): Promise<string | null> => {
|
||||||
const refreshToken = getStoredRefreshToken();
|
const refreshToken = getStoredRefreshToken();
|
||||||
if (!refreshToken) return null;
|
if (!refreshToken) {
|
||||||
|
throw new RefreshTokenExpiredError();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res: RefreshTokenResponseBody = await AuthAPI.refreshToken({ refresh_token: refreshToken });
|
const res: RefreshTokenResponseBody = await AuthAPI.refreshToken({ refresh_token: refreshToken });
|
||||||
@@ -71,11 +168,29 @@ export function initAuthRefresh(): void {
|
|||||||
await setToken(res.access_token);
|
await setToken(res.access_token);
|
||||||
await setStoredRefreshToken(res.refresh_token);
|
await setStoredRefreshToken(res.refresh_token);
|
||||||
|
|
||||||
|
// 主动刷新:为新 token 安排下次续期
|
||||||
|
scheduleProactiveRefresh(res.access_token);
|
||||||
|
|
||||||
return res.access_token;
|
return res.access_token;
|
||||||
} catch {
|
} catch (err) {
|
||||||
// 刷新失败 → 清除残留
|
// ---- 永久失败:refresh_token 已过期/无效 ----
|
||||||
|
if (isRefreshError(err)) {
|
||||||
|
clearStoredRefreshToken();
|
||||||
|
clearProactiveRefresh();
|
||||||
|
throw new RefreshTokenExpiredError();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 临时失败:网络超时/无连接 → 保留 token,下次再试 ----
|
||||||
|
if (err instanceof RequestError) {
|
||||||
|
if (err.type === RequestErrorType.NETWORK || err.type === RequestErrorType.TIMEOUT) {
|
||||||
|
return null; // 临时失败,不登出
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- HTTP/Business 错误(如 5xx)→ 保守清除 ----
|
||||||
clearStoredRefreshToken();
|
clearStoredRefreshToken();
|
||||||
return null;
|
clearProactiveRefresh();
|
||||||
|
throw new RefreshTokenExpiredError();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
51
src/services/cache/model-cache.ts
vendored
Normal file
51
src/services/cache/model-cache.ts
vendored
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
// ============================================================
|
||||||
|
// model-cache — 模型列表加密本地缓存
|
||||||
|
//
|
||||||
|
// 策略:
|
||||||
|
// - 应用启动时 initModelCache() 从加密 localStorage 恢复缓存
|
||||||
|
// - getCachedModels() 同步读取(内存缓存,高频安全)
|
||||||
|
// - setCachedModels() 异步加密写入(API 返回后更新)
|
||||||
|
// - 降级:非 Electron 环境回退到 localStorage 明文
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import {initSafeStore, getSafeStore, setSafeStore} from '@/utils/safe-storage';
|
||||||
|
import type {ModelsListResponseBody} from '@/services/modules/models';
|
||||||
|
|
||||||
|
/** 缓存键名(与 safe-storage 加密存储一致) */
|
||||||
|
const MODEL_CACHE_KEY = 'ele-heixiu-model-cache';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动时初始化模型缓存:从 localStorage 解密到内存缓存。
|
||||||
|
* 应在 AppProvider 挂载时调用(在 useModelList 首次使用之前)。
|
||||||
|
*/
|
||||||
|
export async function initModelCache(): Promise<void> {
|
||||||
|
await initSafeStore(MODEL_CACHE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步读取缓存的模型列表(内存缓存,不触发 IPC)。
|
||||||
|
* 返回 null 表示无缓存(需从 API 拉取)。
|
||||||
|
*/
|
||||||
|
export function getCachedModels(): ModelsListResponseBody[] | null {
|
||||||
|
const raw = getSafeStore(MODEL_CACHE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (Array.isArray(parsed)) return parsed as ModelsListResponseBody[];
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步加密写入模型列表到 localStorage + 更新内存缓存。
|
||||||
|
* API 返回后调用,best-effort(失败不抛异常,下次启动仍可用旧缓存)。
|
||||||
|
*/
|
||||||
|
export async function setCachedModels(models: ModelsListResponseBody[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
await setSafeStore(MODEL_CACHE_KEY, JSON.stringify(models));
|
||||||
|
} catch {
|
||||||
|
// 写入失败静默,内存缓存已在 setSafeStore 内部更新
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,8 +15,35 @@ const AnnouncementUrlObj = {
|
|||||||
|
|
||||||
// ---------- 类型 ----------
|
// ---------- 类型 ----------
|
||||||
|
|
||||||
/** 公告类型枚举 */
|
// ============================================================
|
||||||
export type AnnouncementType = 'system' | 'feature' | 'activity' | 'maintenance' | string;
|
// 公告类型常量
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export const AnnouncementTypeEnum = {
|
||||||
|
System: "system",
|
||||||
|
Feature: "feature",
|
||||||
|
Activity: "activity",
|
||||||
|
Maintenance: "maintenance",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type AnnouncementTypeValue = typeof AnnouncementTypeEnum[keyof typeof AnnouncementTypeEnum];
|
||||||
|
|
||||||
|
export const AnnouncementTypeLabelMap: Record<AnnouncementTypeValue, string> = {
|
||||||
|
[AnnouncementTypeEnum.System]: "系统公告",
|
||||||
|
[AnnouncementTypeEnum.Feature]: "功能更新",
|
||||||
|
[AnnouncementTypeEnum.Activity]: "活动通知",
|
||||||
|
[AnnouncementTypeEnum.Maintenance]: "维护公告",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AnnouncementTypeColorMap: Record<AnnouncementTypeValue, string> = {
|
||||||
|
[AnnouncementTypeEnum.System]: "blue",
|
||||||
|
[AnnouncementTypeEnum.Feature]: "purple",
|
||||||
|
[AnnouncementTypeEnum.Activity]: "orange",
|
||||||
|
[AnnouncementTypeEnum.Maintenance]: "red",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 公告类型(向后兼容:包含已知枚举 + string 宽类型) */
|
||||||
|
export type AnnouncementType = AnnouncementTypeValue | string;
|
||||||
|
|
||||||
/** 单条公告 */
|
/** 单条公告 */
|
||||||
export interface Announcement {
|
export interface Announcement {
|
||||||
|
|||||||
@@ -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',
|
||||||
@@ -20,6 +20,34 @@ const AuthUrlObj = {
|
|||||||
// 认证相关 DTO 传输模型
|
// 认证相关 DTO 传输模型
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
|
export const UserRoleTypeEnum = {
|
||||||
|
User: "user",
|
||||||
|
Admin: "admin",
|
||||||
|
SuperAdmin: "superadmin",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type UserRoleTypeValue = typeof UserRoleTypeEnum[keyof typeof UserRoleTypeEnum];
|
||||||
|
|
||||||
|
export const UserRoleTypeLabelMap: Record<UserRoleTypeValue, string> = {
|
||||||
|
[UserRoleTypeEnum.User]: "用户账户",
|
||||||
|
[UserRoleTypeEnum.Admin]: "管理账户",
|
||||||
|
[UserRoleTypeEnum.SuperAdmin]: "超管账户"
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UserAccountTypeEnum = {
|
||||||
|
Individual: 'individual',
|
||||||
|
Company: 'company',
|
||||||
|
Employee: 'employee',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type UserAccountTypeValue = typeof UserAccountTypeEnum[keyof typeof UserAccountTypeEnum];
|
||||||
|
|
||||||
|
export const UserAccountTypeLabelMap: Record<UserAccountTypeValue, string> = {
|
||||||
|
[UserAccountTypeEnum.Individual]: "个人用户",
|
||||||
|
[UserAccountTypeEnum.Company]: "企业用户",
|
||||||
|
[UserAccountTypeEnum.Employee]: "员工账号"
|
||||||
|
};
|
||||||
|
|
||||||
/** 短信验证码请求体 */
|
/** 短信验证码请求体 */
|
||||||
export interface SendSMSRequestBody {
|
export interface SendSMSRequestBody {
|
||||||
phone: string;
|
phone: string;
|
||||||
@@ -53,10 +81,10 @@ export interface RegisterResponseBody {
|
|||||||
refresh_expires_in: 0;
|
refresh_expires_in: 0;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
username: string;
|
username: string;
|
||||||
role: "user" | "admin" | "superadmin";
|
role: UserRoleTypeValue;
|
||||||
email: string;
|
email: string;
|
||||||
balance_cent: 0;
|
balance_cent: 0;
|
||||||
account_type: "individual" | "company" | "employee";
|
account_type: UserAccountTypeValue;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
company_id: string;
|
company_id: string;
|
||||||
real_name: string;
|
real_name: string;
|
||||||
@@ -81,10 +109,10 @@ export interface LoginResponseBody {
|
|||||||
refresh_expires_in: number;
|
refresh_expires_in: number;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
username: string;
|
username: string;
|
||||||
role: "user" | "admin" | "superadmin";
|
role: UserRoleTypeValue;
|
||||||
email: string;
|
email: string;
|
||||||
balance_cent: number;
|
balance_cent: number;
|
||||||
account_type: "individual" | "company" | "employee";
|
account_type: UserAccountTypeValue;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
company_id: string;
|
company_id: string;
|
||||||
real_name: string;
|
real_name: string;
|
||||||
@@ -108,27 +136,27 @@ export interface RefreshTokenResponseBody {
|
|||||||
export interface UserInfoBody {
|
export interface UserInfoBody {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string | null;
|
||||||
balance_cent: number;
|
balance_cent: number;
|
||||||
gift_balance_cent: number;
|
gift_balance_cent: number;
|
||||||
role: "user" | "admin" | "superadmin";
|
role: UserRoleTypeValue;
|
||||||
status: string;
|
status: string;
|
||||||
account_type: "individual" | "company" | "employee";
|
account_type: UserAccountTypeValue;
|
||||||
display_name: string;
|
display_name: string | null;
|
||||||
real_name: string;
|
real_name: string | null;
|
||||||
phone: string;
|
phone: string;
|
||||||
company_id: string;
|
company_id: string | null;
|
||||||
discount_rate: number;
|
discount_rate: number | null;
|
||||||
subscription_plan: string;
|
subscription_plan: string;
|
||||||
subscription_expires_at: Date;
|
subscription_expires_at: Date;
|
||||||
seat_limit: number;
|
seat_limit: number | null;
|
||||||
last_login_at: Date;
|
last_login_at: Date;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
model_package_id: number;
|
model_package_id: number;
|
||||||
vip_level_id: number;
|
vip_level_id: number;
|
||||||
software_expires_at: Date;
|
software_expires_at: Date;
|
||||||
referral_code: string;
|
referral_code: string | null;
|
||||||
admin_permissions: string[];
|
admin_permissions: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChangePasswordRequestBody {
|
export interface ChangePasswordRequestBody {
|
||||||
@@ -149,42 +177,42 @@ export interface UsePhoneResetPasswordRequestBody {
|
|||||||
|
|
||||||
export class AuthAPI {
|
export class AuthAPI {
|
||||||
/** 登录 */
|
/** 登录 */
|
||||||
static login(data: LoginRequestBody): Promise<LoginResponseBody> {
|
static async login(data: LoginRequestBody): Promise<LoginResponseBody> {
|
||||||
return post<LoginResponseBody>(AuthUrlObj.login, data);
|
return await post<LoginResponseBody>(AuthUrlObj.login, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 注册 */
|
/** 注册 */
|
||||||
static register(data: RegisterRequestBody): Promise<RegisterResponseBody> {
|
static async register(data: RegisterRequestBody): Promise<RegisterResponseBody> {
|
||||||
return post<RegisterResponseBody>(AuthUrlObj.register, data);
|
return await post<RegisterResponseBody>(AuthUrlObj.register, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 发送短信验证码 */
|
/** 发送短信验证码 */
|
||||||
static sendSms(data: SendSMSRequestBody): Promise<void> {
|
static async sendSms(data: SendSMSRequestBody): Promise<void> {
|
||||||
return post<void>(AuthUrlObj.sendSms, data);
|
return await post<void>(AuthUrlObj.sendSms, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 刷新 Token */
|
/** 刷新 Token */
|
||||||
static refreshToken(data: RefreshTokenRequestBody): Promise<RefreshTokenResponseBody> {
|
static async refreshToken(data: RefreshTokenRequestBody): Promise<RefreshTokenResponseBody> {
|
||||||
return post<RefreshTokenResponseBody>(AuthUrlObj.refreshToken, data);
|
return await post<RefreshTokenResponseBody>(AuthUrlObj.refreshToken, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取当前用户信息 */
|
/** 获取当前用户信息 */
|
||||||
static getUserInfo(): Promise<UserInfoBody> {
|
static async getUserInfo(signal?: AbortSignal): Promise<UserInfoBody> {
|
||||||
return get<UserInfoBody>(AuthUrlObj.userInfo);
|
return await get<UserInfoBody>(AuthUrlObj.userInfo, undefined, { signal });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 修改密码(已登录) */
|
/** 修改密码(已登录) */
|
||||||
static changePassword(data: ChangePasswordRequestBody): Promise<void> {
|
static async changePassword(data: ChangePasswordRequestBody): Promise<void> {
|
||||||
return post<void>(AuthUrlObj.changePassword, data);
|
return await post<void>(AuthUrlObj.changePassword, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 手机验证码重置密码 */
|
/** 手机验证码重置密码 */
|
||||||
static resetPassword(data: UsePhoneResetPasswordRequestBody): Promise<void> {
|
static async resetPassword(data: UsePhoneResetPasswordRequestBody): Promise<void> {
|
||||||
return post<void>(AuthUrlObj.resetPassword, data);
|
return await post<void>(AuthUrlObj.resetPassword, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 退出登录 */
|
/** 退出登录 */
|
||||||
static logout(): Promise<void> {
|
static async logout(): Promise<void> {
|
||||||
return post<void>(AuthUrlObj.logout);
|
return await post<void>(AuthUrlObj.logout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
59
src/services/modules/banner.ts
Normal file
59
src/services/modules/banner.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// ============================================================
|
||||||
|
// Banner 模块 — 首页轮播图 API
|
||||||
|
// GET /api/v1/banners
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import {get} from '../request';
|
||||||
|
|
||||||
|
// ---------- 路由常量 ----------
|
||||||
|
|
||||||
|
const BannerUrlObj = {
|
||||||
|
list: '/api/v1/creatives',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ---------- 类型 ----------
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Banner 分类常量
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export const BannerCategoryEnum = {
|
||||||
|
Promotion: "promotion",
|
||||||
|
Banner: "banner",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type BannerCategoryValue = typeof BannerCategoryEnum[keyof typeof BannerCategoryEnum];
|
||||||
|
|
||||||
|
export const BannerCategoryLabelMap: Record<BannerCategoryValue, string> = {
|
||||||
|
[BannerCategoryEnum.Promotion]: "推广",
|
||||||
|
[BannerCategoryEnum.Banner]: "轮播",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 单条 Banner */
|
||||||
|
export interface Banner {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
content: "真人素材提示";
|
||||||
|
image_url: string;
|
||||||
|
link_url?: string;
|
||||||
|
category: BannerCategoryValue;
|
||||||
|
vip_level_id?: number;
|
||||||
|
sort_order: number;
|
||||||
|
is_active: boolean;
|
||||||
|
created_by: string;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Banner 列表响应 */
|
||||||
|
export type BannerListResponse = Banner[];
|
||||||
|
|
||||||
|
// ---------- API ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Banner 轮播图列表
|
||||||
|
* GET /api/v1/banners
|
||||||
|
*/
|
||||||
|
export function fetchBanners(signal?: AbortSignal): Promise<BannerListResponse> {
|
||||||
|
return get<BannerListResponse>(BannerUrlObj.list, undefined, { signal });
|
||||||
|
}
|
||||||
91
src/services/modules/billing.ts
Normal file
91
src/services/modules/billing.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import {type Nullable} from "@/utils/type";
|
||||||
|
import {get} from "@/services/request.ts";
|
||||||
|
|
||||||
|
const BillingUrlObj = {
|
||||||
|
getBalance: '/api/v1/billing/balance',
|
||||||
|
getLedger: '/api/v1/billing/ledger',
|
||||||
|
ExportLedger: "/api/v1/billing/ledger/export"
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface BalanceResponseBody {
|
||||||
|
user_id: string,
|
||||||
|
balance_cent: Nullable<number>,
|
||||||
|
gift_balance_cent: Nullable<number>,
|
||||||
|
currency: "CNY",
|
||||||
|
today_spent_cent: number,
|
||||||
|
today_calls: number
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface LedgerReqeustParam {
|
||||||
|
month: Nullable<Date>,
|
||||||
|
page: number,
|
||||||
|
page_size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LedgerItemResponseBody {
|
||||||
|
id: string,
|
||||||
|
entry_type: string,
|
||||||
|
balance_cent: string,
|
||||||
|
amount_cent: number,
|
||||||
|
currency: string,
|
||||||
|
balance_after_cent: number,
|
||||||
|
task_id: string,
|
||||||
|
description: "string",
|
||||||
|
accounting_month: "string",
|
||||||
|
accounting_day: "string",
|
||||||
|
created_at: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportLedgerRequstParam {
|
||||||
|
start_time: Date,
|
||||||
|
end_time: Date,
|
||||||
|
entry_type: Nullable<string>
|
||||||
|
debug?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BillingAPI {
|
||||||
|
static getBalance(signal?: AbortSignal): Promise<BalanceResponseBody> {
|
||||||
|
return get<BalanceResponseBody>(BillingUrlObj.getBalance, undefined, {signal});
|
||||||
|
}
|
||||||
|
|
||||||
|
static getLedger(param: LedgerReqeustParam, signal?: AbortSignal): Promise<LedgerItemResponseBody[]> {
|
||||||
|
return get<LedgerItemResponseBody[]>(BillingUrlObj.getLedger, param as unknown as Record<string, unknown>, {signal});
|
||||||
|
}
|
||||||
|
|
||||||
|
static exportUrl = BillingUrlObj.ExportLedger;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 账单条目类型常量
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export const EntryTypeEnum = {
|
||||||
|
TaskHold: "task_hold",
|
||||||
|
Recharge: "recharge",
|
||||||
|
Gift: "gift",
|
||||||
|
HoldRelease: "hold_release",
|
||||||
|
HoldSettle: "hold_settle",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type EntryTypeValue = typeof EntryTypeEnum[keyof typeof EntryTypeEnum];
|
||||||
|
|
||||||
|
export const EntryTypeLabelMap = {
|
||||||
|
[EntryTypeEnum.TaskHold]: "任务消耗",
|
||||||
|
[EntryTypeEnum.Recharge]: "充值",
|
||||||
|
[EntryTypeEnum.Gift]: "赠送",
|
||||||
|
[EntryTypeEnum.HoldRelease]: "退款",
|
||||||
|
[EntryTypeEnum.HoldSettle]: "结算",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
|
||||||
|
export const EntryTypeColorMap = {
|
||||||
|
[EntryTypeEnum.TaskHold]: "orange",
|
||||||
|
[EntryTypeEnum.Recharge]: "green",
|
||||||
|
[EntryTypeEnum.Gift]: "green",
|
||||||
|
[EntryTypeEnum.HoldRelease]: "blue",
|
||||||
|
[EntryTypeEnum.HoldSettle]: "orange",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type EntryUITypeValue = typeof EntryTypeLabelMap[keyof typeof EntryTypeLabelMap];
|
||||||
|
export type ColorValue = typeof EntryTypeColorMap[keyof typeof EntryTypeColorMap];
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// API 模块统一导出
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export {
|
|
||||||
fetchAnnouncements,
|
|
||||||
type Announcement,
|
|
||||||
type AnnouncementType,
|
|
||||||
type AnnouncementListResponse,
|
|
||||||
} from './announcement';
|
|
||||||
|
|
||||||
export {
|
|
||||||
AuthAPI,
|
|
||||||
type SendSMSRequestBody,
|
|
||||||
type RegisterRequestBody,
|
|
||||||
type RegisterResponseBody,
|
|
||||||
type LoginRequestBody,
|
|
||||||
type LoginResponseBody,
|
|
||||||
type RefreshTokenRequestBody,
|
|
||||||
type RefreshTokenResponseBody,
|
|
||||||
type UserInfoBody,
|
|
||||||
type ChangePasswordRequestBody,
|
|
||||||
type UsePhoneResetPasswordRequestBody,
|
|
||||||
} from './auth';
|
|
||||||
159
src/services/modules/models.ts
Normal file
159
src/services/modules/models.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import {get} from '../request';
|
||||||
|
// 注意:后端 /api/v1/models 的 category 查询参数实际不可用(传参返回空列表),
|
||||||
|
// 因此客户端采用"全量拉取 + 按 category_name 字段分组"的策略。
|
||||||
|
// 后端分类 slug 格式为 img2img / txt2img 等,前端通过 CATEGORY_SLUG_MAP 映射。
|
||||||
|
// ---------- 路由常量 ----------
|
||||||
|
|
||||||
|
const ModelsUrlObj = {
|
||||||
|
modelList: '/api/v1/models',
|
||||||
|
getModelInfo: '/api/v1/models/{model_id}',
|
||||||
|
estimateCost: '/api/v1/models/{model_id}/estimate'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ---------- 类型 ----------
|
||||||
|
|
||||||
|
/** 模型类别常量数组(单一数据源,类型由此推导) */
|
||||||
|
export const MODEL_CATEGORIES = [
|
||||||
|
'image_to_image',
|
||||||
|
'text_to_image',
|
||||||
|
'image_to_video',
|
||||||
|
'text_to_audio',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ModelCategoryStringEnum = typeof MODEL_CATEGORIES[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模型参数 Schema 项
|
||||||
|
*
|
||||||
|
* 后端 /api/v1/models 返回的 param_schema 为对象数组,
|
||||||
|
* 每项描述一个模型入参的元信息(标识、字段映射、规格约束、UI 配置)。
|
||||||
|
*/
|
||||||
|
export interface ParamSchemaItem {
|
||||||
|
id: string;
|
||||||
|
/** 映射到提交参数中的字段名 */
|
||||||
|
map_to: string;
|
||||||
|
/** 规格约束(类型、范围、默认值等) */
|
||||||
|
spec: Record<string, unknown>;
|
||||||
|
/** UI 控件配置 */
|
||||||
|
ui: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模型类别中文映射 */
|
||||||
|
export const MODEL_CATEGORY_LABELS: Record<ModelCategoryStringEnum, string> = {
|
||||||
|
image_to_image: '图生图',
|
||||||
|
text_to_image: '文生图',
|
||||||
|
image_to_video: '图生视频',
|
||||||
|
text_to_audio: '文生音频',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 前端 ModelCategory → 后端分类 slug 映射
|
||||||
|
*
|
||||||
|
* 后端分类 API 使用简写 slug(如 img2img),而非前端的长标识符。
|
||||||
|
* 查询模型列表时需将前端类别转为后端 slug 作为 category 参数。
|
||||||
|
*/
|
||||||
|
export const CATEGORY_SLUG_MAP: Record<ModelCategoryStringEnum, string> = {
|
||||||
|
image_to_image: 'img2img',
|
||||||
|
text_to_image: 'txt2img',
|
||||||
|
image_to_video: 'img2video',
|
||||||
|
text_to_audio: 'txt2audio',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 后端 slug → 前端 ModelCategory 反向映射(由 CATEGORY_SLUG_MAP 自动推导) */
|
||||||
|
const SLUG_TO_CATEGORY: Record<string, ModelCategoryStringEnum> = Object.fromEntries(
|
||||||
|
Object.entries(CATEGORY_SLUG_MAP).map(([cat, slug]) => [slug, cat as ModelCategoryStringEnum]),
|
||||||
|
) as Record<string, ModelCategoryStringEnum>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据任意 category_name 值解析出最佳中文标签
|
||||||
|
*
|
||||||
|
* 解析优先级:
|
||||||
|
* 1. 匹配 MODEL_CATEGORY_LABELS(前端 ModelCategory 字面量)
|
||||||
|
* 2. 匹配 SLUG_TO_CATEGORY 反向映射(后端 slug → 中英文标签)
|
||||||
|
* 3. 兜底返回原始字符串
|
||||||
|
*/
|
||||||
|
export function resolveCategoryLabel(categoryName: string): string {
|
||||||
|
// 优先按 ModelCategory 字面量查找
|
||||||
|
if (categoryName in MODEL_CATEGORY_LABELS) {
|
||||||
|
return MODEL_CATEGORY_LABELS[categoryName as ModelCategoryStringEnum];
|
||||||
|
}
|
||||||
|
// 按后端 slug 反向查找
|
||||||
|
const cat = SLUG_TO_CATEGORY[categoryName];
|
||||||
|
if (cat) {
|
||||||
|
return MODEL_CATEGORY_LABELS[cat];
|
||||||
|
}
|
||||||
|
// 兜底返回原始值
|
||||||
|
return categoryName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将前端 ModelCategory 转为后端 API 用的分类 slug
|
||||||
|
*/
|
||||||
|
export function toCategorySlug(category: ModelCategoryStringEnum): string {
|
||||||
|
return CATEGORY_SLUG_MAP[category];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询可用模型的请求参数
|
||||||
|
*/
|
||||||
|
export interface ModelsListReqeustParams {
|
||||||
|
provider_name?: string;
|
||||||
|
category?: ModelCategoryStringEnum;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询可用模型的响应体
|
||||||
|
*/
|
||||||
|
export interface ModelsListResponseBody {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
provider_name: string;
|
||||||
|
model_type: ModelCategoryStringEnum;
|
||||||
|
category_name: ModelCategoryStringEnum | null;
|
||||||
|
status: string;
|
||||||
|
priority: number;
|
||||||
|
response_mode: string;
|
||||||
|
pricing_config: Record<string, unknown> | null;
|
||||||
|
param_schema: ParamSchemaItem[];
|
||||||
|
quota_config: Record<string, unknown> | null;
|
||||||
|
ui_config: Record<string, unknown>;
|
||||||
|
meta_config: Record<string, unknown>;
|
||||||
|
constraints_config: Record<string, unknown>;
|
||||||
|
expires_at: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模型信息请求参数
|
||||||
|
*/
|
||||||
|
export interface ModelInfoRequestParams {
|
||||||
|
model_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模型详情响应体(与列表项结构一致) */
|
||||||
|
export type ModelInfoResponseBody = ModelsListResponseBody;
|
||||||
|
|
||||||
|
// ---------- API ----------
|
||||||
|
|
||||||
|
|
||||||
|
export class ModelAPI {
|
||||||
|
/**
|
||||||
|
* 获取可用模型列表
|
||||||
|
* GET /api/v1/models
|
||||||
|
*/
|
||||||
|
static fetchModels(params: ModelsListReqeustParams, signal?: AbortSignal): Promise<ModelsListResponseBody[]> {
|
||||||
|
return get<ModelsListResponseBody[]>(ModelsUrlObj.modelList, params as unknown as Record<string, unknown>, { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个模型详情
|
||||||
|
* GET /api/v1/models/:id
|
||||||
|
*/
|
||||||
|
static fetchModelInfo(modelId: string): Promise<ModelInfoResponseBody> {
|
||||||
|
const url = ModelsUrlObj.getModelInfo.replace('{model_id}', modelId);
|
||||||
|
return get<ModelInfoResponseBody>(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user