feat():learning后台管理前端页面初始化

This commit is contained in:
yuj
2025-12-04 17:51:24 +08:00
commit 83a614bd75
97 changed files with 23324 additions and 0 deletions

557
API_PATTERNS.md Normal file
View File

@@ -0,0 +1,557 @@
# API 调用模式和通用实现指南
## 核心 API 架构
### ApiClient 核心类
**位置**`src/services/api.ts`
```typescript
class ApiClient {
private instance: any;
private isRefreshing = false;
private failedQueue: Array<{
resolve: (value: any) => void;
reject: (reason: any) => void;
}> = [];
// 特性:
// 1. 自动附加 Authorization 头
// 2. 401 错误时自动刷新 token
// 3. Token 刷新期间的请求入队
// 4. 自动处理 token 过期和重新登录
}
export const apiClient = new ApiClient();
```
---
## API 服务层设计模式
### 模式 1列表 API用户等级配置
**文件**`src/services/userLevelConfigApi.ts`
```typescript
import { apiClient } from './api';
import type { UserLevelConfig, UserLevelConfigListRequest, UserLevelConfigListResponse } from '../types/userLevelConfig';
// 模式:响应数据提取
// - apiClient 返回完整响应对象
// - 最后一层 API 函数负责提取数据或返回完整响应
export const getUserLevelConfigList = async (
params: UserLevelConfigListRequest
): Promise<UserLevelConfigListResponse> => {
const response = await apiClient.get('/admin/user-level-configs', { params });
return response.data; // 返回响应数据部分
};
export const getAllUserLevelConfigs = async (): Promise<UserLevelConfig[]> => {
const response = await apiClient.get('/admin/user-level-configs/all');
return response.data;
};
```
### 模式 2列表 API系统配置
**文件**`src/services/systemConfigApi.ts`
```typescript
import { apiClient } from './api';
import type { SystemConfig, SystemConfigListRequest, SystemConfigListResponse } from '../types/systemConfig';
// 注意:不同的 API 可能返回不同的数据结构
// 系统配置返回完整响应对象(包含 code, message, data
export const getSystemConfigList = async (
params: SystemConfigListRequest
): Promise<SystemConfigListResponse> => {
const response = await apiClient.get('/admin/system-configs', { params });
return response; // 返回完整响应对象
};
```
### 模式 3CRUD 操作
```typescript
// 创建
export const createUserLevelConfig = async (
data: UserLevelConfigCreateRequest
): Promise<UserLevelConfig> => {
const response = await apiClient.post('/admin/user-level-configs', data);
return response.data;
};
// 读取
export const getUserLevelConfigById = async (id: number): Promise<UserLevelConfig> => {
const response = await apiClient.get(`/admin/user-level-configs/${id}`);
return response.data;
};
// 更新
export const updateUserLevelConfig = async (
id: number,
data: UserLevelConfigUpdateRequest
): Promise<UserLevelConfig> => {
const response = await apiClient.put(`/admin/user-level-configs/${id}`, data);
return response.data;
};
// 删除
export const deleteUserLevelConfig = async (id: number): Promise<void> => {
await apiClient.delete(`/admin/user-level-configs/${id}`);
};
// 特殊操作:状态更新
export const updateUserLevelConfigStatus = async (
id: number,
data: UserLevelConfigStatusRequest
): Promise<void> => {
await apiClient.put(`/admin/user-level-configs/${id}/status`, data);
};
```
---
## 类型定义模式
### 完整类型定义示例
**文件**`src/types/userLevelConfig.ts`
```typescript
// 1. 主体数据类型
export interface UserLevelConfig {
id: number;
level_name: string;
level_code: string;
project_limit: number;
description: string;
sort_order: number;
status: number; // 1=启用, 0=禁用
created_at: string;
updated_at: string;
}
// 2. 请求类型
export interface UserLevelConfigListRequest {
level_name?: string;
status?: number;
page?: number;
size?: number;
}
export interface UserLevelConfigCreateRequest {
level_name: string;
level_code: string;
project_limit: number;
description?: string;
sort_order?: number;
}
export interface UserLevelConfigUpdateRequest {
level_name: string;
project_limit: number;
description?: string;
sort_order?: number;
}
export interface UserLevelConfigStatusRequest {
status: number;
}
// 3. 响应类型
export interface UserLevelConfigListResponse {
data: UserLevelConfig[];
total: number;
page: number;
size: number;
}
```
---
## 页面组件实现模式
### CRUD 页面的标准结构
**文件**`src/pages/UserLevelConfigs.tsx`
```typescript
import React, { useEffect, useState } from 'react';
import { Table, Button, Modal, Form, Input, message } from 'antd';
import type { UserLevelConfig } from '../types/userLevelConfig';
import { getUserLevelConfigList, createUserLevelConfig, updateUserLevelConfig, deleteUserLevelConfig } from '../services/userLevelConfigApi';
export default function UserLevelConfigs() {
// ==================== 状态管理 ====================
const [loading, setLoading] = useState(false);
const [list, setList] = useState<UserLevelConfig[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [size, setSize] = useState(10);
// 编辑弹窗状态
const [editOpen, setEditOpen] = useState(false);
const [editing, setEditing] = useState<UserLevelConfig | null>(null);
const [form] = Form.useForm();
// 创建弹窗状态
const [createOpen, setCreateOpen] = useState(false);
const [createForm] = Form.useForm();
// ==================== 数据加载 ====================
const fetchList = async () => {
setLoading(true);
try {
const res = await getUserLevelConfigList({ page, size });
// 处理响应数据:兼容数组和对象两种格式
if (Array.isArray(res)) {
setList(res);
setTotal(res.length);
} else {
setList(res?.data ?? []);
setTotal(res?.total ?? 0);
}
} catch (e) {
console.error('获取列表失败:', e);
message.error('获取列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchList();
}, [page, size]);
// ==================== 创建操作 ====================
const openCreate = () => {
createForm.resetFields();
setCreateOpen(true);
};
const submitCreate = async () => {
try {
const values = await createForm.validateFields();
await createUserLevelConfig(values);
message.success('创建成功');
setCreateOpen(false);
fetchList();
} catch (error: any) {
message.error(error?.response?.data?.message || '创建失败');
}
};
// ==================== 编辑操作 ====================
const openEdit = (config: UserLevelConfig) => {
setEditing(config);
form.setFieldsValue({
level_name: config.level_name,
project_limit: config.project_limit,
description: config.description,
sort_order: config.sort_order,
});
setEditOpen(true);
};
const submitEdit = async () => {
try {
const values = await form.validateFields();
if (!editing) return;
await updateUserLevelConfig(editing.id, values);
message.success('更新成功');
setEditOpen(false);
fetchList();
} catch (error) {
message.error('更新失败');
}
};
// ==================== 删除操作 ====================
const handleDelete = async (config: UserLevelConfig) => {
try {
await deleteUserLevelConfig(config.id);
message.success('删除成功');
fetchList();
} catch (error) {
message.error('删除失败');
}
};
// ==================== 渲染 ====================
const columns = [
// 列定义...
];
return (
<div>
{/* 主表格 */}
<Table dataSource={list} columns={columns} loading={loading} />
{/* 创建弹窗 */}
<Modal title="新建" open={createOpen} onOk={submitCreate}>
<Form form={createForm} layout="vertical">
{/* 表单字段 */}
</Form>
</Modal>
{/* 编辑弹窗 */}
<Modal title="编辑" open={editOpen} onOk={submitEdit}>
<Form form={form} layout="vertical">
{/* 表单字段 */}
</Form>
</Modal>
</div>
);
}
```
---
## 权限和路由集成
### 添加新页面的完整检查清单
#### 1. 创建类型定义
- [ ] `src/types/xxxConfig.ts` - 定义数据模型和请求/响应类型
#### 2. 创建 API 服务
- [ ] `src/services/xxxConfigApi.ts` - 实现 CRUD API 方法
#### 3. 创建页面组件
- [ ] `src/pages/XxxConfigs.tsx` - 实现 CRUD UI
#### 4. 注册路由
- [ ] `src/App.tsx` - 添加路由定义
```typescript
<Route path="/system/xxx-configs" element={<XxxConfigs />} />
```
#### 5. 添加菜单项
- [ ] `src/components/DynamicMenu.tsx` - 添加菜单项定义
```typescript
{
key: 'system-xxx-configs',
label: 'Xxx 配置',
icon: <SettingOutlined />,
path: '/xxx-configs',
}
```
#### 6. 更新 Layout 子导航
- [ ] `src/components/Layout.tsx` - 添加子导航按钮(如果需要)
```typescript
<button
className={activeSubTab === 'xxx-configs' ? 'active' : ''}
onClick={() => navigate('/system/xxx-configs')}
>
Xxx 配置
</button>
```
---
## 错误处理最佳实践
### API 错误处理
```typescript
// 页面中的错误处理
const fetchList = async () => {
try {
const res = await getUserLevelConfigList({ page, size });
setList(res?.data ?? []);
} catch (error: any) {
// 1. 优先使用 API 返回的错误信息
const errorMsg = error?.response?.data?.message;
// 2. 其次使用通用错误信息
message.error(errorMsg || '获取列表失败');
// 3. 记录详细错误用于调试
console.error('获取列表失败:', error);
}
};
// 创建/更新时的错误处理
const submitCreate = async () => {
try {
const values = await createForm.validateFields();
await createUserLevelConfig(values);
message.success('创建成功');
} catch (error: any) {
// 区分表单验证错误和 API 错误
if (error?.response?.data?.message) {
message.error(error.response.data.message);
} else {
message.error('创建失败');
}
}
};
```
---
## 通用 CRUD 表格列定义
### 标准列定义模式
```typescript
const columns = [
// 基本字段列
{
title: '名称',
dataIndex: 'level_name',
key: 'level_name',
},
{
title: '代码',
dataIndex: 'level_code',
key: 'level_code',
},
// 数值列(带格式化)
{
title: '数值',
dataIndex: 'value',
key: 'value',
render: (value: number) => (value === 0 ? '不限' : value),
},
// 状态列(带标签)
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (value: number) =>
value === 1 ? <Tag color="green">启用</Tag> : <Tag color="red">禁用</Tag>,
},
// 操作列(最后)
{
title: '操作',
key: 'action',
render: (_: any, record: T) => (
<Space>
<Button type="link" icon={<EditOutlined />} onClick={() => openEdit(record)}>
编辑
</Button>
<Button type="link" icon={<DeleteOutlined />} danger onClick={() => handleDelete(record)}>
删除
</Button>
</Space>
),
},
];
```
---
## 表单字段最佳实践
### 创建表单示例
```typescript
<Form form={createForm} layout="vertical">
{/* 必填字段 */}
<Form.Item
name="level_name"
label="等级名称"
rules={[{ required: true, message: '请输入等级名称' }]}
>
<Input placeholder="请输入等级名称" />
</Form.Item>
{/* 代码字段(创建时必填,编辑时只读) */}
<Form.Item
name="level_code"
label="等级代码"
rules={[{ required: true, message: '请输入等级代码' }]}
>
<Input placeholder="请输入等级代码" />
</Form.Item>
{/* 数值字段 */}
<Form.Item
name="project_limit"
label="项目数限制0表示不限"
rules={[{ required: true, message: '请输入项目数限制' }]}
initialValue={0}
>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
{/* 可选文本字段 */}
<Form.Item name="description" label="描述">
<Input.TextArea rows={3} placeholder="请输入描述" />
</Form.Item>
{/* 排序字段 */}
<Form.Item name="sort_order" label="排序顺序" initialValue={0}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Form>
```
---
## 分页实现
### 标准分页逻辑
```typescript
// 状态
const [page, setPage] = useState(1);
const [size, setSize] = useState(10);
const [total, setTotal] = useState(0);
// 依赖项变化时重新加载
useEffect(() => {
fetchList();
}, [page, size]);
// 表格分页配置
<Table
dataSource={list}
columns={columns}
pagination={{
current: page,
pageSize: size,
total: total,
onChange: (p, s) => {
setPage(p);
setSize(s);
},
}}
/>
```
---
## 总结
### API 调用流程
```
用户操作
页面组件(使用 API 函数)
API 服务函数(处理参数和响应)
ApiClient自动处理 token 和拦截器)
AxiosHTTP 请求)
后端 API
```
### 关键要点
1. **分层设计**:每一层职责清晰
2. **类型安全**:完整的 TypeScript 类型支持
3. **错误处理**:统一的错误处理机制
4. **权限集成**:无缝的权限管理
5. **状态管理**:简化的本地状态管理
6. **自动化**:自动 Token 管理和刷新

306
DOCUMENTATION_INDEX.md Normal file
View File

@@ -0,0 +1,306 @@
# Goalfymax 管理后台 - 文档索引
## 文档导航地图
本项目包含全面的文档,帮助快速理解和开发。以下是各文档的用途和推荐阅读顺序。
---
## 文档列表
### 1. EXPLORATION_SUMMARY.md (项目探索总结)
**大小**: 303 行 | **阅读时间**: 8-10 分钟
**用途**: 快速理解项目整体架构和现状
**适合**: 初次接触项目的人
**包含内容**:
- 项目总体评估
- 关键发现双层路由、API 抽象、CRUD 模式、类型支持)
- 已实现的所有功能列表
- 代码质量评估(优点和改进空间)
- 文件统计和最近开发活动
- 技术栈详解
- 学习路径建议
- 改进建议(短期、中期、长期)
**开始阅读**: 首先阅读这个文档了解项目全貌
---
### 2. PROJECT_ARCHITECTURE.md (项目架构详解)
**大小**: 480 行 | **阅读时间**: 15-20 分钟
**用途**: 深入理解项目的架构设计
**适合**: 想要理解系统设计的开发者
**包含内容**:
- 完整的目录结构说明
- 核心架构设计路由、菜单、API、权限
- 关键文件详解用户等级配置、系统配置、Layout、认证
- API 端点规范
- 权限系统详解
- 页面模块划分
- 通用 CRUD 页面模式
- 代码架构建议
- 开发指南(如何添加新功能)
**使用场景**:
- 理解整体系统架构
- 决定功能应该放在哪里
- 理解权限管理机制
---
### 3. API_PATTERNS.md (API 调用模式)
**大小**: 557 行 | **阅读时间**: 15-20 分钟
**用途**: 学习如何正确调用 API 和实现功能
**适合**: 需要编写 API 服务和页面的开发者
**包含内容**:
- ApiClient 核心类说明
- 3 种 API 服务设计模式列表、CRUD、特殊操作
- 完整的类型定义模板
- CRUD 页面组件实现模式
- 权限和路由集成
- 添加新页面的完整检查清单
- 错误处理最佳实践
- 通用 CRUD 表格列定义
- 表单字段最佳实践
- 分页实现
**使用场景**:
- 添加新的管理功能
- 实现 API 服务
- 创建页面组件
- 学习最佳实践
---
### 4. QUICK_REFERENCE.md (快速参考)
**大小**: 392 行 | **阅读时间**: 5-10 分钟 (查阅用)
**用途**: 快速查找常见信息
**适合**: 需要快速找到某个特定信息的开发者
**包含内容**:
- 项目基本信息表
- 目录速查表
- 关键概念速览
- 常见任务速查(添加新功能的步骤)
- 重要文件速查表
- API 端点速查
- 代码片段库(复制即用)
- 常见错误和解决方案
- 性能优化建议
- 开发工作流标准步骤
- 有用的命令
- 常用类名和常量
**使用场景**:
- 快速查找某个文件的作用
- 复制代码模板
- 查询常见错误的解决方案
- 回忆某个 API 端点
---
### 5. QUICK_START.md (快速开始)
**大小**: 566 行 | **阅读时间**: 10-15 分钟
**用途**: 快速启动项目和理解基础
**适合**: 刚开始使用项目的开发者
**包含内容**:
- 环境要求
- 项目初始化
- 开发服务器启动
- 构建和部署
- 项目结构概览
- 核心技术说明
- 认证和权限系统
- 路由说明
- 常用 npm 命令
---
### 6. FRONTEND_GUIDE.md (前端开发指南)
**大小**: 1527 行 | **阅读时间**: 25-30 分钟
**用途**: 全面的前端开发指南
**适合**: 需要全面理解前端实现的开发者
**包含内容**:
- 项目概述和技术栈
- 完整的项目结构说明
- 核心架构和设计模式
- 关键特性详解
- 开发最佳实践
- 常见模式和示例
- 故障排除指南
- 性能优化指南
---
## 推荐阅读路径
### 路径 1: 快速上手2-3 小时)
适合需要快速理解项目基础的新人
```
1. EXPLORATION_SUMMARY.md (8 分钟)
2. QUICK_START.md (12 分钟)
3. QUICK_REFERENCE.md (10 分钟,仅浏览目录)
4. 查看具体代码文件
- src/App.tsx
- src/services/userLevelConfigApi.ts
- src/pages/UserLevelConfigs.tsx
```
### 路径 2: 深度理解4-5 小时)
适合需要全面理解架构的开发者
```
1. EXPLORATION_SUMMARY.md (8 分钟)
2. PROJECT_ARCHITECTURE.md (20 分钟)
3. QUICK_REFERENCE.md (10 分钟)
4. API_PATTERNS.md (20 分钟)
5. FRONTEND_GUIDE.md (30 分钟)
6. 研究关键代码文件
- src/services/api.ts (ApiClient 类)
- src/components/DynamicMenu.tsx (权限过滤)
- src/components/Layout.tsx (布局结构)
```
### 路径 3: 开发新功能1-2 小时)
适合需要立即开始开发的人
```
1. QUICK_REFERENCE.md - 常见任务速查部分 (5 分钟)
2. API_PATTERNS.md - 相关部分 (10 分钟)
3. 复制模板代码,开始开发
4. 遇到问题时查阅相关文档
```
---
## 按主题查找文档
### 我需要了解...
| 主题 | 首选文档 | 相关部分 |
|------|---------|---------|
| 项目整体架构 | PROJECT_ARCHITECTURE.md | "核心架构设计" |
| 如何添加新功能 | API_PATTERNS.md | "权限和路由集成" |
| API 调用方式 | API_PATTERNS.md | "API 服务层设计模式" |
| 权限和菜单 | PROJECT_ARCHITECTURE.md | "权限系统" |
| 如何启动项目 | QUICK_START.md | "项目初始化" |
| 某个文件的作用 | QUICK_REFERENCE.md | "重要文件速查" |
| 某个 API 端点 | QUICK_REFERENCE.md | "API 端点速查" |
| 常见错误解决 | QUICK_REFERENCE.md | "常见错误和解决方案" |
| 代码最佳实践 | API_PATTERNS.md | "最佳实践" 部分 |
| 项目文件统计 | EXPLORATION_SUMMARY.md | "文件统计" |
| 技术栈详情 | EXPLORATION_SUMMARY.md | "技术栈详解" |
---
## 文档使用技巧
### 快速导航
1. 使用文档内的目录跳转(通常在开头)
2. 使用编辑器的搜索功能 (Ctrl+F / Cmd+F) 查找特定内容
3. 从 QUICK_REFERENCE.md 的表格快速定位信息
### 复制代码
1. 在 API_PATTERNS.md 中找到相应的代码片段
2. 直接复制到你的项目中
3. 根据需要修改变量名和 API 端点
### 学习流程
1. 首先阅读相关概念的说明
2. 查看具体的代码例子
3. 理解代码背后的原理
4. 在自己的代码中应用
---
## 文档的版本和更新
- **生成时间**: 2025-10-28
- **项目状态**: 活跃开发中
- **最后更新**: 正在添加系统配置管理功能
### 何时更新文档
- 添加新的主要功能时
- 更改了架构或模式时
- 添加新的最佳实践或约定时
---
## 相关资源链接
### 项目内文档
- [项目根目录](.) - 所有文档都在这里
### 外部技术文档
- [Ant Design 官方文档](https://ant.design/) - UI 组件库
- [React Router 官方文档](https://reactrouter.com/) - 路由管理
- [Jotai 官方文档](https://jotai.org/) - 状态管理
- [TypeScript 官方文档](https://www.typescriptlang.org/) - 类型系统
- [Axios 官方文档](https://axios-http.com/) - HTTP 客户端
- [Vite 官方文档](https://vitejs.dev/) - 构建工具
---
## 获得帮助
### 如果你...
**不知道从哪里开始**
→ 从 EXPLORATION_SUMMARY.md 开始,了解项目整体情况
**需要快速实现一个功能**
→ 查看 API_PATTERNS.md 的"权限和路由集成"部分
**遇到了 bug 或问题**
→ 查看 QUICK_REFERENCE.md 的"常见错误和解决方案"部分
**需要详细了解某个概念**
→ 查看 PROJECT_ARCHITECTURE.md 的相关章节
**需要查找某个 API 端点**
→ 查看 QUICK_REFERENCE.md 的"API 端点速查"部分
**想学习最佳实践**
→ 阅读 API_PATTERNS.md 和 FRONTEND_GUIDE.md
---
## 文档质量反馈
这些文档的目标是帮助你快速上手并高效开发。如果你发现:
- 某个部分不清楚
- 缺少某个重要概念的解释
- 代码示例有错误
- 可以改进的地方
欢迎提出改进建议。
---
## 总结
本项目提供的文档覆盖了从快速上手到深度理解的全部内容:
**EXPLORATION_SUMMARY.md** - 项目全貌
**PROJECT_ARCHITECTURE.md** - 架构深度解析
**API_PATTERNS.md** - 开发模式和最佳实践
**QUICK_REFERENCE.md** - 快速查阅工具
**QUICK_START.md** - 快速开始指南
**FRONTEND_GUIDE.md** - 全面的前端指南
根据你的需求选择合适的文档,祝开发顺利!

33
Dockerfile Normal file
View File

@@ -0,0 +1,33 @@
# 多阶段构建 Dockerfile
# 阶段1构建阶段
FROM node:20-alpine AS builder
# 设置工作目录
WORKDIR /app
# 复制 package.json 和 package-lock.json
COPY package*.json ./
# 安装依赖
RUN npm ci
# 复制项目文件
COPY . .
# 构建生产版本
RUN npm run build
# 阶段2运行阶段
FROM nginx:alpine
# 复制构建产物到 nginx
COPY --from=builder /app/dist /usr/share/nginx/html
# 复制 nginx 配置
COPY nginx.conf /etc/nginx/conf.d/default.conf
# 暴露端口
EXPOSE 80
# 启动 nginx
CMD ["nginx", "-g", "daemon off;"]

303
EXPLORATION_SUMMARY.md Normal file
View File

@@ -0,0 +1,303 @@
# Goalfymax 管理后台 - 项目探索总结
## 项目总体评估
这是一个**架构清晰、类型安全的 React 管理后台应用**,采用现代化技术栈和最佳实践。
---
## 关键发现
### 1. 双层路由系统
项目实现了巧妙的权限管理策略:
- **App.tsx**:定义所有可用路由(静态)
- **DynamicRoutes.tsx**:根据用户权限动态加载路由(备用)
- **DynamicMenu.tsx**:权限感知的菜单项过滤
这使得权限管理贯穿整个应用的路由、菜单和 UI 层级。
### 2. 完整的 API 抽象层
```
3 层架构:
┌─────────────────┐
│ UI 组件 │ (页面)
├─────────────────┤
│ API 服务层 │ (xxxApi.ts)
├─────────────────┤
│ ApiClient │ (api.ts 中的类)
├─────────────────┤
│ Axios │ + 拦截器
└─────────────────┘
```
特点:
- 自动 Token 管理(请求自动附加 Authorization 头)
- 自动 Token 刷新401 错误时)
- 请求队列机制(刷新期间的请求不会重复)
### 3. 统一的 CRUD 模式
UserLevelConfigs 和 SystemConfigs 两个新增功能遵循相同的模式:
- 3 层结构(页面 + 服务 + 类型)
- 列表 + 创建 + 编辑 + 删除 + 状态切换
- Modal 弹窗实现
- 表格形式展现
这可以作为未来添加新管理功能的模板。
### 4. 完整的类型支持
每个功能都配套完整的 TypeScript 类型:
- 实体类型(如 UserLevelConfig
- 请求类型(如 UserLevelConfigListRequest
- 响应类型(如 UserLevelConfigListResponse
---
## 已实现的功能
### 核心功能
- [x] 单点登录SSO集成
- [x] 基于角色的访问控制RBAC
- [x] 动态菜单系统
- [x] 权限感知路由
- [x] 自动 Token 刷新
### 管理功能
- [x] 仪表盘Dashboard
- [x] 系统用户管理
- [x] 角色管理
- [x] 配额规则管理
- [x] 用户项目配额管理
- [x] GoalfyMax 用户管理
- [x] **用户等级配置**(新)
- [x] **系统配置**(新)
- [x] 用户反馈管理
- [x] 消息推送管理
- [x] 供应商模型价格管理
### 监控和分析
- [x] Token 历史记录
- [x] Token 分析
- [x] 系统健康检查
---
## 代码质量评估
### 优点
| 方面 | 评价 |
|------|------|
| **架构** | 清晰的分层结构,职责分离 |
| **类型安全** | 完整的 TypeScript 支持 |
| **权限管理** | 多层次的权限检查机制 |
| **可维护性** | 统一的 CRUD 模式,便于维护 |
| **可扩展性** | 易于添加新的管理功能 |
| **错误处理** | 统一的 API 错误处理 |
| **Token 管理** | 自动化的 Token 刷新机制 |
### 需要改进的地方
| 问题 | 严重程度 | 建议 |
|------|---------|------|
| 页面文件过大 | 中 | 拆分为子组件(表格、表单等) |
| CRUD 代码重复 | 中 | 提取通用 Hook 或高阶组件 |
| API 响应处理不一致 | 低 | 统一响应提取逻辑 |
| 加载状态管理 | 低 | 考虑使用更高级的状态管理 |
---
## 文件统计
### 代码统计
- **总 TS/TSX 文件数**48 个
- **总代码行数**:约 7000+ 行
- **核心基础设施**~1000 行
- api.ts: 587 行
- Layout.tsx: 298 行
- DynamicMenu.tsx: 228 行
### 功能模块大小
| 功能 | 文件数 | 行数 |
|------|--------|------|
| 用户等级配置 | 3 | 399 |
| 系统配置 | 3 | 401 |
| 认证管理 | 1+ | 500+ |
| API 客户端 | 1 | 587 |
| 布局和菜单 | 2 | 526 |
---
## 最近的开发活动
### Git 提交历史
```
6cfd343 Merge branch 'feature/token-price' # 合并 token 价格功能
09d50ea 创建用户时输入密码
c4cefd1 创建goalfymax用户
021bbc4 推送消息增加标题
516fd5d token计费配置
```
### 当前改动
```
Modified (在 Git 中):
- src/App.css
- src/App.tsx
- src/components/AuthGuard.tsx
- src/components/DynamicMenu.tsx
- src/components/Layout.tsx
- src/pages/GoalfyMaxUsers.tsx
- src/routes/DynamicRoutes.tsx
- src/services/api.ts
Added (未追踪):
- src/components/DynamicMenu.css
- src/pages/SystemConfigs.tsx
- src/pages/UserLevelConfigs.tsx
- src/services/systemConfigApi.ts
- src/services/userLevelConfigApi.ts
- src/types/systemConfig.ts
- src/types/userLevelConfig.ts
```
---
## 核心文件快速查阅
### 必看文件(了解项目架构)
1. **src/App.tsx** - 应用入口和路由定义
2. **src/services/api.ts** - API 客户端核心
3. **src/components/Layout.tsx** - 主布局结构
4. **src/components/DynamicMenu.tsx** - 权限感知菜单
### 参考模板(创建新功能)
1. **src/types/userLevelConfig.ts** - 类型定义模板
2. **src/services/userLevelConfigApi.ts** - API 服务模板
3. **src/pages/UserLevelConfigs.tsx** - 页面组件模板
### 权限和认证
1. **src/hooks/useAuth.ts** - 认证 Hook
2. **src/hooks/usePagePermissions.ts** - 页面权限 Hook
3. **src/atoms/auth.ts** - 认证状态原子
---
## 技术栈详解
### 前端框架
- **React 18.3.1** - UI 框架
- **TypeScript 5.9.3** - 类型系统
- **Vite 5.4.20** - 构建工具
### UI 和交互
- **Ant Design 5.27.4** - UI 组件库
- **React Router 7.9.4** - 路由管理
- **Axios 1.12.2** - HTTP 客户端
### 状态管理和其他
- **Jotai 2.15.0** - 原子状态管理
- **Dayjs 1.11.18** - 日期处理
### 开发工具
- **Vite 插件** (@vitejs/plugin-react)
- **ESLint** - 代码检查
- **TypeScript ESLint** - 类型检查
---
## 学习路径建议
### 初级(快速上手)
1. 阅读 QUICK_REFERENCE.md
2. 理解 App.tsx 的路由结构
3. 浏览 UserLevelConfigs.tsx 了解页面模式
### 中级(理解架构)
1. 阅读 PROJECT_ARCHITECTURE.md
2. 深入研究 ApiClient 的 token 刷新机制
3. 分析 DynamicMenu 的权限过滤逻辑
### 高级(扩展能力)
1. 阅读 API_PATTERNS.md 详细模式
2. 研究如何提取通用 CRUD Hook
3. 优化性能(拆分大文件、缓存策略等)
---
## 立即可做的改进
### 短期1-2 周)
1. **统一 API 响应处理** - 让所有 API 服务使用一致的响应提取方式
2. **提取通用 CRUD Hook** - 减少页面重复代码
3. **完善错误处理** - 添加更详细的错误信息和日志
### 中期1-2 月)
1. **拆分大文件** - UserLevelConfigs 和 SystemConfigs 拆分为子组件
2. **添加表单验证** - 增强表单验证和用户反馈
3. **性能优化** - 添加列表虚拟化、缓存等
### 长期3-6 月)
1. **重构为 Hooks** - 将更多逻辑移到自定义 Hooks
2. **添加单元测试** - 提高代码覆盖率
3. **国际化** - 支持多语言
---
## 资源导航
### 项目内文档
- **PROJECT_ARCHITECTURE.md** - 详细的项目结构和设计说明
- **API_PATTERNS.md** - API 调用模式和实现指南
- **QUICK_REFERENCE.md** - 快速参考和代码片段
### 外部资源
- [Ant Design 官方文档](https://ant.design/)
- [React Router 官方文档](https://reactrouter.com/)
- [Jotai 官方文档](https://jotai.org/)
- [TypeScript 官方文档](https://www.typescriptlang.org/)
- [Axios 官方文档](https://axios-http.com/)
---
## 常见问题解答
### Q: 如何添加新的管理功能?
A: 参考 UserLevelConfigs 或 SystemConfigs 的实现,按照以下步骤:
1. 创建类型定义 (src/types/)
2. 创建 API 服务 (src/services/)
3. 创建页面组件 (src/pages/)
4. 注册路由和菜单项
### Q: 如何处理权限检查?
A: 使用 `usePagePermissions()` Hook 获取用户权限DynamicMenu 会自动过滤菜单项。
### Q: Token 过期了怎么办?
A: ApiClient 会自动处理 401 错误,尝试刷新 token如果失败会触发重新登录。
### Q: 页面加载很慢怎么办?
A: 检查是否有大量 API 请求,考虑使用缓存、分页或虚拟化。
---
## 总结
这个项目展示了一个**成熟的 React 管理后台的标准实践**
**架构清晰** - 分层明确,易于理解和维护
**类型安全** - 完整的 TypeScript 支持
**权限管理** - 多层次的权限检查
**可扩展** - 统一的 CRUD 模式,易于添加功能
**自动化** - Token 刷新、权限检查等自动化处理
适合作为企业级管理后台的参考实现和模板。
---
**文档生成时间**2025-10-28
**项目状态**:活跃开发中
**最后更新**:正在添加系统配置管理功能

113
EXPLORATION_SUMMARY.txt Normal file
View File

@@ -0,0 +1,113 @@
========================================
前端项目探索完成总结
========================================
项目路径: /Users/youziba/goalfyagent/goalfymax-admin-web
技术栈:
- React 18.3.1
- TypeScript
- Vite (构建工具)
- React Router 7.9.4 (路由)
- Ant Design 5.27.4 (UI组件库)
- Jotai 2.15.0 (状态管理)
- Axios 1.12.2 (HTTP客户端)
========================================
关键发现
========================================
1. 项目框架: React + TypeScript + Vite
- 标准的 React 函数组件范式
- 完整的 TypeScript 类型支持
- 使用 Jotai 管理全局认证状态
2. 路由设置:
- 文件: src/App.tsx
- 使用 React Router v7 的 Routes/Route
- 支持嵌套路由
- 所有路由在一个文件中集中管理
3. 菜单配置:
- 文件: src/components/Layout.tsx
- 静态硬编码方式
- 支持主菜单和子菜单
- 菜单项通过权限进行条件渲染
4. API 调用:
- 文件: src/services/api.ts
- 使用 ApiClient 类包装 Axios
- 自动处理 Token 认证和刷新 (401 处理)
- 支持请求/响应拦截
- 各功能模块创建专门的 API 服务文件
5. 表单和表格:
- 使用 Ant Design Form 进行表单管理
- 使用 Ant Design Table 展示列表数据
- Modal 弹窗用于创建和编辑
- 完整的分页支持
6. 权限管理:
- Hook: usePagePermissions
- 从认证状态中获取用户权限页面列表
- 菜单和路由通过权限检查进行显示
7. 参考实现:
- UserLevelConfigs.tsx (用户等级配置) - 283 行
- 包含: 列表、创建、编辑、删除、状态切换
========================================
已生成的文档
========================================
1. FRONTEND_GUIDE.md (完整指南, 1527 行)
- 详细的项目结构介绍
- 每个功能的完整代码示例
- 权限管理详解
- 创建新页面的 9 步完整流程
位置: /Users/youziba/goalfyagent/goalfymax-admin-web/FRONTEND_GUIDE.md
2. QUICK_START.md (快速开始指南)
- 简化的 5 步创建新页面流程
- 核心文件速查表
- 常用开发模式代码示例
- 快速参考信息
位置: /Users/youziba/goalfyagent/goalfymax-admin-web/QUICK_START.md
========================================
创建新的配置管理页面需要 5 个步骤
========================================
1. src/types/xxx.ts - TypeScript 接口定义
2. src/services/xxxApi.ts - API 服务函数
3. src/pages/Xxx.tsx - React 页面组件
4. src/App.tsx - 添加路由
5. src/components/Layout.tsx - 添加菜单项
具体代码示例请查看 QUICK_START.md
========================================
核心文件位置速查
========================================
路由配置: src/App.tsx
菜单配置: src/components/Layout.tsx
API 客户端: src/services/api.ts
权限检查: src/hooks/usePagePermissions.ts
认证管理: src/hooks/useAuth.ts
全局状态: src/atoms/auth.ts
页面列表: src/pages/
类型定义: src/types/
========================================
可以立即开始的工作
========================================
1. 根据 QUICK_START.md 创建通用配置管理页面
2. 参考用户等级配置页面的实现方式
3. 遵循现有的代码风格和架构模式
4. 充分利用 Ant Design 组件库
========================================

1527
FRONTEND_GUIDE.md Normal file

File diff suppressed because it is too large Load Diff

480
PROJECT_ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,480 @@
# Goalfymax 管理后台项目架构概览
## 项目概述
这是一个基于 **React 18** + **Vite** + **Ant Design 5** + **React Router 7** 的现代管理后台应用。
- **技术栈**React 18.3.1 + TypeScript + Vite 5.4.20 + Ant Design 5.27.4
- **状态管理**Jotai原子状态管理
- **HTTP 客户端**Axios 1.12.2
- **日期处理**Dayjs 1.11.18
- **总文件数**48 个 TypeScript/TSX 文件
- **代码行数**~7000+ 行代码
---
## 目录结构
```
src/
├── App.tsx # 主应用入口,定义静态路由
├── main.tsx # Vite 应用入口点
├── index.css # 全局样式
├── App.css # 应用样式
├── atoms/ # 状态管理Jotai 原子状态)
│ └── auth.ts # 认证状态原子
├── components/ # 可复用 UI 组件
│ ├── Layout.tsx # 主布局组件(侧边栏+主区域)
│ ├── DynamicMenu.tsx # 动态菜单组件(权限感知)
│ ├── DynamicMenu.css # 菜单样式
│ ├── AuthGuard.tsx # 认证守卫组件
│ ├── PermissionGuard.tsx # 权限守卫组件
│ ├── PagePermissionGuard.tsx
│ ├── QuotaCharts.tsx # 配额图表组件
│ ├── QuotaRulesForm.tsx # 配额规则表单
│ ├── QuotaRulesTable.tsx # 配额规则表格
│ ├── QuotaHistoryTable.tsx # 配额历史表格
│ ├── QuotaFilters.tsx # 配额过滤器
│ ├── QuotaStats.tsx # 配额统计
│ └── UserProjectQuotaPage.tsx
├── hooks/ # 自定义 React Hooks
│ ├── useAuth.ts # 认证 Hook登录、登出、Token 管理)
│ ├── usePagePermissions.ts # 页面权限 Hook
│ └── usePermissions.ts # 操作权限 Hook
├── pages/ # 页面组件
│ ├── Dashboard.tsx # 仪表盘
│ ├── Overview.tsx # 总览页
│ ├── Operations.tsx # 运营页
│ ├── Monitoring.tsx # 监控页
│ ├── Finance.tsx # 财务页
│ ├── SystemHealth.tsx # 系统健康
│ ├── QuotaRules.tsx # 配额规则管理
│ ├── UserProjectQuota.tsx # 用户项目配额管理
│ ├── UserManagement.tsx # 系统用户管理
│ ├── RoleManagement.tsx # 角色管理
│ ├── GoalfyMaxUsers.tsx # GoalfyMax 用户管理
│ ├── UserLevelConfigs.tsx # 用户等级配置管理
│ ├── SystemConfigs.tsx # 系统配置管理
│ ├── UserFeedback.tsx # 用户反馈管理
│ ├── MessagePush.tsx # 消息推送管理
│ ├── TokenHistory.tsx # Token 历史
│ ├── TokenAnalytics.tsx # Token 分析
│ ├── VendorModelPricing.tsx# 供应商模型价格管理
│ └── ...
├── routes/ # 路由配置
│ └── DynamicRoutes.tsx # 动态路由配置(基于权限的动态路由)
├── services/ # API 服务层
│ ├── api.ts # 核心 API 客户端ApiClient+ API 服务导出
│ ├── userLevelConfigApi.ts # 用户等级配置 API
│ ├── systemConfigApi.ts # 系统配置 API
│ ├── userApi.ts # 用户 API
│ ├── roleApi.ts # 角色 API
│ └── ...
├── types/ # TypeScript 类型定义
│ ├── userLevelConfig.ts # 用户等级配置类型
│ ├── systemConfig.ts # 系统配置类型
│ ├── quota.ts # 配额类型
│ ├── userProjectQuota.ts # 用户项目配额类型
│ └── ...
├── utils/ # 工具函数
│ └── storageMigration.ts # 存储迁移工具
└── assets/ # 静态资源
└── react.svg
```
---
## 核心架构设计
### 1. 路由设计
**双层路由系统**
#### App.tsx 中的静态路由
- 定义所有可用的路由路径
- 组织模块化的路由结构dashboard、operations、monitoring、system 等)
```typescript
// 示例:系统管理路由
<Route path="/system" element={<Navigate to="/system/quota-rules" replace />} />
<Route path="/system/quota-rules" element={<QuotaRulesPage />} />
<Route path="/system/user-management" element={<UserManagement />} />
<Route path="/system/user-level-configs" element={<UserLevelConfigs />} />
<Route path="/system/system-configs" element={<SystemConfigs />} />
```
#### DynamicRoutes.tsx 中的权限感知路由
- 根据用户权限动态显示/隐藏路由
- 使用 `usePagePermissions()` hook 获取用户权限
- 只挂载有权限访问的路由
### 2. 菜单系统
**DynamicMenu 组件**特点:
- 权限感知的菜单项过滤
- 支持一级和二级菜单
- 自动同步当前路由位置
- 详细的权限检查日志
核心逻辑:
```typescript
const filterAccessibleMenus = (items: MenuItem[]): MenuItem[] => {
// 1. 仪表盘始终显示
// 2. 父级菜单权限检查(如 /system、/operations
// 3. 单个菜单项权限检查
// 4. 返回过滤后的菜单项
}
```
### 3. API 调用模式
**分层结构**
```
UI 组件
Custom Hook (useXxx)
API Service (xxxApi.ts)
ApiClient (api.ts)
Axios 实例 + 拦截器
后端 API
```
#### ApiClient 类特性
- **自动 Token 管理**:请求自动附加 Authorization 头
- **Token 刷新机制**401 错误时自动刷新 token
- **请求队列**:刷新 token 期间的请求入队等待
- **错误处理**:统一处理认证失败和其他错误
#### API 服务示例
**系统配置 API**systemConfigApi.ts
```typescript
export const getSystemConfigList = async (params) => {
const response = await apiClient.get('/admin/system-configs', { params });
return response; // 返回完整响应对象
}
export const createSystemConfig = async (data) => {
const response = await apiClient.post('/admin/system-configs', data);
return response.data;
}
```
**用户等级配置 API**userLevelConfigApi.ts
```typescript
export const getUserLevelConfigList = async (params) => {
const response = await apiClient.get('/admin/user-level-configs', { params });
return response.data;
}
```
---
## 关键文件详解
### 1. 用户等级配置UserLevelConfigs
**文件清单**
- `/src/pages/UserLevelConfigs.tsx` - 页面组件292 行)
- `/src/services/userLevelConfigApi.ts` - API 服务60 行)
- `/src/types/userLevelConfig.ts` - 类型定义47 行)
**功能**
- 列表查询(分页)
- 创建新等级配置
- 编辑等级配置
- 删除等级配置
- 启用/禁用等级配置
**数据模型**
```typescript
interface UserLevelConfig {
id: number;
level_name: string; // 等级名称
level_code: string; // 等级代码
project_limit: number; // 项目数限制
description: string; // 描述
sort_order: number; // 排序顺序
status: number; // 1=启用, 0=禁用
created_at: string;
updated_at: string;
}
```
### 2. 系统配置SystemConfigs
**文件清单**
- `/src/pages/SystemConfigs.tsx` - 页面组件284 行)
- `/src/services/systemConfigApi.ts` - API 服务66 行)
- `/src/types/systemConfig.ts` - 类型定义51 行)
**功能**
- 列表查询(分页)
- 创建新系统配置
- 编辑系统配置
- 删除系统配置
- 启用/禁用系统配置
**数据模型**
```typescript
interface SystemConfig {
id: number;
key: string; // 唯一标识
name: string; // 配置名称
value: string; // 配置值
type: string; // 配置类型string, int, bool, json
desc: string; // 配置描述
status: number; // 1=启用, 0=禁用
createdAt: string;
updatedAt: string;
}
```
### 3. Layout 组件(布局)
**结构**
```
┌─────────────────────────────┐
│ Header顶部栏
├─────────┬───────────────────┤
│ │ │
│ Sidebar │ Main Content │
│(Menus) │ (页面内容) │
│ │ │
├─────────┼───────────────────┤
│ Footer页脚
└─────────────────────────────┘
```
**特性**
- 侧边栏可折叠/展开(按钮)
- 动态菜单DynamicMenu
- 子导航标签(针对多级菜单页面)
- 用户认证状态显示 + 登出按钮
### 4. 认证流程useAuth Hook
**功能**
- SSO 单点登录
- Token 管理access_token + refresh_token
- 自动 Token 刷新
- Token 过期检测和重新登录
---
## API 端点规范
### 用户等级配置
```
GET /admin/user-level-configs # 列表(分页)
GET /admin/user-level-configs/all # 全部(不分页)
GET /admin/user-level-configs/:id # 详情
POST /admin/user-level-configs # 创建
PUT /admin/user-level-configs/:id # 更新
PUT /admin/user-level-configs/:id/status # 更新状态
DELETE /admin/user-level-configs/:id # 删除
```
### 系统配置
```
GET /admin/system-configs # 列表(分页)
GET /admin/system-configs/all # 全部(不分页)
GET /admin/system-configs/:id # 详情
GET /admin/system-configs/key/:key # 按 Key 查询
POST /admin/system-configs # 创建
PUT /admin/system-configs/:id # 更新
PUT /admin/system-configs/:id/status # 更新状态
DELETE /admin/system-configs/:id # 删除
```
### 其他 API
```
GET /admin/goalfymax-users # GoalfyMax 用户列表
GET /admin/user-feedback # 用户反馈
GET /admin/message-push/logs # 推送记录
GET /admin/vendor-model-pricing # 供应商模型价格
POST /admin/message-push/send # 发送消息
```
---
## 权限系统
### 权限检查层级
1. **页面级权限**usePagePermissions
- 检查用户是否可以访问某个页面
- 返回可访问的页面列表
2. **操作级权限**usePermissions
- 检查用户是否可以执行某个操作
- 如:创建、编辑、删除、启用/禁用
3. **菜单级权限**DynamicMenu
- 根据页面权限动态显示/隐藏菜单项
- 支持嵌套菜单的权限管理
### 权限存储
- 通过 localStorage 缓存
- 从后端 SSO API 获取用户权限信息
- Jotai 原子状态管理
---
## 页面模块划分
### 系统管理(/system
- 配额/套餐规则
- 用户项目配额
- 系统用户管理
- 角色管理
- GoalfyMax 用户管理
- **用户等级配置**
- **系统配置**
### 运营(/operations
- 用户反馈管理
- 消息推送管理
- 供应商模型价格管理
### 监控(/monitoring
- Token 历史
- Token 分析
- 系统健康
### 财务(/finance
- 财务数据统计
---
## 通用 CRUD 页面模式
### UserLevelConfigs 和 SystemConfigs 采用的设计模式
**页面结构**
1. 数据列表(分页表格)
2. 创建弹窗
3. 编辑弹窗
4. 操作列(编辑、启用/禁用、删除)
**状态管理**
```typescript
const [list, setList] = useState<T[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [size, setSize] = useState(10);
const [editing, setEditing] = useState<T | null>(null);
const [editOpen, setEditOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
```
**操作流程**
1. **查询列表**`fetchList()` → API → 更新 state
2. **创建**:表单验证 → API → 刷新列表
3. **编辑**:打开弹窗 → 表单填充 → 表单验证 → API → 刷新列表
4. **删除**:确认弹窗 → API → 刷新列表
5. **状态切换**API → 刷新列表
---
## 代码架构建议
### 当前优点
1. 清晰的分层结构(页面 → Hook → API → Client
2. 类型安全(完整的 TypeScript 类型定义)
3. 权限管理系统
4. 可复用的 CRUD 模式
5. 自动 Token 刷新机制
### 需要关注的方面
1. **页面文件大小**UserLevelConfigs.tsx 和 SystemConfigs.tsx 都接近 300 行,可以考虑拆分
2. **组件复用**:考虑提取通用的 CRUD 表格组件
3. **错误处理**:某些 API 调用的错误处理需要完善
4. **加载状态**:分页加载、动作加载等状态管理可以统一
---
## 开发指南
### 添加新的配置管理页面
**步骤**
1. **创建类型定义** (`src/types/xxxConfig.ts`)
```typescript
export interface XxxConfig {
id: number;
name: string;
value: string;
status: number;
created_at: string;
updated_at: string;
}
export interface XxxConfigListRequest {
page?: number;
size?: number;
}
export interface XxxConfigListResponse {
data: XxxConfig[];
total: number;
}
```
2. **创建 API 服务** (`src/services/xxxConfigApi.ts`)
```typescript
export const getXxxConfigList = async (params) =>
(await apiClient.get('/admin/xxx-configs', { params })).data;
export const createXxxConfig = async (data) =>
(await apiClient.post('/admin/xxx-configs', data)).data;
// ... 其他 CRUD 操作
```
3. **创建页面组件** (`src/pages/XxxConfigs.tsx`)
- 参考 UserLevelConfigs.tsx 或 SystemConfigs.tsx 的结构
- 实现列表、创建、编辑、删除、状态切换
4. **添加路由** (`src/App.tsx`)
```typescript
<Route path="/system/xxx-configs" element={<XxxConfigs />} />
```
5. **添加菜单项** (`src/components/DynamicMenu.tsx`)
```typescript
{
key: 'system-xxx-configs',
label: 'Xxx 配置',
icon: <SettingOutlined />,
path: '/xxx-configs',
}
```
---
## 总结
这是一个**结构良好的 React 管理后台**,具有:
- 权限驱动的动态菜单和路由
- 清晰的分层 API 架构
- 完整的 TypeScript 类型支持
- 通用的 CRUD 页面模式
- 自动化的 Token 管理
最近的开发方向是**添加系统配置管理功能**SystemConfigs 和 UserLevelConfigs这些都是遵循统一的模式和最佳实践。

392
QUICK_REFERENCE.md Normal file
View File

@@ -0,0 +1,392 @@
# Goalfymax 管理后台 - 快速参考指南
## 项目信息
| 项目 | 说明 |
|------|------|
| **名称** | goalfymax-admin-web |
| **框架** | React 18.3.1 + TypeScript + Vite |
| **UI 库** | Ant Design 5.27.4 |
| **路由** | React Router 7.9.4 |
| **HTTP** | Axios 1.12.2 |
| **状态管理** | Jotai 2.15.0 |
| **总文件数** | 48 个 TS/TSX 文件 |
---
## 目录速查表
| 目录 | 用途 | 示例 |
|------|------|------|
| `src/pages/` | 页面组件 | UserLevelConfigs.tsx |
| `src/services/` | API 服务 | userLevelConfigApi.ts |
| `src/types/` | TypeScript 类型 | userLevelConfig.ts |
| `src/components/` | 可复用组件 | Layout.tsx, DynamicMenu.tsx |
| `src/hooks/` | 自定义 Hooks | useAuth.ts, usePagePermissions.ts |
| `src/atoms/` | Jotai 状态管理 | auth.ts |
| `src/routes/` | 路由配置 | DynamicRoutes.tsx |
| `src/utils/` | 工具函数 | storageMigration.ts |
---
## 关键概念速览
### 1. 双层路由系统
```
App.tsx (静态路由) + DynamicRoutes.tsx (权限感知路由)
Layout 组件 (侧边栏 + 菜单)
DynamicMenu (权限过滤菜单项)
```
### 2. API 调用流程
```
页面组件 → API 服务 → ApiClient → Axios → 后端 API
```
### 3. 权限检查层级
```
页面权限 (usePagePermissions) → 菜单权限 (DynamicMenu) → 操作权限 (usePermissions)
```
---
## 常见任务速查
### 添加新的配置管理页面
**所需文件数**3-4 个
**开发时间**:约 30-60 分钟
**步骤**
```bash
# 1. 创建类型定义
src/types/xxxConfig.ts
# 2. 创建 API 服务
src/services/xxxConfigApi.ts
# 3. 创建页面组件
src/pages/XxxConfigs.tsx
# 4. 更新路由 (src/App.tsx)
<Route path="/system/xxx-configs" element={<XxxConfigs />} />
# 5. 更新菜单 (src/components/DynamicMenu.tsx)
# 6. 更新子导航 (src/components/Layout.tsx)
```
### 快速复制模板
**UserLevelConfigs 类似的 CRUD 页面**
- 类型定义:`src/types/userLevelConfig.ts` (47 行)
- API 服务:`src/services/userLevelConfigApi.ts` (60 行)
- 页面组件:`src/pages/UserLevelConfigs.tsx` (292 行)
**SystemConfigs 类似的配置管理**
- 类型定义:`src/types/systemConfig.ts` (51 行)
- API 服务:`src/services/systemConfigApi.ts` (66 行)
- 页面组件:`src/pages/SystemConfigs.tsx` (284 行)
---
## 重要文件速查
### 核心基础设施
| 文件 | 行数 | 作用 |
|------|------|------|
| `src/App.tsx` | 68 | 主应用入口,静态路由定义 |
| `src/services/api.ts` | 587 | 核心 API 客户端 + 所有 API 服务导出 |
| `src/components/Layout.tsx` | 298 | 主布局(侧边栏 + 主区域 + 子导航) |
| `src/components/DynamicMenu.tsx` | 228 | 权限感知菜单 |
| `src/hooks/useAuth.ts` | ? | 认证管理 Hook |
| `src/hooks/usePagePermissions.ts` | ? | 页面权限 Hook |
### 配置管理示例
| 功能 | 页面 | 类型 | API 服务 |
|------|------|------|----------|
| 用户等级配置 | UserLevelConfigs.tsx | userLevelConfig.ts | userLevelConfigApi.ts |
| 系统配置 | SystemConfigs.tsx | systemConfig.ts | systemConfigApi.ts |
| GoalfyMax 用户 | GoalfyMaxUsers.tsx | - | api.ts (GoalfyMaxUsersApi) |
---
## API 端点速查
### 用户等级配置
```
GET /admin/user-level-configs 列表(分页)
GET /admin/user-level-configs/all 全部(不分页)
GET /admin/user-level-configs/:id 详情
POST /admin/user-level-configs 创建
PUT /admin/user-level-configs/:id 更新
PUT /admin/user-level-configs/:id/status 状态更新
DELETE /admin/user-level-configs/:id 删除
```
### 系统配置
```
GET /admin/system-configs 列表(分页)
GET /admin/system-configs/all 全部(不分页)
GET /admin/system-configs/:id 详情
GET /admin/system-configs/key/:key 按 Key 查询
POST /admin/system-configs 创建
PUT /admin/system-configs/:id 更新
PUT /admin/system-configs/:id/status 状态更新
DELETE /admin/system-configs/:id 删除
```
---
## 代码片段库
### 创建新的 API 服务函数
```typescript
// src/services/xxxConfigApi.ts
import { apiClient } from './api';
import type { XxxConfig, XxxConfigListRequest } from '../types/xxxConfig';
export const getXxxConfigList = async (params: XxxConfigListRequest) => {
const response = await apiClient.get('/admin/xxx-configs', { params });
return response.data;
};
export const createXxxConfig = async (data: any) => {
const response = await apiClient.post('/admin/xxx-configs', data);
return response.data;
};
export const updateXxxConfig = async (id: number, data: any) => {
const response = await apiClient.put(`/admin/xxx-configs/${id}`, data);
return response.data;
};
export const deleteXxxConfig = async (id: number) => {
await apiClient.delete(`/admin/xxx-configs/${id}`);
};
```
### 创建新的类型定义
```typescript
// src/types/xxxConfig.ts
export interface XxxConfig {
id: number;
name: string;
value: string;
status: number;
created_at: string;
updated_at: string;
}
export interface XxxConfigListRequest {
page?: number;
size?: number;
}
export interface XxxConfigListResponse {
data: XxxConfig[];
total: number;
}
```
### 创建 CRUD 页面骨架
```typescript
// src/pages/XxxConfigs.tsx
import React, { useEffect, useState } from 'react';
import { Table, Button, Modal, Form, Input, message } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import type { XxxConfig } from '../types/xxxConfig';
import { getXxxConfigList, createXxxConfig, updateXxxConfig, deleteXxxConfig } from '../services/xxxConfigApi';
export default function XxxConfigs() {
const [loading, setLoading] = useState(false);
const [list, setList] = useState<XxxConfig[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [size, setSize] = useState(10);
const [editing, setEditing] = useState<XxxConfig | null>(null);
const [editOpen, setEditOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [form] = Form.useForm();
const [createForm] = Form.useForm();
const fetchList = async () => {
setLoading(true);
try {
const res = await getXxxConfigList({ page, size });
setList(res?.data ?? []);
setTotal(res?.total ?? 0);
} catch (e) {
message.error('获取列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchList();
}, [page, size]);
// ... 其他 handlers
return (
<div>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
新建
</Button>
<Table dataSource={list} loading={loading} pagination={{
current: page, pageSize: size, total,
onChange: (p, s) => { setPage(p); setSize(s); }
}} />
{/* 弹窗 */}
</div>
);
}
```
---
## 常见错误和解决方案
### 问题 1页面显示后没有数据
**原因**API 端点不正确或权限不足
**解决**
1. 检查 API 端点 URL
2. 检查用户权限
3. 查看浏览器控制台错误日志
### 问题 2菜单项不显示
**原因**:权限检查失败
**解决**
1. 检查 DynamicMenu.tsx 的权限逻辑
2. 验证用户权限是否包含该页面路径
3. 查看控制台的权限检查日志(带 🔍 标记)
### 问题 3创建/编辑后页面没有刷新
**原因**:未调用 fetchList()
**解决**
1. 确保在 submitCreate/submitEdit 中调用 fetchList()
2. 检查是否有错误导致代码未执行到刷新逻辑
### 问题 4Token 过期导致 401 错误
**原因**Token 已过期ApiClient 应该自动处理
**解决**
1. 检查 refreshToken 是否存在
2. 查看浏览器控制台的 token 刷新日志
3. 检查后端是否正确响应 token 刷新请求
---
## 性能优化建议
### 当前架构的瓶颈
1. **大型页面文件**
- UserLevelConfigs.tsx: 292 行
- SystemConfigs.tsx: 284 行
- 建议:拆分为表格、表单、操作等子组件
2. **重复的 CRUD 代码**
- 每个管理页面都有类似的列表、创建、编辑、删除逻辑
- 建议:提取通用的 CRUD Hook 或高阶组件
3. **API 响应处理不一致**
- userLevelConfigApi 返回 response.data
- systemConfigApi 返回 response
- 建议:统一响应处理方式
---
## 开发工作流
### 开发新功能的标准步骤
```
1. 分析需求,确定数据模型
2. 创建 TypeScript 类型定义 (src/types/)
3. 创建 API 服务函数 (src/services/)
4. 创建页面组件 (src/pages/)
5. 注册路由 (src/App.tsx)
6. 添加菜单项 (src/components/DynamicMenu.tsx)
7. 更新子导航(如需要) (src/components/Layout.tsx)
8. 本地测试
9. 提交代码
```
---
## 有用的命令
```bash
# 启动开发服务器
npm run dev
# 构建生产版本
npm run build
# 代码检查
npm run lint
# 预览生产构建
npm run preview
```
---
## 文档链接
- **项目架构概览**PROJECT_ARCHITECTURE.md
- **API 模式详解**API_PATTERNS.md
- **Ant Design 文档**https://ant.design/
- **React Router 文档**https://reactrouter.com/
- **Jotai 文档**https://jotai.org/
---
## 常用类名和常量
### 用户等级配置
- 页面:`/system/user-level-configs`
- 菜单 key`system-user-level-configs`
- 类型文件:`userLevelConfig.ts`
- API 服务:`userLevelConfigApi.ts`
### 系统配置
- 页面:`/system/system-configs`
- 菜单 key`system-system-configs``system-global-configs`
- 类型文件:`systemConfig.ts`
- API 服务:`systemConfigApi.ts`
---
## 快速检查清单
创建新页面前,检查:
- [ ] 后端 API 已实现
- [ ] 类型定义完整
- [ ] API 服务函数齐全CRUD 所有操作)
- [ ] 页面组件遵循 CRUD 模式
- [ ] 路由已注册
- [ ] 菜单项已添加
- [ ] 权限检查已配置(如需要)
- [ ] 表单验证规则完整
- [ ] 错误处理到位
- [ ] 加载状态反馈清晰

566
QUICK_START.md Normal file
View File

@@ -0,0 +1,566 @@
# 前端开发快速开始指南
## 项目信息
- **技术栈**: React 18 + TypeScript + Vite + Ant Design 5 + React Router 7
- **项目路径**: `/Users/youziba/goalfyagent/goalfymax-admin-web`
- **状态管理**: Jotai
- **HTTP客户端**: Axios (自动处理 Token 认证和刷新)
---
## 快速创建新的配置管理页面
以创建"通用配置"管理页面为例,需要 5 个步骤:
### 步骤 1创建类型定义文件
**文件**: `src/types/commonConfig.ts`
```typescript
export interface CommonConfig {
id: number;
config_key: string; // 配置键
config_value: string; // 配置值
description: string; // 描述
status: number; // 1=启用, 0=禁用
created_at: string;
updated_at: string;
}
export interface CommonConfigListRequest {
config_key?: string;
status?: number;
page?: number;
size?: number;
}
export interface CommonConfigListResponse {
data: CommonConfig[];
total: number;
page: number;
size: number;
}
export interface CommonConfigCreateRequest {
config_key: string;
config_value: string;
description?: string;
}
export interface CommonConfigUpdateRequest {
config_key: string;
config_value: string;
description?: string;
}
```
### 步骤 2创建 API 服务文件
**文件**: `src/services/commonConfigApi.ts`
```typescript
import { apiClient } from './api';
import type {
CommonConfig,
CommonConfigListRequest,
CommonConfigListResponse,
CommonConfigCreateRequest,
CommonConfigUpdateRequest,
} from '../types/commonConfig';
export const getCommonConfigList = async (
params: CommonConfigListRequest
): Promise<CommonConfigListResponse> => {
const response = await apiClient.get('/admin/common-configs', { params });
return response.data;
};
export const getCommonConfigById = async (id: number): Promise<CommonConfig> => {
const response = await apiClient.get(`/admin/common-configs/${id}`);
return response.data;
};
export const createCommonConfig = async (
data: CommonConfigCreateRequest
): Promise<CommonConfig> => {
const response = await apiClient.post('/admin/common-configs', data);
return response.data;
};
export const updateCommonConfig = async (
id: number,
data: CommonConfigUpdateRequest
): Promise<CommonConfig> => {
const response = await apiClient.put(`/admin/common-configs/${id}`, data);
return response.data;
};
export const deleteCommonConfig = async (id: number): Promise<void> => {
await apiClient.delete(`/admin/common-configs/${id}`);
};
export const updateCommonConfigStatus = async (
id: number,
status: number
): Promise<void> => {
await apiClient.put(`/admin/common-configs/${id}/status`, { status });
};
```
### 步骤 3创建页面组件
**文件**: `src/pages/CommonConfigs.tsx`
```typescript
import React, { useEffect, useState } from 'react';
import {
Table,
Button,
Modal,
Form,
Input,
Tag,
Space,
Popconfirm,
message,
Row,
Col,
Card,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
CheckCircleOutlined,
StopOutlined,
} from '@ant-design/icons';
import type { CommonConfig } from '../types/commonConfig';
import {
getCommonConfigList,
createCommonConfig,
updateCommonConfig,
deleteCommonConfig,
updateCommonConfigStatus,
} from '../services/commonConfigApi';
export default function CommonConfigs() {
const [loading, setLoading] = useState(false);
const [list, setList] = useState<CommonConfig[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [size, setSize] = useState(10);
const [editOpen, setEditOpen] = useState(false);
const [editing, setEditing] = useState<CommonConfig | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [form] = Form.useForm();
const [createForm] = Form.useForm();
// 获取列表
const fetchList = async () => {
setLoading(true);
try {
const res = await getCommonConfigList({ page, size });
setList(res?.data ?? []);
setTotal(res?.total ?? 0);
} catch (e) {
message.error('获取列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchList();
}, [page, size]);
const openEdit = (config: CommonConfig) => {
setEditing(config);
form.setFieldsValue({
config_key: config.config_key,
config_value: config.config_value,
description: config.description,
});
setEditOpen(true);
};
const submitEdit = async () => {
try {
const values = await form.validateFields();
if (!editing) return;
await updateCommonConfig(editing.id, values);
message.success('更新成功');
setEditOpen(false);
fetchList();
} catch (error) {
message.error('更新失败');
}
};
const openCreate = () => {
createForm.resetFields();
setCreateOpen(true);
};
const submitCreate = async () => {
try {
const values = await createForm.validateFields();
await createCommonConfig(values);
message.success('创建成功');
setCreateOpen(false);
fetchList();
} catch (error: any) {
message.error(error?.response?.data?.message || '创建失败');
}
};
const handleDelete = async (config: CommonConfig) => {
try {
await deleteCommonConfig(config.id);
message.success('删除成功');
fetchList();
} catch (error) {
message.error('删除失败');
}
};
const handleToggleStatus = async (config: CommonConfig) => {
try {
const newStatus = config.status === 1 ? 0 : 1;
await updateCommonConfigStatus(config.id, newStatus);
message.success(newStatus === 1 ? '已启用' : '已禁用');
fetchList();
} catch (error) {
message.error('状态更新失败');
}
};
const columns = [
{
title: '配置键',
dataIndex: 'config_key',
key: 'config_key',
},
{
title: '配置值',
dataIndex: 'config_value',
key: 'config_value',
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (value: number) =>
value === 1 ? <Tag color="green">启用</Tag> : <Tag color="red">禁用</Tag>,
},
{
title: '操作',
key: 'action',
render: (_: any, config: CommonConfig) => (
<Space>
<Button type="link" icon={<EditOutlined />} onClick={() => openEdit(config)}>
编辑
</Button>
<Button
type="link"
icon={config.status === 1 ? <StopOutlined /> : <CheckCircleOutlined />}
onClick={() => handleToggleStatus(config)}
>
{config.status === 1 ? '禁用' : '启用'}
</Button>
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(config)}>
<Button type="link" danger icon={<DeleteOutlined />}>
删除
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={24}>
<Card>
<div style={{ marginBottom: 16 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
新建配置
</Button>
</div>
<Table
dataSource={list}
columns={columns}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize: size,
total: total,
onChange: (p, s) => {
setPage(p);
setSize(s);
},
}}
/>
</Card>
</Col>
</Row>
{/* 编辑弹窗 */}
<Modal
title="编辑配置"
open={editOpen}
onOk={submitEdit}
onCancel={() => setEditOpen(false)}
>
<Form form={form} layout="vertical">
<Form.Item
name="config_key"
label="配置键"
rules={[{ required: true, message: '请输入配置键' }]}
>
<Input placeholder="请输入配置键" />
</Form.Item>
<Form.Item
name="config_value"
label="配置值"
rules={[{ required: true, message: '请输入配置值' }]}
>
<Input placeholder="请输入配置值" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input.TextArea rows={3} />
</Form.Item>
</Form>
</Modal>
{/* 创建弹窗 */}
<Modal
title="新建配置"
open={createOpen}
onOk={submitCreate}
onCancel={() => setCreateOpen(false)}
>
<Form form={createForm} layout="vertical">
<Form.Item
name="config_key"
label="配置键"
rules={[{ required: true, message: '请输入配置键' }]}
>
<Input placeholder="请输入配置键app_name" />
</Form.Item>
<Form.Item
name="config_value"
label="配置值"
rules={[{ required: true, message: '请输入配置值' }]}
>
<Input placeholder="请输入配置值" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input.TextArea rows={3} placeholder="请输入描述" />
</Form.Item>
</Form>
</Modal>
</div>
);
}
```
### 步骤 4在 App.tsx 中添加路由
`src/App.tsx` 中找到系统管理路由部分,添加:
```typescript
import CommonConfigs from './pages/CommonConfigs';
// 在 Routes 中的系统管理部分添加:
<Route path="/system/common-configs" element={<CommonConfigs />} />
```
### 步骤 5在 Layout.tsx 中添加菜单项
`src/components/Layout.tsx` 中找到系统管理子菜单部分(`data-tabs="admin"`),添加:
```typescript
<button
className={activeSubTab === 'common-configs' ? 'active' : ''}
onClick={() => navigate('/system/common-configs')}
>
通用配置
</button>
```
---
## 核心文件速查表
| 功能 | 文件位置 | 说明 |
|------|---------|------|
| 全局路由 | `src/App.tsx` | 所有页面路由定义 |
| 菜单导航 | `src/components/Layout.tsx` | 菜单项和导航配置 |
| API 客户端 | `src/services/api.ts` | Axios 包装,自动处理 Token |
| 权限检查 | `src/hooks/usePagePermissions.ts` | 获取用户权限信息 |
| 认证管理 | `src/hooks/useAuth.ts` | SSO 认证相关逻辑 |
| 全局状态 | `src/atoms/auth.ts` | Jotai 状态原子定义 |
---
## 常用开发模式
### 1. 获取列表并分页
```typescript
const [list, setList] = useState<T[]>([]);
const [page, setPage] = useState(1);
const [size, setSize] = useState(10);
const [total, setTotal] = useState(0);
const fetchList = async () => {
const res = await getXxxList({ page, size });
setList(res?.data ?? []);
setTotal(res?.total ?? 0);
};
useEffect(() => {
fetchList();
}, [page, size]);
// 在 Table 中使用
<Table
dataSource={list}
pagination={{
current: page,
pageSize: size,
total: total,
onChange: (p, s) => {
setPage(p);
setSize(s);
},
}}
/>
```
### 2. 打开编辑弹窗并回显数据
```typescript
const [editOpen, setEditOpen] = useState(false);
const [editing, setEditing] = useState<T | null>(null);
const [form] = Form.useForm();
const openEdit = (record: T) => {
setEditing(record);
form.setFieldsValue({
field1: record.field1,
field2: record.field2,
});
setEditOpen(true);
};
const submitEdit = async () => {
const values = await form.validateFields();
await updateXxx(editing!.id, values);
message.success('更新成功');
setEditOpen(false);
fetchList();
};
```
### 3. 权限检查
```typescript
import { usePagePermissions } from '../hooks/usePagePermissions';
function MyComponent() {
const { getAccessiblePages } = usePagePermissions();
const accessiblePages = getAccessiblePages();
if (!accessiblePages.includes('/system')) {
return <div>无权访问</div>;
}
return <div>内容</div>;
}
```
### 4. 调用 API
```typescript
import { apiClient } from '../services/api';
// GET 请求
const data = await apiClient.get('/admin/xxx');
// POST 请求
const result = await apiClient.post('/admin/xxx', { key: 'value' });
// PUT 请求
const updated = await apiClient.put('/admin/xxx/123', { key: 'new-value' });
// DELETE 请求
await apiClient.delete('/admin/xxx/123');
```
---
## Ant Design 常用组件
| 组件 | 用途 | 导入 |
|------|------|------|
| `Button` | 按钮 | `from 'antd'` |
| `Table` | 数据表格 | `from 'antd'` |
| `Form` | 表单 | `from 'antd'` |
| `Input` | 文本输入 | `from 'antd'` |
| `InputNumber` | 数字输入 | `from 'antd'` |
| `Select` | 下拉选择 | `from 'antd'` |
| `Modal` | 弹窗对话框 | `from 'antd'` |
| `Message` | 消息提示 | `from 'antd'` |
| `Tag` | 标签 | `from 'antd'` |
| `Card` | 卡片容器 | `from 'antd'` |
| `Space` | 间距布局 | `from 'antd'` |
| `Popconfirm` | 确认弹窗 | `from 'antd'` |
---
## 开发工作流
1. **创建类型定义**`src/types/xxx.ts`
2. **创建 API 服务**`src/services/xxxApi.ts`
3. **创建页面组件**`src/pages/Xxx.tsx`
4. **添加路由**`src/App.tsx`
5. **添加菜单**`src/components/Layout.tsx`
6. **测试**`npm run dev`
---
## API 命名规范
- 列表: `GET /admin/xxx`
- 详情: `GET /admin/xxx/:id`
- 创建: `POST /admin/xxx`
- 更新: `PUT /admin/xxx/:id`
- 删除: `DELETE /admin/xxx/:id`
- 状态: `PUT /admin/xxx/:id/status`
---
## 注意事项
1. **Token 自动管理**: API 请求会自动附带 Token401 错误会自动刷新 Token
2. **权限检查**: 菜单和路由需要通过 `usePagePermissions` 检查用户权限
3. **类型安全**: 始终使用 TypeScript 定义接口,避免使用 `any`
4. **表单验证**: 使用 `Form.useForm()``validateFields()` 进行表单验证
5. **消息提示**: 使用 `message.success()` / `message.error()` 等方法提示用户
6. **异步处理**: 使用 `async/await` 简化异步逻辑
---
## 参考文档
完整的开发指南请查看: `/Users/youziba/goalfyagent/goalfymax-admin-web/FRONTEND_GUIDE.md`

178
README.md Normal file
View File

@@ -0,0 +1,178 @@
# Goalfy Admin Web - Token 统计系统
基于 React + Ant Design + TypeScript + Vite 技术栈开发的 Token 使用统计前端页面。
## 技术栈
- **React 19.1.1** - 前端框架
- **Ant Design 5.x** - UI 组件库
- **TypeScript 5.9.3** - 类型系统
- **Vite 7.1.7** - 构建工具
- **Axios** - HTTP 客户端
- **Day.js** - 日期处理
- **ESLint** - 代码检查
## 功能特性
### 🎯 核心功能
- **Token 使用统计** - 实时显示 Token 使用量、用户数、项目数、模型数
- **历史数据查询** - 支持按时间范围、用户、项目等条件查询
- **数据可视化** - 图表展示使用趋势和排行榜
- **详细数据表格** - 支持排序、分页的详细数据展示
### 🎨 界面设计
- **深色主题** - 参考 goalfy-admin 的深色设计风格
- **响应式布局** - 支持桌面和移动端
- **现代化 UI** - 使用 Ant Design 组件库
- **直观操作** - 简洁的查询界面和操作流程
## 项目结构
```
src/
├── components/ # React 组件
│ ├── QuotaFilters.tsx # 查询过滤器
│ ├── QuotaStats.tsx # 统计概览
│ ├── QuotaCharts.tsx # 图表组件
│ └── QuotaHistoryTable.tsx # 历史数据表格
├── services/ # API 服务
│ └── api.ts # API 客户端
├── types/ # TypeScript 类型
│ └── quota.ts # 配额相关类型
├── App.tsx # 主应用组件
├── App.css # 全局样式
└── main.tsx # 应用入口
```
## 快速开始
### 1. 安装依赖
```bash
npm install
```
### 2. 启动开发服务器
```bash
npm run dev
```
### 3. 构建生产版本
```bash
npm run build
```
## API 配置
`src/services/api.ts` 中配置后端 API 地址:
```typescript
const API_BASE_URL = 'http://localhost:8080'; // 修改为实际的后端地址
```
## 后端 API 接口
应用调用以下后端接口:
- `POST /api/quotas/history` - 获取配额历史数据
- `GET /health` - 健康检查
### 请求参数示例
```json
{
"start_date": "2024-01-01",
"end_date": "2024-01-31",
"user_id": "user123",
"api_group": "openai",
"project_id": "project456",
"period": "daily",
"group_by": ["user_id", "model"]
}
```
### 响应数据格式
```json
{
"success": true,
"data": [
{
"id": 1,
"user_id": "user123",
"api_group": "openai",
"project_id": "project456",
"day": "2024-01-01",
"account": "account123",
"model": "gpt-4",
"quota_used": 1000.5,
"created_at": "2024-01-01T10:00:00Z"
}
]
}
```
## 组件说明
### QuotaFilters
查询条件过滤器,支持:
- 时间范围选择
- 用户ID、API组、项目ID筛选
- 统计周期选择
- 分组维度配置
### QuotaStats
统计概览卡片,显示:
- 总 Token 使用量
- 活跃用户数
- 项目数量
- 模型数量
### QuotaCharts
数据可视化图表:
- 每日使用趋势图
- 用户使用量排行
- 模型使用量排行
### QuotaHistoryTable
详细数据表格,支持:
- 数据排序
- 分页显示
- 响应式布局
- 数据导出
## 样式定制
应用使用深色主题,主要颜色变量:
```css
--bg: #0b0c0f; /* 背景色 */
--panel: #12141a; /* 面板色 */
--text: #e7e9ee; /* 文字色 */
--accent: #5f97d2; /* 强调色 */
--border: #232634; /* 边框色 */
```
## 开发说明
### 添加新功能
1.`src/types/` 中定义类型
2.`src/services/` 中添加 API 调用
3.`src/components/` 中创建组件
4.`App.tsx` 中集成组件
### 代码规范
- 使用 TypeScript 严格模式
- 遵循 ESLint 规则
- 组件使用函数式写法
- 使用 Ant Design 组件库
## 部署说明
1. 构建生产版本:`npm run build`
2.`dist` 目录部署到 Web 服务器
3. 配置后端 API 地址
4. 确保 CORS 配置正确
## 许可证
内部使用,仅供 Goalfy 团队使用。

78
README_ENV.md Normal file
View File

@@ -0,0 +1,78 @@
# 环境变量配置
## 配置说明
项目支持通过环境变量配置,支持不同环境的部署需求。参考 `goalfymax-front` 项目的环境变量配置方式。
## 环境变量文件
### 开发环境
创建 `.env.development` 文件:
```bash
VITE_API_BASE_URL=http://localhost:8084
VITE_APP_TITLE=GoalfyMax Admin
VITE_APP_VERSION=1.0.0
VITE_ENABLE_DEVTOOLS=true
VITE_USE_MOCK=false
```
### 生产环境
创建 `.env.production` 文件:
```bash
VITE_API_BASE_URL=https://your-production-api.com
VITE_APP_TITLE=GoalfyMax Admin
VITE_APP_VERSION=1.0.0
VITE_ENABLE_DEVTOOLS=false
VITE_USE_MOCK=false
```
### 测试环境
创建 `.env.test` 文件:
```bash
VITE_API_BASE_URL=https://your-test-api.com
VITE_APP_TITLE=GoalfyMax Admin
VITE_APP_VERSION=1.0.0
VITE_ENABLE_DEVTOOLS=true
VITE_USE_MOCK=false
```
## 使用方法
### 1. 复制环境变量模板
```bash
cp env.example .env.development
```
### 2. 修改配置
编辑对应的环境变量文件,设置正确的配置值。
### 3. 启动项目
```bash
# 开发环境
npm run dev
# 生产环境构建
npm run build
```
## 环境变量说明
| 变量名 | 说明 | 默认值 |
|--------|------|--------|
| `VITE_API_BASE_URL` | API 基础地址 | `http://localhost:8084` |
| `VITE_APP_TITLE` | 应用标题 | `GoalfyMax Admin` |
| `VITE_APP_VERSION` | 应用版本 | `1.0.0` |
| `VITE_ENABLE_DEVTOOLS` | 启用开发工具 | `true` |
| `VITE_USE_MOCK` | 使用 Mock 数据 | `false` |
## 代理配置
开发环境下Vite 会自动代理 `/api` 请求到 `VITE_API_BASE_URL`,无需在代码中硬编码 API 地址。
## 注意事项
1. 环境变量文件需要以 `.env` 开头
2. 变量名必须以 `VITE_` 开头才能在客户端代码中使用
3. 生产环境构建时,环境变量会被编译到代码中
4. 敏感信息不要放在环境变量中,应该通过后端配置
5. 开发环境使用代理,生产环境直接使用环境变量中的 API 地址

82
build-and-push.sh Executable file
View File

@@ -0,0 +1,82 @@
#!/bin/bash
set -e # 遇到错误立即退出
# 配置变量
AWS_ACCOUNT_ID="177603749739"
AWS_REGION="us-west-2"
ECR_REGISTRY="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com"
IMAGE_NAME="goalfy/goalfymax-admin-web"
IMAGE_TAG="${1:-latest}" # 默认使用 latest可通过第一个参数指定版本
FULL_IMAGE_NAME="${ECR_REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
echo "=========================================="
echo "构建和推送前端 Docker 镜像到 AWS ECR"
echo "=========================================="
echo "镜像名称: ${FULL_IMAGE_NAME}"
echo "=========================================="
# 1. 登录到 AWS ECR
echo "步骤 1/4: 登录到 AWS ECR..."
aws ecr get-login-password --region ${AWS_REGION} | docker login --username AWS --password-stdin ${ECR_REGISTRY}
if [ $? -ne 0 ]; then
echo "错误: ECR 登录失败,请检查 AWS 凭证配置"
exit 1
fi
echo "✓ ECR 登录成功"
# 2. 构建 Docker 镜像
echo ""
echo "步骤 2/4: 构建 Docker 镜像..."
docker build --platform linux/amd64 -t ${FULL_IMAGE_NAME} -f Dockerfile .
if [ $? -ne 0 ]; then
echo "错误: Docker 镜像构建失败"
exit 1
fi
echo "✓ 镜像构建成功"
# 3. 如果指定了版本标签,同时也打上 latest 标签
if [ "${IMAGE_TAG}" != "latest" ]; then
echo ""
echo "步骤 3/4: 打 latest 标签..."
LATEST_IMAGE_NAME="${ECR_REGISTRY}/${IMAGE_NAME}:latest"
docker tag ${FULL_IMAGE_NAME} ${LATEST_IMAGE_NAME}
echo "✓ latest 标签已创建"
else
echo ""
echo "步骤 3/4: 跳过(已经是 latest 标签)"
fi
# 4. 推送镜像到 ECR
echo ""
echo "步骤 4/4: 推送镜像到 ECR..."
docker push ${FULL_IMAGE_NAME}
if [ $? -ne 0 ]; then
echo "错误: 镜像推送失败"
exit 1
fi
# 如果创建了 latest 标签,也推送它
if [ "${IMAGE_TAG}" != "latest" ]; then
echo "推送 latest 标签..."
docker push ${LATEST_IMAGE_NAME}
fi
echo ""
echo "=========================================="
echo "✓ 完成!镜像已成功推送"
echo "=========================================="
echo "镜像地址: ${FULL_IMAGE_NAME}"
if [ "${IMAGE_TAG}" != "latest" ]; then
echo " ${LATEST_IMAGE_NAME}"
fi
echo "=========================================="
echo ""
echo "下一步: 部署到 K8s 集群"
echo " kubectl apply -f k8s/"

15
env.example Normal file
View File

@@ -0,0 +1,15 @@
# 环境变量配置示例
# 复制此文件为 .env.local 或 .env.development 等,并根据需要修改
# API 基础地址
VITE_API_BASE_URL=http://localhost:8084
# 应用信息
VITE_APP_TITLE=GoalfyMax Admin
VITE_APP_VERSION=1.0.0
# 开发工具
VITE_ENABLE_DEVTOOLS=true
# Mock 数据
VITE_USE_MOCK=false

23
eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>goalfymax-admin-web</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

49
k8s/configmap.yaml Normal file
View File

@@ -0,0 +1,49 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: goalfymax-admin-web-config
namespace: goalfyagent
data:
nginx.conf: |
server {
listen 80;
server_name _;
# 前端静态文件根目录
root /usr/share/nginx/html;
index index.html index.htm;
# Gzip 压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
# 前端路由支持SPA应用
location / {
try_files $uri $uri/ /index.html;
}
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# 健康检查接口
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# 安全头设置
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# 隐藏 nginx 版本
server_tokens off;
}

58
k8s/deployment.yaml Normal file
View File

@@ -0,0 +1,58 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: goalfymax-admin-web
namespace: goalfyagent
labels:
app: goalfymax-admin-web
spec:
replicas: 2
selector:
matchLabels:
app: goalfymax-admin-web
template:
metadata:
labels:
app: goalfymax-admin-web
spec:
containers:
- name: goalfymax-admin-web
image: 177603749739.dkr.ecr.us-west-2.amazonaws.com/goalfy/goalfymax-admin-web:latest
imagePullPolicy: Always
ports:
- containerPort: 80
name: http
protocol: TCP
resources:
requests:
cpu: "50m"
memory: "64Mi"
limits:
cpu: "200m"
memory: "256Mi"
livenessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
volumeMounts:
- name: nginx-config
mountPath: /etc/nginx/conf.d/default.conf
subPath: nginx.conf
readOnly: true
volumes:
- name: nginx-config
configMap:
name: goalfymax-admin-web-config
restartPolicy: Always

38
k8s/httproute.yaml Normal file
View File

@@ -0,0 +1,38 @@
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: goalfymax-admin-web-route
namespace: goalfyagent
labels:
app: goalfymax-admin-web
spec:
# 指定父级 Gateway
parentRefs:
- name: ingress-gateway
namespace: istio-system
# 配置主机名
hostnames:
- "goalfymax-admin.goalfyai.com"
# 路由规则
rules:
# 前端静态文件路由
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: goalfymax-admin-web
port: 80
weight: 100
# API 路由到后端服务
- matches:
- path:
type: PathPrefix
value: /api/
backendRefs:
- name: goalfymax-admin
port: 8087
weight: 100

16
k8s/service.yaml Normal file
View File

@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: goalfymax-admin-web
namespace: goalfyagent
labels:
app: goalfymax-admin-web
spec:
type: ClusterIP
ports:
- port: 80
targetPort: 80
protocol: TCP
name: http
selector:
app: goalfymax-admin-web

42
nginx.conf Normal file
View File

@@ -0,0 +1,42 @@
server {
listen 80;
server_name _;
# 前端静态文件根目录
root /usr/share/nginx/html;
index index.html index.htm;
# Gzip 压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
# 前端路由支持SPA应用
location / {
try_files $uri $uri/ /index.html;
}
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# 健康检查接口
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# 安全头设置
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# 隐藏 nginx 版本
server_tokens off;
}

4769
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "goalfymax-admin-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^6.1.0",
"antd": "^5.27.4",
"axios": "^1.12.2",
"dayjs": "^1.11.18",
"jotai": "^2.15.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.9.4"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@types/node": "^24.6.0",
"@types/react": "^18.3.26",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.7.0",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "^5.4.20"
}
}

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

214
src/App.css Normal file
View File

@@ -0,0 +1,214 @@
/* --- Query form styles (aligned with gateway UI tone) --- */
.form-row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 12px;
}
.form-item { display: inline-flex; align-items: center; gap: 6px; }
.form-label { font-size: 12px; color: var(--muted); white-space: nowrap; }
.form-input, .form-select {
height: 32px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--soft);
color: var(--text);
font-size: 14px;
}
.form-select[multiple] {
height: 120px;
padding: 6px 8px;
}
.dropdown-panel {
background: var(--soft);
border: 1px solid var(--border);
border-radius: 8px;
min-width: 220px;
box-shadow: 0 8px 20px rgba(0,0,0,0.25);
}
.form-split { color: var(--muted); }
:root{
--bg: #f5f7fb;
--panel: #ffffff;
--soft: #f1f3f7;
--text: #171a1f;
--muted: #4d5562;
--accent: #5f97d2;
--good: #2fb167;
--warn: #caa410;
--bad: #b6404a;
--border: #e5e7ef;
--shadow: 0 10px 25px rgba(10,16,25,.08);
}
:root.light{
--bg: #f5f7fb;
--panel: #ffffff;
--soft: #f1f3f7;
--text: #171a1f;
--muted: #4d5562;
--accent: #5f97d2;
--good: #2fb167;
--warn: #caa410;
--bad: #b6404a;
--border: #e5e7ef;
--shadow: 0 10px 25px rgba(10,16,25,.08);
}
*{box-sizing:border-box; margin:0; padding:0}
html,body{height:100%; width:100%; margin:0; padding:0}
#root{width:100%; height:100%; margin:0; padding:0}
body{
margin:0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";
background:var(--bg);
color:var(--text);
display:flex;
min-height:100vh;
width:100%;
overflow:hidden;
}
a{color:inherit; text-decoration:none}
.container{display:flex; flex:1; width:100vw; height:100vh; max-width:none; margin:0; padding:0}
.sidebar{
border-right:1px solid var(--border);
background:linear-gradient(180deg, var(--panel), var(--soft));
padding:20px; height:100vh; flex-shrink:0;
display:flex; flex-direction:column;
overflow: hidden;
}
.brand{ display:flex; align-items:center; gap:10px; padding:8px 6px; margin-bottom:16px; }
.brand .logo{
width:28px; height:28px; border-radius:8px;
background: radial-gradient(120% 120% at 0% 0%, var(--accent) 0%, #9dc3e7 48%, #9394e7 100%);
box-shadow:var(--shadow);
}
.brand h1{font-size:16px; letter-spacing:.4px; margin:0; font-weight:700}
.sidegroup{margin-top:16px}
.sidegroup h2{ font-size:11px; text-transform:uppercase; color:var(--muted); letter-spacing:.8px; margin:12px 8px; }
.nav{display:flex; flex-direction:column; gap:4px}
.nav a{
display:flex; align-items:center; gap:10px; padding:10px 12px; border-radius:10px;
color:var(--text); border:1px solid transparent;
}
.nav a:hover{background:var(--soft)}
.nav a.active{background:var(--soft); border-color:var(--border)}
.header{
display:flex; align-items:center; justify-content:space-between; padding:16px 22px;
border-bottom:1px solid var(--border); background:var(--panel); z-index:6; flex-shrink:0;
}
.search{ display:flex; align-items:center; gap:10px; background:var(--soft); padding:8px 12px; border-radius:12px; border:1px solid var(--border); }
.search input{background:transparent; border:none; outline:none; color:var(--text); width:280px}
.actions{display:flex; align-items:center; gap:10px}
.btn{ padding:8px 12px; border-radius:10px; border:1px solid var(--border); background:var(--soft); color:var(--text); cursor:pointer; }
.btn.primary{background:var(--accent); border-color:transparent; color:white; box-shadow:var(--shadow)}
.main{flex:1; display:flex; flex-direction:column; min-width:0; height:100vh; overflow:hidden; width:100%}
.subnav{
background:var(--panel);
padding:12px 22px; border-bottom:1px solid var(--border); flex-shrink:0;
overflow-x:auto; -webkit-overflow-scrolling:touch;
}
.segment{
display:flex; gap:8px; background:var(--soft); padding:6px; border-radius:12px; border:1px solid var(--border); width:max-content;
}
.segment button{
border:none; background:transparent; color:var(--muted); padding:8px 12px; border-radius:8px; cursor:pointer;
}
.segment button.active{
background:var(--panel); color:var(--text); box-shadow:var(--shadow);
}
.content{padding:22px; display:flex; flex-direction:column; gap:16px; flex:1; overflow-y:auto; min-height:0; box-sizing:border-box}
.panel{
background:var(--panel); border:1px solid var(--border); border-radius:16px; padding:18px; box-shadow:var(--shadow);
width:100%;
}
.panel h2{margin:0 0 8px 0; font-size:16px}
.panel .desc{color:var(--muted); font-size:13px; margin-bottom:10px}
.flex-2{display:grid; grid-template-columns: 1.2fr 1fr; gap:16px}
.flex-3{display:grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap:16px}
.full{display:block; width:100%; box-sizing:border-box}
.kpis{display:grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap:12px}
.kpi{
background:var(--soft); border:1px solid var(--border); border-radius:14px; padding:14px;
}
.kpi h3{margin:0; font-size:12px; color:var(--muted)}
.kpi .num{font-size:26px; font-weight:800; margin-top:6px}
.kpi .delta{font-size:12px; margin-left:8px; padding:2px 6px; border-radius:8px; background:var(--panel); border:1px solid var(--border)}
.table{width:100%; border-collapse:collapse; font-size:14px; min-width:800px; table-layout:fixed; box-sizing:border-box}
.table th, .table td{padding:10px 12px; border-bottom:1px solid var(--border); text-align:left; white-space:nowrap; overflow:hidden; text-overflow:ellipsis}
.table thead th{color:var(--muted); font-weight:600}
.table-container{overflow-x:auto; width:100%; max-width:100%; -webkit-overflow-scrolling:touch; box-sizing:border-box}
.table th:nth-child(1), .table td:nth-child(1){width:8%}
.table th:nth-child(2), .table td:nth-child(2){width:15%}
.table th:nth-child(3), .table td:nth-child(3){width:12%}
.table th:nth-child(4), .table td:nth-child(4){width:15%}
.table th:nth-child(5), .table td:nth-child(5){width:15%}
.table th:nth-child(6), .table td:nth-child(6){width:15%}
.table th:nth-child(7), .table td:nth-child(7){width:20%}
.badge{font-size:12px; padding:4px 8px; border-radius:999px; background:var(--soft); border:1px solid var(--border)}
.badge.good{background:rgba(47,177,103,.12); border-color:rgba(47,177,103,.35); color:var(--good)}
.badge.warn{background:rgba(202,164,16,.12); border-color:rgba(202,164,16,.35); color:var(--warn)}
.badge.bad{ background:rgba(182,64,74,.12); border-color:rgba(182,64,74,.35); color:var(--bad)}
.chart{ height:260px; border:1px dashed var(--border); border-radius:12px; display:flex; align-items:center; justify-content:center; color:var(--muted); font-size:13px; }
.footer{padding:12px 22px; border-top:1px solid var(--border); color:var(--muted)}
.hidden{display:none}
@media (max-width: 1200px){
.kpis{grid-template-columns: repeat(2, minmax(0,1fr));}
.flex-3{grid-template-columns: repeat(2, minmax(0,1fr));}
.flex-2{grid-template-columns: 1fr;}
.table{font-size:13px; min-width:700px}
.table th, .table td{padding:8px 10px}
.table th:nth-child(1), .table td:nth-child(1){width:10%}
.table th:nth-child(2), .table td:nth-child(2){width:18%}
.table th:nth-child(3), .table td:nth-child(3){width:15%}
.table th:nth-child(4), .table td:nth-child(4){width:18%}
.table th:nth-child(5), .table td:nth-child(5){width:18%}
.table th:nth-child(6), .table td:nth-child(6){width:15%}
.table th:nth-child(7), .table td:nth-child(7){width:6%}
}
@media (max-width: 900px){
.sidebar{display:none}
.sidebar.mobile-open{display:flex; position:fixed; left:0; top:0; height:100vh; width:240px; z-index:1000;}
.mobile-backdrop{position:fixed; inset:0; background:rgba(0,0,0,.35); z-index:999}
.mobile-toggle{display:inline-flex; align-items:center; gap:6px}
.kpis{grid-template-columns: 1fr;}
.flex-3{grid-template-columns: 1fr;}
.table{font-size:12px; min-width:600px}
.table th, .table td{padding:6px 8px}
.search input{width:200px}
.header{padding:12px 16px}
.content{padding:16px; overflow-x:auto}
.table th:nth-child(1), .table td:nth-child(1){width:12%}
.table th:nth-child(2), .table td:nth-child(2){width:20%}
.table th:nth-child(3), .table td:nth-child(3){width:18%}
.table th:nth-child(4), .table td:nth-child(4){width:20%}
.table th:nth-child(5), .table td:nth-child(5){width:18%}
.table th:nth-child(6), .table td:nth-child(6){width:12%}
.table th:nth-child(7), .table td:nth-child(7){width:0%; display:none}
}
@media (max-width: 600px){
.table{min-width:500px}
.table th, .table td{padding:4px 6px; font-size:11px}
.kpi .num{font-size:20px}
.kpi h3{font-size:11px}
.table th:nth-child(1), .table td:nth-child(1){width:15%}
.table th:nth-child(2), .table td:nth-child(2){width:25%}
.table th:nth-child(3), .table td:nth-child(3){width:20%}
.table th:nth-child(4), .table td:nth-child(4){width:0%; display:none}
.table th:nth-child(5), .table td:nth-child(5){width:25%}
.table th:nth-child(6), .table td:nth-child(6){width:15%}
.table th:nth-child(7), .table td:nth-child(7){width:0%; display:none}
}

276
src/App.tsx Normal file
View File

@@ -0,0 +1,276 @@
import React from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import PagePermissionGuard from './components/PagePermissionGuard';
import { usePagePermissions } from './hooks/usePagePermissions';
import Layout from './components/Layout';
import Dashboard from './pages/Dashboard';
import Overview from './pages/Overview';
import Operations from './pages/Operations';
import Monitoring from './pages/Monitoring';
import Finance from './pages/Finance';
import FinanceTransactionLogs from './pages/FinanceTransactionLogs';
import TokenHistoryPage from './pages/TokenHistory';
import TokenAnalytics from './pages/TokenAnalytics';
import SystemHealth from './pages/SystemHealth';
import UserProjectQuotaWrapper from './pages/UserProjectQuota';
import UserManagement from './pages/UserManagement';
import RoleManagement from './pages/RoleManagement';
import GoalfyMaxUsers from './pages/GoalfyMaxUsers';
import UserFeedback from './pages/UserFeedback';
import MessagePush from './pages/MessagePush';
import VendorModelPricing from './pages/VendorModelPricing';
import MCPProviderPricing from './pages/MCPProviderPricing';
import VmPricing from './pages/VmPricing';
import SystemConfigs from './pages/SystemConfigs';
import UserLevelConfigs from './pages/UserLevelConfigs';
import AuditLogs from './pages/AuditLogs';
import InviteCodes from './pages/InviteCodes';
import NoPermission from './pages/NoPermission';
import FinanceSandboxCosts from './pages/FinanceSandboxCosts';
import FinanceTokenUsage from './pages/FinanceTokenUsage';
import FinanceMcpUsage from './pages/FinanceMcpUsage';
import FinancePaymentRecords from './pages/FinancePaymentRecords';
import FinanceMcpAccountRechargeRecords from './pages/FinanceMcpAccountRechargeRecords';
import FinanceModelAccountRechargeRecords from './pages/FinanceModelAccountRechargeRecords';
function App() {
const FirstAllowedRedirect: React.FC = () => {
const { getAccessiblePages } = usePagePermissions();
const pages = getAccessiblePages();
const target = pages[0] || '/no-permission';
console.log('↪️ [Router] default redirect to:', target, 'pages=', pages);
return <Navigate to={target} replace />;
};
return (
<Layout>
<Routes>
{/* 默认落地:首个可访问页面或无权限页 */}
<Route path="/" element={<FirstAllowedRedirect />} />
{/* 仪表盘(受页面权限控制) */}
<Route
path="/dashboard"
element={
<PagePermissionGuard pagePath="/dashboard" fallback={<Navigate to="/no-permission" replace />}>
<Dashboard />
</PagePermissionGuard>
}
/>
{/* 总览页面(受页面权限控制) */}
<Route
path="/overview"
element={
<PagePermissionGuard pagePath="/overview" fallback={<Navigate to="/no-permission" replace />}>
<Overview />
</PagePermissionGuard>
}
/>
{/* 运营页面 - 嵌套路由(父路径受控) */}
<Route path="/operations" element={<Navigate to="/operations/user-feedback" replace />} />
<Route
path="/operations/user-feedback"
element={
<PagePermissionGuard pagePath="/operations" fallback={<Navigate to="/no-permission" replace />}>
<UserFeedback />
</PagePermissionGuard>
}
/>
<Route
path="/operations/message-push"
element={
<PagePermissionGuard pagePath="/operations" fallback={<Navigate to="/no-permission" replace />}>
<MessagePush />
</PagePermissionGuard>
}
/>
<Route
path="/operations/vendor-model-pricing"
element={
<PagePermissionGuard pagePath="/operations" fallback={<Navigate to="/no-permission" replace />}>
<VendorModelPricing />
</PagePermissionGuard>
}
/>
<Route
path="/operations/mcp-provider-pricing"
element={
<PagePermissionGuard pagePath="/operations" fallback={<Navigate to="/no-permission" replace />}>
<MCPProviderPricing />
</PagePermissionGuard>
}
/>
<Route
path="/operations/vm-pricing"
element={
<PagePermissionGuard pagePath="/operations" fallback={<Navigate to="/no-permission" replace />}>
<VmPricing />
</PagePermissionGuard>
}
/>
<Route
path="/operations/invite-codes"
element={
<PagePermissionGuard pagePath="/operations" fallback={<Navigate to="/no-permission" replace />}>
<InviteCodes />
</PagePermissionGuard>
}
/>
{/* 监控页面 - 嵌套路由(父路径受控) */}
<Route path="/monitoring" element={<Navigate to="/monitoring/token-history" replace />} />
<Route
path="/monitoring/token-history"
element={
<PagePermissionGuard pagePath="/monitoring" fallback={<Navigate to="/no-permission" replace />}>
<TokenHistoryPage />
</PagePermissionGuard>
}
/>
<Route
path="/monitoring/token-analytics"
element={
<PagePermissionGuard pagePath="/monitoring" fallback={<Navigate to="/no-permission" replace />}>
<TokenAnalytics />
</PagePermissionGuard>
}
/>
<Route
path="/monitoring/system-health"
element={
<PagePermissionGuard pagePath="/monitoring" fallback={<Navigate to="/no-permission" replace />}>
<SystemHealth />
</PagePermissionGuard>
}
/>
{/* 财务页面(父路由重定向到子页面:用户流水) */}
<Route path="/finance" element={<Navigate to="/finance/transaction-logs" replace />} />
<Route
path="/finance/transaction-logs"
element={
<PagePermissionGuard pagePath="/finance" fallback={<Navigate to="/no-permission" replace />}>
<FinanceTransactionLogs />
</PagePermissionGuard>
}
/>
<Route
path="/finance/sandbox-costs"
element={
<PagePermissionGuard pagePath="/finance" fallback={<Navigate to="/no-permission" replace />}>
<FinanceSandboxCosts />
</PagePermissionGuard>
}
/>
<Route
path="/finance/token-usage"
element={
<PagePermissionGuard pagePath="/finance" fallback={<Navigate to="/no-permission" replace />}>
<FinanceTokenUsage />
</PagePermissionGuard>
}
/>
<Route
path="/finance/mcp-usage"
element={
<PagePermissionGuard pagePath="/finance" fallback={<Navigate to="/no-permission" replace />}>
<FinanceMcpUsage />
</PagePermissionGuard>
}
/>
<Route
path="/finance/payment-records"
element={
<PagePermissionGuard pagePath="/finance" fallback={<Navigate to="/no-permission" replace />}>
<FinancePaymentRecords />
</PagePermissionGuard>
}
/>
<Route
path="/finance/mcp-account-recharge-records"
element={
<PagePermissionGuard pagePath="/finance" fallback={<Navigate to="/no-permission" replace />}>
<FinanceMcpAccountRechargeRecords />
</PagePermissionGuard>
}
/>
<Route
path="/finance/model-account-recharge-records"
element={
<PagePermissionGuard pagePath="/finance" fallback={<Navigate to="/no-permission" replace />}>
<FinanceModelAccountRechargeRecords />
</PagePermissionGuard>
}
/>
{/* 系统管理页面 - 嵌套路由 */}
<Route path="/system" element={<Navigate to="/system/user-management" replace />} />
<Route
path="/system/user-project-quota"
element={
<PagePermissionGuard pagePath="/system" fallback={<Navigate to="/no-permission" replace />}>
<UserProjectQuotaWrapper />
</PagePermissionGuard>
}
/>
<Route
path="/system/user-management"
element={
<PagePermissionGuard pagePath="/system" fallback={<Navigate to="/no-permission" replace />}>
<UserManagement />
</PagePermissionGuard>
}
/>
<Route
path="/system/role-management"
element={
<PagePermissionGuard pagePath="/system" fallback={<Navigate to="/no-permission" replace />}>
<RoleManagement />
</PagePermissionGuard>
}
/>
<Route
path="/system/goalfymax-users"
element={
<PagePermissionGuard pagePath="/system" fallback={<Navigate to="/no-permission" replace />}>
<GoalfyMaxUsers />
</PagePermissionGuard>
}
/>
<Route
path="/system/user-level-configs"
element={
<PagePermissionGuard pagePath="/system" fallback={<Navigate to="/no-permission" replace />}>
<UserLevelConfigs />
</PagePermissionGuard>
}
/>
<Route
path="/system/system-configs"
element={
<PagePermissionGuard pagePath="/system" fallback={<Navigate to="/no-permission" replace />}>
<SystemConfigs />
</PagePermissionGuard>
}
/>
<Route
path="/system/audit-logs"
element={
<PagePermissionGuard pagePath="/system" fallback={<Navigate to="/no-permission" replace />}>
<AuditLogs />
</PagePermissionGuard>
}
/>
{/* 无权限页面 */}
<Route path="/no-permission" element={<NoPermission />} />
{/* 404页面 */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</Layout>
);
}
export default App;

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

36
src/atoms/auth.ts Normal file
View File

@@ -0,0 +1,36 @@
import { atom } from 'jotai';
export interface User {
sub: string;
name?: string;
email?: string;
preferred_username?: string;
permissions?: string[];
roles?: string[];
pages?: Array<{
id?: number;
name?: string;
path: string;
icon?: string;
sort_order?: number;
is_active?: boolean;
}>;
}
export interface AuthState {
user: User | null;
token: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
loginUrl: string | null;
}
export const authStateAtom = atom<AuthState>({
user: null,
token: localStorage.getItem('token'),
refreshToken: localStorage.getItem('refreshToken'),
isAuthenticated: false,
loginUrl: null,
});
export const authLoadingAtom = atom<boolean>(false);

View File

@@ -0,0 +1,90 @@
import React, { useEffect, useRef, useState } from 'react';
import { Spin } from 'antd';
import { useAuth } from '../hooks/useAuth';
interface AuthGuardProps {
children: React.ReactNode;
}
const AuthGuard: React.FC<AuthGuardProps> = ({ children }) => {
const {
isAuthenticated,
initAuth,
loginUrl,
redirectToLogin,
loading,
logout,
} = useAuth();
const initedRef = useRef(false);
const [booting, setBooting] = useState(true);
// 调试信息
console.log('🔐 [AuthGuard] 状态:', {
booting,
isAuthenticated,
loading,
hasLoginUrl: !!loginUrl,
});
useEffect(() => {
if (initedRef.current) {
setBooting(false);
return;
}
initedRef.current = true;
(async () => {
try {
await initAuth();
} finally {
setBooting(false);
}
})();
}, [initAuth]);
// 未认证时主动触发SSO登录流程
useEffect(() => {
if (!booting && !isAuthenticated && !loading) {
redirectToLogin();
}
}, [booting, isAuthenticated, loading, redirectToLogin]);
if (booting) {
return (
<div
style={{
display: 'flex',
height: '100vh',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Spin tip="Loading...">
<div className="w-[200px]" />
</Spin>
</div>
);
}
if (!isAuthenticated) {
// 未认证时显示加载等待自动跳转SSO
return (
<div
style={{
display: 'flex',
height: '100vh',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Spin tip="Redirecting to SSO...">
<div className="w-[200px]" />
</Spin>
</div>
);
}
return <>{children}</>;
};
export default AuthGuard;

View File

@@ -0,0 +1,108 @@
/* Ant Design Menu 自定义样式 */
.dynamic-menu-wrapper {
width: 100%;
}
.dynamic-menu-wrapper .ant-menu-inline {
border: none !important;
background: transparent;
}
.dynamic-menu-wrapper .ant-menu-item,
.dynamic-menu-wrapper .ant-menu-submenu-title {
border-radius: 10px;
margin: 4px 0;
padding-left: 16px !important;
height: 40px;
line-height: 40px;
transition: all 0.2s;
border: 1px solid transparent;
}
.dynamic-menu-wrapper .ant-menu-item:hover,
.dynamic-menu-wrapper .ant-menu-submenu-title:hover {
background: var(--soft) !important;
}
.dynamic-menu-wrapper .ant-menu-item-selected {
background: var(--soft) !important;
border-color: var(--border) !important;
}
.dynamic-menu-wrapper .ant-menu-item-selected::after {
display: none;
}
/* 图标样式 */
.dynamic-menu-wrapper .ant-menu-item .anticon,
.dynamic-menu-wrapper .ant-menu-submenu-title .anticon {
font-size: 16px;
color: var(--text);
}
/* 折叠时的样式 */
.dynamic-menu-wrapper .ant-menu-inline-collapsed {
width: 100% !important;
}
.dynamic-menu-wrapper .ant-menu-inline-collapsed > .ant-menu-item,
.dynamic-menu-wrapper .ant-menu-inline-collapsed > .ant-menu-submenu > .ant-menu-submenu-title {
padding: 0 16px !important;
text-align: center;
margin: 4px 0;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.dynamic-menu-wrapper .ant-menu-inline-collapsed .ant-menu-item-icon {
margin-inline-end: 0 !important;
}
.dynamic-menu-wrapper .ant-menu-inline-collapsed > .ant-menu-item .anticon,
.dynamic-menu-wrapper .ant-menu-inline-collapsed > .ant-menu-submenu > .ant-menu-submenu-title .anticon {
font-size: 20px;
line-height: 40px;
margin: 0 !important;
}
/* 子菜单样式 */
.dynamic-menu-wrapper .ant-menu-submenu-title {
color: var(--text);
}
.dynamic-menu-wrapper .ant-menu-sub {
background: transparent !important;
}
.dynamic-menu-wrapper .ant-menu-submenu .ant-menu-item {
padding-left: 48px !important;
}
/* 移除默认的边框和阴影 */
.dynamic-menu-wrapper .ant-menu-item,
.dynamic-menu-wrapper .ant-menu-submenu {
border: none;
}
.dynamic-menu-wrapper .ant-menu:not(.ant-menu-horizontal) .ant-menu-item-selected {
background-color: var(--soft);
}
/* 展开箭头样式 */
.dynamic-menu-wrapper .ant-menu-submenu-arrow {
color: var(--muted);
}
/* 子菜单展开时的样式 */
.dynamic-menu-wrapper .ant-menu-submenu-open > .ant-menu-submenu-title {
color: var(--text);
}
/* 文字超出隐藏 */
.dynamic-menu-wrapper .ant-menu-title-content {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,250 @@
import React from 'react';
import { Menu } from 'antd';
import {
PieChartOutlined,
TeamOutlined,
ControlOutlined,
MessageOutlined,
BellOutlined,
LineChartOutlined,
AccountBookOutlined,
SettingOutlined,
DatabaseOutlined,
UsergroupAddOutlined,
CrownOutlined
} from '@ant-design/icons';
import { useNavigate, useLocation } from 'react-router-dom';
import { usePagePermissions } from '../hooks/usePagePermissions';
import './DynamicMenu.css';
interface MenuItem {
key: string;
label: string;
icon?: React.ReactNode;
path?: string;
children?: MenuItem[];
}
interface DynamicMenuProps {
collapsed?: boolean;
}
const DynamicMenu: React.FC<DynamicMenuProps> = ({ collapsed = false }) => {
const navigate = useNavigate();
const location = useLocation();
const { getAccessiblePages, getPageActions } = usePagePermissions();
const accessiblePages = getAccessiblePages();
console.log('🔍 [DynamicMenu] 用户可访问页面:', accessiblePages);
// 菜单配置 - 使用数据库中的页面路径
const menuItems: MenuItem[] = [
{
key: 'overview',
label: '总览',
icon: <PieChartOutlined />,
path: '/overview',
},
{
key: 'operations',
label: '运营',
icon: <TeamOutlined />,
path: '/operations',
children: [
{
key: 'operations-main',
label: '运营管理',
icon: <ControlOutlined />,
path: '/operations',
},
{
key: 'operations-user-feedback',
label: '用户反馈',
icon: <MessageOutlined />,
path: '/user-feedback',
},
{
key: 'operations-message-push',
label: '消息推送',
icon: <BellOutlined />,
path: '/message-push',
},
{
key: 'operations-mcp-pricing',
label: 'MCP价格配置',
icon: <DatabaseOutlined />,
path: '/mcp-provider-pricing',
},
],
},
{
key: 'monitoring',
label: '监控',
icon: <LineChartOutlined />,
path: '/monitoring',
},
{
key: 'finance',
label: '财务',
icon: <AccountBookOutlined />,
path: '/finance',
},
{
key: 'system',
label: '系统管理',
icon: <SettingOutlined />,
path: '/system/user-management',
children: [
{
key: 'system-quota',
label: '配额管理',
icon: <DatabaseOutlined />,
path: '/system',
},
{
key: 'system-goalfymax-users',
label: 'GoalfyMax用户',
icon: <UsergroupAddOutlined />,
path: '/goalfymax-users',
},
{
key: 'system-user-level-configs',
label: '用户等级管理',
icon: <CrownOutlined />,
path: '/user-level-configs',
},
],
},
];
// 过滤可访问的菜单项
const filterAccessibleMenus = (items: MenuItem[]): MenuItem[] => {
return items.map(item => {
// 创建新对象,避免修改原始数据
const newItem = { ...item };
// 移除所有子菜单,只显示一级菜单(子导航在页面内显示)
newItem.children = undefined;
// 父级"系统管理"权限检查
if (newItem.key === 'system' || newItem.path === '/system') {
const hasSystemAccess = accessiblePages.includes('/system');
if (!hasSystemAccess) {
console.log(`🚫 [DynamicMenu] 隐藏系统管理: 用户无 /system 权限`);
return null;
}
console.log(`✅ [DynamicMenu] 显示系统管理菜单项`);
return newItem;
}
// 父级"运营"权限检查
if (newItem.key === 'operations' || newItem.path === '/operations') {
const hasOperationsAccess = accessiblePages.includes('/operations');
if (!hasOperationsAccess) {
console.log(`🚫 [DynamicMenu] 隐藏运营: 用户无 /operations 权限`);
return null;
}
console.log(`✅ [DynamicMenu] 显示运营菜单项`);
return newItem;
}
// 对于有路径的项目,检查权限
if (newItem.path && !accessiblePages.includes(newItem.path)) {
console.log(`🚫 [DynamicMenu] 隐藏菜单项: ${newItem.label} (${newItem.path}) - 用户无权限`);
return null;
}
console.log(`✅ [DynamicMenu] 显示菜单项: ${newItem.label} (${newItem.path}) - 用户有权限`);
return newItem;
}).filter((item): item is MenuItem => item !== null);
};
const accessibleMenus = filterAccessibleMenus(menuItems);
console.log('🔍 [DynamicMenu] 原始菜单项:', menuItems.map(item => ({ key: item.key, label: item.label, path: item.path })));
console.log('🔍 [DynamicMenu] 过滤后的菜单项:', accessibleMenus.map(item => ({ key: item.key, label: item.label, path: item.path })));
const handleMenuClick = ({ key }: { key: string }) => {
const findMenuItem = (items: MenuItem[], targetKey: string): MenuItem | null => {
for (const item of items) {
if (item.key === targetKey) {
return item;
}
if (item.children) {
const found = findMenuItem(item.children, targetKey);
if (found) return found;
}
}
return null;
};
const menuItem = findMenuItem(accessibleMenus, key);
if (menuItem?.path) {
navigate(menuItem.path);
}
};
// 根据当前路径找到对应的菜单key
const getSelectedMenuKey = (): string => {
const currentPath = location.pathname;
// 遍历所有菜单项找到匹配当前路径的菜单key
const findKeyByPath = (items: MenuItem[]): string | null => {
for (const item of items) {
// 精确匹配路径
if (item.path === currentPath) {
return item.key;
}
// 检查是否是子路径(例如 /system/quota-rules 应该选中 system
if (item.path && currentPath.startsWith(item.path)) {
return item.key;
}
// 递归检查子菜单
if (item.children) {
const childKey = findKeyByPath(item.children);
if (childKey) return childKey;
}
}
return null;
};
const key = findKeyByPath(accessibleMenus);
return key || '';
};
// 转换为 Ant Design Menu 所需的格式
const convertToMenuItems = (items: MenuItem[]): any[] => {
return items.map(item => {
if (item.children && item.children.length > 0) {
return {
key: item.key,
label: item.label,
icon: item.icon,
children: convertToMenuItems(item.children),
};
}
return {
key: item.key,
label: item.label,
icon: item.icon,
};
});
};
const selectedKey = getSelectedMenuKey();
return (
<div className="dynamic-menu-wrapper">
<Menu
mode="inline"
inlineCollapsed={collapsed}
selectedKeys={selectedKey ? [selectedKey] : []}
items={convertToMenuItems(accessibleMenus)}
onClick={handleMenuClick}
/>
</div>
);
};
export default DynamicMenu;

391
src/components/Layout.tsx Normal file
View File

@@ -0,0 +1,391 @@
import React, { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
MenuFoldOutlined,
MenuUnfoldOutlined
} from '@ant-design/icons';
import { useAuth } from '../hooks/useAuth';
import DynamicMenu from './DynamicMenu';
const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const location = useLocation();
const navigate = useNavigate();
const { token, logout } = useAuth();
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
// 从路径获取当前激活的标签
const getCurrentTab = () => {
const path = location.pathname;
if (path === '/dashboard' || path === '/overview') return 'overview';
if (path.startsWith('/operations')) return 'operations';
if (path.startsWith('/monitoring')) return 'monitoring';
if (path === '/finance' || path.startsWith('/finance/')) return 'finance';
if (path.startsWith('/system')) return 'admin';
return 'overview';
};
const getCurrentSubTab = () => {
const path = location.pathname;
// 运营页面子导航
if (path.startsWith('/operations/user-feedback')) return 'user-feedback';
if (path.startsWith('/operations/message-push')) return 'message-push';
if (path.startsWith('/operations/vendor-model-pricing')) return 'vendor-model-pricing';
if (path.startsWith('/operations/mcp-provider-pricing')) return 'mcp-provider-pricing';
if (path.startsWith('/operations/vm-pricing')) return 'vm-pricing';
if (path.startsWith('/operations/invite-codes')) return 'invite-codes';
// 监控页面子导航
if (path.startsWith('/monitoring/token-history')) return 'token-history';
if (path.startsWith('/monitoring/token-analytics')) return 'token-analytics';
if (path.startsWith('/monitoring/system-health')) return 'system-health';
// 财务页面子导航
if (path.startsWith('/finance/transaction-logs')) return 'transaction-logs';
if (path.startsWith('/finance/sandbox-costs')) return 'sandbox-costs';
if (path.startsWith('/finance/token-usage')) return 'token-usage';
if (path.startsWith('/finance/mcp-usage')) return 'mcp-usage';
if (path.startsWith('/finance/payment-records')) return 'payment-records';
if (path.startsWith('/finance/mcp-account-recharge-records')) return 'mcp-account-recharge-records';
if (path.startsWith('/finance/model-account-recharge-records')) return 'model-account-recharge-records';
// 系统管理页面子导航
if (path.startsWith('/system/user-project-quota')) return 'user-project-quota';
if (path.startsWith('/system/user-management')) return 'user-management';
if (path.startsWith('/system/role-management')) return 'role-management';
if (path.startsWith('/system/goalfymax-users')) return 'goalfymax-users';
if (path.startsWith('/system/user-level-configs')) return 'user-level-configs';
if (path.startsWith('/system/system-configs')) return 'system-configs';
if (path.startsWith('/system/audit-logs')) return 'audit-logs';
return '';
};
const activeTab = getCurrentTab();
const activeSubTab = getCurrentSubTab();
const handleLogout = async () => {
await logout();
};
return (
<div className="container">
{/* 侧边栏 */}
<aside className={`sidebar${mobileOpen ? ' mobile-open' : ''}`} style={{ width: collapsed ? '80px' : '240px', transition: 'width 0.2s' }}>
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: collapsed ? '16px 0' : '16px',
marginBottom: '8px',
gap: '12px'
}}>
{collapsed ? (
<>
<div className="logo" style={{ margin: 0 }}></div>
<button
onClick={() => setCollapsed(!collapsed)}
style={{
border: '1px solid var(--border)',
background: 'var(--soft)',
cursor: 'pointer',
fontSize: '16px',
color: 'var(--text)',
padding: '8px',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s',
width: '40px',
height: '40px',
flexShrink: 0
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--panel)';
e.currentTarget.style.boxShadow = 'var(--shadow)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--soft)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<MenuUnfoldOutlined />
</button>
</>
) : (
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%'
}}>
<div className="brand" style={{
display: 'flex',
justifyContent: 'flex-start',
alignItems: 'center',
flex: 1
}}>
<div className="logo"></div>
<h1>Goalfy Admin</h1>
</div>
<button
onClick={() => setCollapsed(!collapsed)}
style={{
border: '1px solid var(--border)',
background: 'var(--soft)',
cursor: 'pointer',
fontSize: '16px',
color: 'var(--text)',
padding: '8px',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s',
width: '40px',
height: '40px',
flexShrink: 0
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--panel)';
e.currentTarget.style.boxShadow = 'var(--shadow)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--soft)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<MenuFoldOutlined />
</button>
</div>
)}
</div>
<div className="sidegroup" style={{ flex: 1, overflow: 'hidden' }}>
<DynamicMenu collapsed={collapsed} />
</div>
<div style={{
fontSize: '12px',
color: 'var(--muted)',
textAlign: collapsed ? 'center' : 'left',
padding: '16px',
borderTop: '1px solid var(--border)',
marginTop: 'auto'
}}>
{!collapsed ? 'v1.1 · React Admin' : 'v1.1'}
</div>
</aside>
{/* 主内容区 */}
<section className="main">
<header className="header">
<div style={{display:'flex', alignItems:'center', gap:'10px'}}>
<button
className="btn mobile-toggle"
onClick={() => setMobileOpen(true)}
aria-label="打开菜单"
>
<MenuUnfoldOutlined />
</button>
</div>
<div className="actions">
<div style={{display:'flex', alignItems:'center', gap:'8px'}}>
{token ? (
<>
<span style={{fontSize:12,color:'var(--muted)'}}></span>
<button className="btn" onClick={handleLogout}></button>
</>
) : null}
</div>
</div>
</header>
{/* 监控页面的子导航 */}
{activeTab === 'monitoring' && (
<nav className="subnav">
<div className="segment" data-tabs="mo">
<button
className={activeSubTab === 'token-history' ? 'active' : ''}
onClick={() => navigate('/monitoring/token-history')}
>
Token
</button>
<button
className={activeSubTab === 'token-analytics' ? 'active' : ''}
onClick={() => navigate('/monitoring/token-analytics')}
>
Token
</button>
<button
className={activeSubTab === 'system-health' ? 'active' : ''}
onClick={() => navigate('/monitoring/system-health')}
>
</button>
</div>
</nav>
)}
{/* 运营页面的子导航 */}
{activeTab === 'operations' && (
<nav className="subnav">
<div className="segment" data-tabs="operations">
<button
className={activeSubTab === 'user-feedback' ? 'active' : ''}
onClick={() => navigate('/operations/user-feedback')}
>
</button>
<button
className={activeSubTab === 'message-push' ? 'active' : ''}
onClick={() => navigate('/operations/message-push')}
>
</button>
<button
className={activeSubTab === 'vendor-model-pricing' ? 'active' : ''}
onClick={() => navigate('/operations/vendor-model-pricing')}
>
</button>
<button
className={activeSubTab === 'mcp-provider-pricing' ? 'active' : ''}
onClick={() => navigate('/operations/mcp-provider-pricing')}
>
MCP
</button>
<button
className={activeSubTab === 'vm-pricing' ? 'active' : ''}
onClick={() => navigate('/operations/vm-pricing')}
>
</button>
<button
className={activeSubTab === 'invite-codes' ? 'active' : ''}
onClick={() => navigate('/operations/invite-codes')}
>
</button>
</div>
</nav>
)}
{/* 财务页面的子导航 */}
{activeTab === 'finance' && (
<nav className="subnav">
<div className="segment" data-tabs="finance">
<button
className={activeSubTab === 'transaction-logs' ? 'active' : ''}
onClick={() => navigate('/finance/transaction-logs')}
>
</button>
<button
className={activeSubTab === 'sandbox-costs' ? 'active' : ''}
onClick={() => navigate('/finance/sandbox-costs')}
>
</button>
<button
className={activeSubTab === 'token-usage' ? 'active' : ''}
onClick={() => navigate('/finance/token-usage')}
>
Token费用
</button>
<button
className={activeSubTab === 'mcp-usage' ? 'active' : ''}
onClick={() => navigate('/finance/mcp-usage')}
>
MCP
</button>
<button
className={activeSubTab === 'payment-records' ? 'active' : ''}
onClick={() => navigate('/finance/payment-records')}
>
</button>
<button
className={activeSubTab === 'mcp-account-recharge-records' ? 'active' : ''}
onClick={() => navigate('/finance/mcp-account-recharge-records')}
>
MCP账号管理
</button>
<button
className={activeSubTab === 'model-account-recharge-records' ? 'active' : ''}
onClick={() => navigate('/finance/model-account-recharge-records')}
>
</button>
</div>
</nav>
)}
{/* 系统管理页面的子导航 */}
{activeTab === 'admin' && (
<nav className="subnav">
<div className="segment" data-tabs="admin">
{/* 暂时隐藏用户项目配额菜单 */}
{/* <button
className={activeSubTab === 'user-project-quota' ? 'active' : ''}
onClick={() => navigate('/system/user-project-quota')}
>
用户项目配额
</button> */}
<button
className={activeSubTab === 'user-management' ? 'active' : ''}
onClick={() => navigate('/system/user-management')}
>
</button>
<button
className={activeSubTab === 'role-management' ? 'active' : ''}
onClick={() => navigate('/system/role-management')}
>
</button>
<button
className={activeSubTab === 'goalfymax-users' ? 'active' : ''}
onClick={() => navigate('/system/goalfymax-users')}
>
GoalfyMax用户
</button>
<button
className={activeSubTab === 'user-level-configs' ? 'active' : ''}
onClick={() => navigate('/system/user-level-configs')}
>
</button>
<button
className={activeSubTab === 'system-configs' ? 'active' : ''}
onClick={() => navigate('/system/system-configs')}
>
</button>
<button
className={activeSubTab === 'audit-logs' ? 'active' : ''}
onClick={() => navigate('/system/audit-logs')}
>
</button>
</div>
</nav>
)}
<main className="content">
{children}
</main>
<footer className="footer">© 2025 Goalfy · Internal use only</footer>
</section>
{mobileOpen && (
<div
className="mobile-backdrop"
onClick={() => setMobileOpen(false)}
/>
)}
</div>
);
};
export default Layout;

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { usePagePermissions } from '../hooks/usePagePermissions';
interface PagePermissionGuardProps {
pagePath: string;
action?: string;
children: React.ReactNode;
fallback?: React.ReactNode;
}
const PagePermissionGuard: React.FC<PagePermissionGuardProps> = ({
pagePath,
action = 'access',
children,
fallback = null
}) => {
const { hasPagePermission } = usePagePermissions();
const hasAccess = hasPagePermission(pagePath, action);
return hasAccess ? <>{children}</> : <>{fallback}</>;
};
export default PagePermissionGuard;

View File

@@ -0,0 +1,57 @@
import React from 'react';
import { usePermissions } from '../hooks/usePermissions';
interface PermissionGuardProps {
permission?: string;
permissions?: string[];
requireAll?: boolean;
role?: string;
roles?: string[];
requireAllRoles?: boolean;
children: React.ReactNode;
fallback?: React.ReactNode;
}
const PermissionGuard: React.FC<PermissionGuardProps> = ({
permission,
permissions,
requireAll = false,
role,
roles,
requireAllRoles = false,
children,
fallback = null
}) => {
const {
hasPermission,
hasAnyPermission,
hasAllPermissions,
hasRole,
hasAnyRole,
hasAllRoles
} = usePermissions();
let hasAccess = false;
// 检查权限
if (permission) {
hasAccess = hasPermission(permission);
console.log(`🔐 [PermissionGuard] 检查权限 "${permission}": ${hasAccess}`);
} else if (permissions) {
hasAccess = requireAll ? hasAllPermissions(permissions) : hasAnyPermission(permissions);
console.log(`🔐 [PermissionGuard] 检查权限组 "${permissions}": ${hasAccess}`);
}
// 检查角色
if (role) {
hasAccess = hasRole(role);
console.log(`👤 [PermissionGuard] 检查角色 "${role}": ${hasAccess}`);
} else if (roles) {
hasAccess = requireAllRoles ? hasAllRoles(roles) : hasAnyRole(roles);
console.log(`👤 [PermissionGuard] 检查角色组 "${roles}": ${hasAccess}`);
}
return hasAccess ? <>{children}</> : <>{fallback}</>;
};
export default PermissionGuard;

View File

@@ -0,0 +1,142 @@
import React from 'react';
import { Card, Row, Col, Typography } from 'antd';
import { QuotaHistoryItem } from '../types/quota';
import dayjs from 'dayjs';
const { Title } = Typography;
interface QuotaChartsProps {
data: QuotaHistoryItem[];
loading?: boolean;
}
const QuotaCharts: React.FC<QuotaChartsProps> = ({
data,
loading = false
}) => {
// 计算每日使用量
const dailyUsage = React.useMemo(() => {
const dailyMap = new Map<string, number>();
data.forEach(item => {
const day = item.day;
dailyMap.set(day, (dailyMap.get(day) || 0) + item.quota_used);
});
return Array.from(dailyMap.entries())
.map(([date, quota]) => ({ date, quota }))
.sort((a, b) => dayjs(a.date).unix() - dayjs(b.date).unix());
}, [data]);
// 计算用户使用量排行
const topUsers = React.useMemo(() => {
const userMap = new Map<string, number>();
data.forEach(item => {
userMap.set(item.user_id, (userMap.get(item.user_id) || 0) + item.quota_used);
});
return Array.from(userMap.entries())
.map(([user_id, quota_used]) => ({ user_id, quota_used }))
.sort((a, b) => b.quota_used - a.quota_used)
.slice(0, 10);
}, [data]);
// 计算模型使用量排行
const topModels = React.useMemo(() => {
const modelMap = new Map<string, number>();
data.forEach(item => {
modelMap.set(item.model, (modelMap.get(item.model) || 0) + item.quota_used);
});
return Array.from(modelMap.entries())
.map(([model, quota_used]) => ({ model, quota_used }))
.sort((a, b) => b.quota_used - a.quota_used)
.slice(0, 10);
}, [data]);
return (
<Row gutter={[16, 16]}>
<Col xs={24} lg={12}>
<Card loading={loading}>
<Title level={5}> Token 使</Title>
<div style={{ height: 300, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ textAlign: 'center', color: '#999' }}>
<div>📈</div>
<div></div>
<div style={{ fontSize: '12px', marginTop: '8px' }}>
: {dailyUsage.length}
</div>
</div>
</div>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card loading={loading}>
<Title level={5}> Token 使</Title>
<div style={{ height: 300, overflowY: 'auto' }}>
{topUsers.map((user, index) => (
<div key={user.user_id} style={{
display: 'flex',
justifyContent: 'space-between',
padding: '8px 0',
borderBottom: index < topUsers.length - 1 ? '1px solid #f0f0f0' : 'none'
}}>
<span>
<span style={{
display: 'inline-block',
width: '20px',
textAlign: 'center',
fontWeight: 'bold',
color: index < 3 ? '#ff4d4f' : '#666'
}}>
{index + 1}
</span>
{user.user_id}
</span>
<span style={{ fontWeight: 'bold', color: '#1890ff' }}>
{user.quota_used.toLocaleString()}
</span>
</div>
))}
</div>
</Card>
</Col>
<Col xs={24}>
<Card loading={loading}>
<Title level={5}> Token 使</Title>
<div style={{ height: 300, overflowY: 'auto' }}>
{topModels.map((model, index) => (
<div key={model.model} style={{
display: 'flex',
justifyContent: 'space-between',
padding: '8px 0',
borderBottom: index < topModels.length - 1 ? '1px solid #f0f0f0' : 'none'
}}>
<span>
<span style={{
display: 'inline-block',
width: '20px',
textAlign: 'center',
fontWeight: 'bold',
color: index < 3 ? '#ff4d4f' : '#666'
}}>
{index + 1}
</span>
{model.model}
</span>
<span style={{ fontWeight: 'bold', color: '#52c41a' }}>
{model.quota_used.toLocaleString()}
</span>
</div>
))}
</div>
</Card>
</Col>
</Row>
);
};
export default QuotaCharts;

View File

@@ -0,0 +1,162 @@
import React from 'react';
import {
Card,
Form,
DatePicker,
Input,
Select,
Button,
Space,
Row,
Col,
Typography
} from 'antd';
import { SearchOutlined, ReloadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
const { Title } = Typography;
const { RangePicker } = DatePicker;
const { Option } = Select;
interface QuotaFiltersProps {
onSearch: (values: any) => void;
onReset: () => void;
loading?: boolean;
}
const QuotaFilters: React.FC<QuotaFiltersProps> = ({
onSearch,
onReset,
loading = false
}) => {
const [form] = Form.useForm();
const handleSearch = (values: any) => {
const searchParams = {
...values,
start_date: values.dateRange?.[0]?.format('YYYY-MM-DD'),
end_date: values.dateRange?.[1]?.format('YYYY-MM-DD'),
};
delete searchParams.dateRange;
onSearch(searchParams);
};
const handleReset = () => {
form.resetFields();
onReset();
};
return (
<Card style={{ marginBottom: 16 }}>
<Title level={4} style={{ marginBottom: 16 }}>
</Title>
<Form
form={form}
layout="vertical"
onFinish={handleSearch}
initialValues={{
dateRange: [dayjs().subtract(7, 'day'), dayjs()],
period: 'daily'
}}
>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} lg={8}>
<Form.Item
label="时间范围"
name="dateRange"
rules={[{ required: true, message: '请选择时间范围' }]}
>
<RangePicker
style={{ width: '100%' }}
format="YYYY-MM-DD"
placeholder={['开始日期', '结束日期']}
/>
</Form.Item>
</Col>
<Col xs={24} sm={12} lg={8}>
<Form.Item
label="统计周期"
name="period"
>
<Select placeholder="选择统计周期">
<Option value="daily"></Option>
<Option value="monthly"></Option>
</Select>
</Form.Item>
</Col>
<Col xs={24} sm={12} lg={8}>
<Form.Item
label="用户ID"
name="user_id"
>
<Input placeholder="输入用户ID" />
</Form.Item>
</Col>
<Col xs={24} sm={12} lg={8}>
<Form.Item
label="API组"
name="api_group"
>
<Input placeholder="输入API组" />
</Form.Item>
</Col>
<Col xs={24} sm={12} lg={8}>
<Form.Item
label="项目ID"
name="project_id"
>
<Input placeholder="输入项目ID" />
</Form.Item>
</Col>
<Col xs={24} sm={12} lg={8}>
<Form.Item
label="分组维度"
name="group_by"
>
<Select
mode="multiple"
placeholder="选择分组维度"
style={{ width: '100%' }}
>
<Option value="user_id"></Option>
<Option value="api_group">API组</Option>
<Option value="project_id"></Option>
<Option value="model"></Option>
<Option value="day"></Option>
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item>
<Space>
<Button
type="primary"
htmlType="submit"
icon={<SearchOutlined />}
loading={loading}
>
</Button>
<Button
onClick={handleReset}
icon={<ReloadOutlined />}
>
</Button>
</Space>
</Form.Item>
</Form>
</Card>
);
};
export default QuotaFilters;

View File

@@ -0,0 +1,118 @@
import React from 'react';
import { Table, Tag, Card, Typography, Space } from 'antd';
import { QuotaHistoryItem } from '../types/quota';
import dayjs from 'dayjs';
const { Title } = Typography;
interface QuotaHistoryTableProps {
data: QuotaHistoryItem[];
loading?: boolean;
}
const QuotaHistoryTable: React.FC<QuotaHistoryTableProps> = ({
data,
loading = false
}) => {
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
},
{
title: '用户ID',
dataIndex: 'user_id',
key: 'user_id',
width: 120,
},
{
title: 'API组',
dataIndex: 'api_group',
key: 'api_group',
width: 120,
render: (apiGroup: string) => (
<Tag color="blue">{apiGroup}</Tag>
),
},
{
title: '项目ID',
dataIndex: 'project_id',
key: 'project_id',
width: 120,
},
{
title: '账户',
dataIndex: 'account',
key: 'account',
width: 120,
},
{
title: '模型',
dataIndex: 'model',
key: 'model',
width: 150,
render: (model: string) => (
<Tag color="green">{model}</Tag>
),
},
{
title: 'Token 使用量',
dataIndex: 'quota_used',
key: 'quota_used',
width: 120,
render: (quota: number) => (
<span style={{ fontWeight: 'bold', color: '#1890ff' }}>
{quota.toLocaleString()}
</span>
),
sorter: (a: QuotaHistoryItem, b: QuotaHistoryItem) => a.quota_used - b.quota_used,
},
{
title: '日期',
dataIndex: 'day',
key: 'day',
width: 120,
render: (day: string) => dayjs(day).format('YYYY-MM-DD'),
sorter: (a: QuotaHistoryItem, b: QuotaHistoryItem) =>
dayjs(a.day).unix() - dayjs(b.day).unix(),
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 160,
render: (createdAt: string) => dayjs(createdAt).format('YYYY-MM-DD HH:mm:ss'),
sorter: (a: QuotaHistoryItem, b: QuotaHistoryItem) =>
dayjs(a.created_at).unix() - dayjs(b.created_at).unix(),
},
];
return (
<Card>
<Title level={4} style={{ marginBottom: 16 }}>
Token 使
</Title>
<Table
columns={columns}
dataSource={data}
loading={loading}
rowKey="id"
pagination={{
pageSize: 20,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) =>
`${range[0]}-${range[1]} 条,共 ${total} 条记录`,
}}
scroll={{ x: 1200 }}
size="small"
/>
</Card>
);
};
export default QuotaHistoryTable;

View File

@@ -0,0 +1,82 @@
import React from 'react';
import { Card, Row, Col, Statistic, Typography } from 'antd';
import {
UserOutlined,
ProjectOutlined,
ApiOutlined,
DatabaseOutlined
} from '@ant-design/icons';
const { Title } = Typography;
interface QuotaStatsProps {
totalQuota: number;
totalUsers: number;
totalProjects: number;
totalModels: number;
loading?: boolean;
}
const QuotaStats: React.FC<QuotaStatsProps> = ({
totalQuota,
totalUsers,
totalProjects,
totalModels,
loading = false
}) => {
return (
<div style={{ marginBottom: 24 }}>
<Title level={4} style={{ marginBottom: 16 }}>
Token 使
</Title>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="总 Token 使用量"
value={totalQuota}
precision={2}
loading={loading}
prefix={<DatabaseOutlined />}
suffix="tokens"
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="活跃用户数"
value={totalUsers}
loading={loading}
prefix={<UserOutlined />}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="项目数量"
value={totalProjects}
loading={loading}
prefix={<ProjectOutlined />}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="模型数量"
value={totalModels}
loading={loading}
prefix={<ApiOutlined />}
/>
</Card>
</Col>
</Row>
</div>
);
};
export default QuotaStats;

View File

@@ -0,0 +1,87 @@
import React, { useEffect, useState } from 'react'
import { QuotaApiService } from '../services/api'
import type { PageResponse, UserProjectQuotaItem } from '../types/userProjectQuota'
export default function UserProjectQuotaPage() {
const [items, setItems] = useState<UserProjectQuotaItem[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [size, setSize] = useState(10)
const [userId, setUserId] = useState('')
const [enabled, setEnabled] = useState('')
const [form, setForm] = useState<UserProjectQuotaItem>({ id:0, user_id: '', project_limit: 0, vm_limit: 0, process_limit: 0, enabled: true, description: '' })
const [edit, setEdit] = useState<UserProjectQuotaItem | null>(null)
const load = async () => {
const res: PageResponse<UserProjectQuotaItem> = await QuotaApiService.listUserProjectQuota({ user_id: userId, enabled, page, size })
const list = res?.data?.data || res?.data?.items || []
setItems(Array.isArray(list) ? list : [])
setTotal(res?.data?.total || 0)
}
useEffect(()=>{ load() }, [page, size])
return (
<section className="panel">
<h2></h2>
<div className="desc">//</div>
<div style={{display:'flex', gap:8, alignItems:'center'}}>
<input placeholder="user_id" value={userId} onChange={e=>setUserId(e.target.value)} />
<select value={enabled} onChange={e=>setEnabled(e.target.value)}>
<option value="">enabled=</option>
<option value="true">enabled=true</option>
<option value="false">enabled=false</option>
</select>
<button className="btn" onClick={()=>{ setPage(1); load() }}></button>
</div>
<div className="panel" style={{marginTop:12}}>
<h3>{edit ? '编辑配额' : '创建配额'}</h3>
<div style={{display:'grid', gridTemplateColumns:'repeat(3, 1fr)', gap:8}}>
<input placeholder="user_id" value={edit ? edit.user_id : form.user_id} onChange={e=> edit? setEdit({...edit, user_id:e.target.value}) : setForm({...form, user_id:e.target.value}) } disabled={!!edit} />
<input placeholder="project_limit" type="number" value={edit? edit.project_limit : form.project_limit} onChange={e=> edit? setEdit({...edit, project_limit:Number(e.target.value)}) : setForm({...form, project_limit:Number(e.target.value)}) } />
<input placeholder="vm_limit" type="number" value={edit? edit.vm_limit : form.vm_limit} onChange={e=> edit? setEdit({...edit, vm_limit:Number(e.target.value)}) : setForm({...form, vm_limit:Number(e.target.value)}) } />
<input placeholder="process_limit" type="number" value={edit? edit.process_limit : form.process_limit} onChange={e=> edit? setEdit({...edit, process_limit:Number(e.target.value)}) : setForm({...form, process_limit:Number(e.target.value)}) } />
<label><input type="checkbox" checked={edit? !!edit.enabled : !!form.enabled} onChange={e=> edit? setEdit({...edit, enabled:e.target.checked}) : setForm({...form, enabled:e.target.checked}) } /> enabled</label>
<input placeholder="description" value={edit? (edit.description||'') : form.description} onChange={e=> edit? setEdit({...edit, description:e.target.value}) : setForm({...form, description:e.target.value}) } />
</div>
<div style={{display:'flex', gap:8, marginTop:8}}>
{!edit && (<button className="btn" onClick={async ()=>{ await QuotaApiService.createUserProjectQuota({ user_id:form.user_id, project_limit:form.project_limit, vm_limit:form.vm_limit, process_limit:form.process_limit, enabled:form.enabled, description:form.description }); setForm({ id:0, user_id:'', project_limit:0, vm_limit:0, process_limit:0, enabled:true, description:'' }); load() }}></button>)}
{edit && (<>
<button className="btn primary" onClick={async ()=>{ await QuotaApiService.updateUserProjectQuota(edit.id, { project_limit:edit.project_limit, vm_limit:edit.vm_limit, process_limit:edit.process_limit, enabled:edit.enabled, description:edit.description }); setEdit(null); load() }}></button>
<button className="btn" onClick={()=>setEdit(null)}></button>
</>)}
</div>
</div>
<div className="panel full" style={{marginTop:12}}>
<div className="table-container">
<table className="table">
<thead><tr><th>ID</th><th>user_id</th><th>project_limit</th><th>vm_limit</th><th>process_limit</th><th>enabled</th><th></th></tr></thead>
<tbody>
{items.map((it)=> (
<tr key={it.id}>
<td>{it.id}</td>
<td>{it.user_id}</td>
<td>{it.project_limit}</td>
<td>{it.vm_limit}</td>
<td>{it.process_limit}</td>
<td>{String(it.enabled)}</td>
<td>
<button className="btn" onClick={()=>setEdit({...it})}></button>
<button className="btn" onClick={async ()=>{ await QuotaApiService.deleteUserProjectQuota(it.id); load() }}></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</section>
)
}

415
src/hooks/useAuth.ts Normal file
View File

@@ -0,0 +1,415 @@
// import { message } from 'antd'; // 暂时注释掉,未使用
import { useAtom } from 'jotai';
import { useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSearchParams } from 'react-router-dom';
import { authStateAtom, authLoadingAtom } from '../atoms/auth';
import { SSOApiService } from '../services/api';
import type { AuthState } from '../atoms/auth';
export const useAuth = () => {
const [authState, setAuthState] = useAtom(authStateAtom);
const [loading, setLoading] = useAtom(authLoadingAtom);
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const refreshTimerRef = useRef<number | null>(null);
// 公共的token更新和定时器设置逻辑
const updateTokenAndSetTimer = (
tokenData: {
access_token: string;
refresh_token?: string;
expires_in?: number;
}
) => {
const { access_token, refresh_token, expires_in } = tokenData;
// 更新本地存储
localStorage.setItem('token', access_token);
if (refresh_token) {
localStorage.setItem('refreshToken', refresh_token);
}
// 更新状态
const updatedState: Partial<AuthState> = {
token: access_token,
};
if (refresh_token) {
updatedState.refreshToken = refresh_token;
}
setAuthState(prev => ({
...prev,
...updatedState,
}));
// 清除旧的定时器并设置新的定时器
if (refreshTimerRef.current) {
clearTimeout(refreshTimerRef.current);
}
if (expires_in) {
// 提前2分钟触发主动刷新
refreshTimerRef.current = setTimeout(
refreshToken,
expires_in * 1000 - 2 * 60000
);
}
};
// 跳转到登录页面若无URL则先请求再跳转
const redirectToLogin = async () => {
console.log('🔄 [Redirect] 开始跳转登录流程');
let loginUrl = authState.loginUrl;
console.log('🔍 [Redirect] 当前loginUrl:', loginUrl);
if (!loginUrl) {
console.log('🚀 [Redirect] 无登录URL获取新的登录URL');
try {
const resp = await SSOApiService.initiateLogin();
console.log('🚀 [Redirect] 登录URL响应:', {
success: resp?.success,
hasAuthUrl: !!resp?.auth_url,
authUrl: resp?.auth_url
});
if (resp?.auth_url) {
loginUrl = resp.auth_url;
setAuthState(prev => ({ ...prev, loginUrl }));
}
} catch (e) {
console.error('❌ [Redirect] 获取登录URL失败:', e);
}
}
if (loginUrl && loginUrl.startsWith('http')) {
console.log('🔄 [Redirect] 跳转到SSO登录页面:', loginUrl);
window.location.href = loginUrl;
} else {
console.error('❌ [Redirect] 无效的登录URL:', loginUrl);
}
};
// token 刷新
const refreshToken = async () => {
if (!authState.refreshToken) {
console.warn('No refresh token available');
return;
}
try {
const response = await SSOApiService.refreshToken(authState.refreshToken);
if (response?.access_token) {
updateTokenAndSetTimer({
access_token: response.access_token,
refresh_token: response.refresh_token,
expires_in: response.expires_in,
});
}
} catch (error) {
console.error('Token refresh failed:', error);
// 刷新失败,重新登录
logout();
}
};
// SSO登录
const ssoLogin = async (code?: string, state?: string) => {
try {
console.log('🔐 [SSO] 开始SSO登录流程', { hasCode: !!code, hasState: !!state });
setLoading(true);
if (code && state) {
console.log('📞 [SSO] 处理SSO回调调用 /api/sso/callback');
// 有code和state进行回调处理
const response = await SSOApiService.callback(code, state);
console.log('📞 [SSO] 回调响应:', {
success: response?.success,
hasToken: !!response?.access_token,
hasUserInfo: !!response?.user_info
});
if (response?.access_token) {
console.log('🔑 [SSO] 获取到access_token更新本地状态');
updateTokenAndSetTimer({
access_token: response.access_token,
refresh_token: response.refresh_token,
expires_in: response.expires_in,
});
// 获取用户信息
if (response.user_info) {
console.log('👤 [SSO] 回调中包含用户信息,直接设置');
setAuthState(prev => ({
...prev,
user: response.user_info || null,
isAuthenticated: true,
}));
localStorage.setItem('user', JSON.stringify(response.user_info));
// 角色判断:若非 sys_admin跳转无权限
try {
const rolesRaw: any = (response as any)?.user_info?.roles || [];
const roleNames: string[] = Array.isArray(rolesRaw)
? rolesRaw.map((r: any) => (typeof r === 'string' ? r : r?.name)).filter(Boolean)
: [];
const isSysAdmin = roleNames.includes('sys_admin');
console.log('🔎 [SSO] 角色检测:', { roleNames, isSysAdmin });
if (!isSysAdmin) {
console.warn('🚫 [Permission] 非 sys_admin跳转 /no-permission');
navigate('/no-permission', { replace: true });
}
} catch (e) {
console.warn('⚠️ [SSO] 角色解析失败:', e);
}
} else {
console.log('👤 [SSO] 回调中无用户信息,调用 /api/sso/userinfo');
await getCurrentUser();
}
}
} else {
console.log('🚀 [SSO] 获取登录URL调用 /api/sso/login');
// 无code和state获取登录URL
const response = await SSOApiService.initiateLogin();
console.log('🚀 [SSO] 登录URL响应:', {
success: response?.success,
hasAuthUrl: !!response?.auth_url,
authUrl: response?.auth_url
});
if (response?.auth_url) {
setAuthState(prev => ({
...prev,
loginUrl: response.auth_url,
}));
}
}
} catch (error) {
console.error('❌ [SSO] SSO登录失败:', error);
throw error;
} finally {
setLoading(false);
}
};
// 获取当前用户信息
const getCurrentUser = async () => {
try {
setLoading(true);
const response = await SSOApiService.userInfo();
if (response?.user_info) {
setAuthState(prev => ({
...prev,
user: response.user_info,
isAuthenticated: true,
}));
localStorage.setItem('user', JSON.stringify(response.user_info));
// 角色判断:若非 sys_admin跳转无权限
try {
const rolesRaw: any = (response as any)?.user_info?.roles || [];
const roleNames: string[] = Array.isArray(rolesRaw)
? rolesRaw.map((r: any) => (typeof r === 'string' ? r : r?.name)).filter(Boolean)
: [];
const isSysAdmin = roleNames.includes('sys_admin');
console.log('🔎 [Auth] 角色检测:', { roleNames, isSysAdmin });
if (!isSysAdmin) {
console.warn('🚫 [Permission] 非 sys_admin跳转 /no-permission');
navigate('/no-permission', { replace: true });
}
} catch (e) {
console.warn('⚠️ [Auth] 角色解析失败:', e);
}
}
} catch (error) {
console.error('Get current user failed:', error);
throw error;
} finally {
setLoading(false);
}
};
// 初始化认证状态
const initAuth = async () => {
try {
console.log('🔐 [Auth] 开始初始化认证状态');
setLoading(true);
// 检查URL中的code参数
const code = searchParams.get('code');
const state = searchParams.get('state');
console.log('🔍 [Auth] 检查URL参数:', { code: !!code, state: !!state });
if (code && state) {
console.log('📞 [Auth] 检测到SSO回调参数开始处理回调');
// 如果有code参数进行单点登录
try {
await ssoLogin(code, state);
setSearchParams({});
console.log('✅ [Auth] SSO回调处理完成');
} catch (error) {
console.error('❌ [Auth] SSO回调处理失败:', error);
}
return;
}
// 检查本地存储的token
const token = localStorage.getItem('token');
const refreshToken = localStorage.getItem('refreshToken');
const userStr = localStorage.getItem('user');
console.log('💾 [Auth] 检查本地存储:', {
hasToken: !!token,
hasRefreshToken: !!refreshToken,
hasUser: !!userStr
});
if (token && userStr) {
console.log('🔑 [Auth] 发现本地token开始验证有效性');
try {
const user = JSON.parse(userStr);
setAuthState(prev => ({
...prev,
user,
token,
refreshToken,
isAuthenticated: true, // 先标记为已认证避免触发SSO登录
}));
// 在后台验证token是否有效不阻塞用户使用
getCurrentUser().then(() => {
console.log('✅ [Auth] 后台Token验证成功');
}).catch(error => {
console.warn('⚠️ [Auth] 后台Token验证失败但不影响用户使用:', error);
// 可以选择是否要处理验证失败的情况
});
console.log('✅ [Auth] 本地token验证成功用户已认证');
return;
} catch (error) {
console.error('❌ [Auth] 用户数据解析失败:', error);
}
}
// 如果没有code也没有有效token请求登录URL
console.log('🚀 [Auth] 无有效认证信息开始获取登录URL');
await ssoLogin();
} catch (error) {
console.error('❌ [Auth] 初始化认证失败:', error);
} finally {
setLoading(false);
}
};
// 登出
const logout = async () => {
try {
console.log('🚪 [Logout] 开始登出流程');
// 调用后端登出接口获取登出URL
let logoutUrl = null;
if (authState.token) {
try {
console.log('📞 [Logout] 调用后端登出接口 /api/sso/logout');
const res = await SSOApiService.logout();
console.log('📞 [Logout] 后端登出响应:', {
success: res?.success,
message: res?.message
});
// 检查返回的message是否为有效URL
if (res?.message && res.message.startsWith('http')) {
logoutUrl = res.message;
console.log('✅ [Logout] 使用后端返回的登出URL:', logoutUrl);
} else {
console.warn('⚠️ [Logout] 后端返回的message不是有效URL:', res.message);
}
} catch (error) {
console.warn('⚠️ [Logout] 后端登出失败,但继续清理本地状态:', error);
}
}
// 清理本地状态(必须)
console.log('🧹 [Logout] 清理本地状态');
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
localStorage.removeItem('user');
// 清理定时器
if (refreshTimerRef.current) {
clearTimeout(refreshTimerRef.current);
}
// 使用后端返回的登出URL如果没有则使用默认的
if (logoutUrl) {
console.log('✅ [Logout] 登出完成跳转到SSO登出页面');
console.log('🔄 [Logout] 登出URL:', logoutUrl);
window.location.href = logoutUrl;
} else {
console.log('✅ [Logout] 登出完成,跳转到当前域名');
// 如果没有有效的登出URL跳转到当前域名让应用重新走认证流程
window.location.href = window.location.origin;
}
} catch (error) {
console.error('❌ [Logout] 登出失败:', error);
// 即使登出失败,也要清理本地状态
console.log('🧹 [Logout] 清理本地状态(异常情况)');
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
localStorage.removeItem('user');
setAuthState({
user: null,
token: null,
refreshToken: null,
isAuthenticated: false,
loginUrl: null,
});
// 跳转到登录页面
console.log('🔄 [Logout] 异常情况,跳转到登录页面');
await redirectToLogin();
}
};
// 处理业务接口被动触发的token刷新和重新登录问题
useEffect(() => {
const handleTokenRefreshed = (event: CustomEvent) => {
const { access_token, refresh_token, expires_in } = event.detail;
updateTokenAndSetTimer({
access_token,
refresh_token,
expires_in,
});
};
const handleRelogin = () => {
logout();
};
window.addEventListener('auth:tokenRefreshed', handleTokenRefreshed as EventListener);
window.addEventListener('auth:relogin', handleRelogin);
return () => {
window.removeEventListener('auth:tokenRefreshed', handleTokenRefreshed as EventListener);
window.removeEventListener('auth:relogin', handleRelogin);
};
}, []);
// 组件卸载时清理定时器
useEffect(() => {
return () => {
if (refreshTimerRef.current) {
clearTimeout(refreshTimerRef.current);
}
};
}, []);
return {
...authState,
loading,
initAuth,
redirectToLogin,
refreshToken,
getCurrentUser,
logout,
};
};

View File

@@ -0,0 +1,74 @@
import { useAtom } from 'jotai';
import { authStateAtom } from '../atoms/auth';
export const usePagePermissions = () => {
const [authState] = useAtom(authStateAtom);
const hasPagePermission = (pagePath: string, action: string = 'read'): boolean => {
console.log(`🔐 [usePagePermissions] 检查页面权限 "${pagePath}:${action}"`);
console.log(`🔐 [usePagePermissions] 用户数据:`, authState.user);
// 检查用户是否有页面数据
if (!authState.user?.pages) {
console.log(`🔐 [usePagePermissions] 用户页面数据为空:`, authState.user);
return false;
}
// 根据页面路径和激活状态检查权限
const hasAccess = authState.user.pages.some(page =>
page.path === pagePath && page.is_active !== false
);
console.log(`🔐 [usePagePermissions] 页面权限检查结果: ${hasAccess}, 用户可访问页面:`,
authState.user.pages.map(p => ({ path: p.path, isActive: p.is_active })));
return hasAccess;
};
const hasPageAction = (pagePath: string, action: string): boolean => {
return hasPagePermission(pagePath, action);
};
const getPageActions = (pagePath: string): string[] => {
console.log(`🔐 [usePagePermissions] 获取页面操作权限 "${pagePath}"`);
if (!authState.user?.pages) {
console.log(`🔐 [usePagePermissions] 用户页面数据为空`);
return [];
}
// 检查用户是否有该页面的访问权限
const hasAccess = authState.user.pages.some(page => page.path === pagePath);
if (!hasAccess) {
console.log(`🔐 [usePagePermissions] 用户无该页面访问权限`);
return [];
}
// 简化版本:如果有页面访问权限,返回基本操作
return ['read', 'create', 'update', 'delete'];
};
const getAccessiblePages = (): string[] => {
console.log(`🔐 [usePagePermissions] 获取用户可访问页面`);
console.log(`🔐 [usePagePermissions] 用户数据:`, authState.user);
if (!authState.user?.pages) {
console.log(`🔐 [usePagePermissions] 用户页面数据为空`);
return [];
}
// 只返回激活状态的页面路径
const pages = authState.user.pages
.filter(page => page.is_active !== false)
.map(page => page.path);
console.log(`🔐 [usePagePermissions] 用户可访问页面:`, pages);
return pages;
};
return {
hasPagePermission,
hasPageAction,
getPageActions,
getAccessiblePages
};
};

View File

@@ -0,0 +1,46 @@
import { useAtom } from 'jotai';
import { authStateAtom } from '../atoms/auth';
export const usePermissions = () => {
const [authState] = useAtom(authStateAtom);
const hasPermission = (permission: string): boolean => {
if (!authState.user?.permissions) {
console.log(`🔐 [usePermissions] 用户权限数据为空:`, authState.user);
return false;
}
const hasAccess = authState.user.permissions.includes(permission);
console.log(`🔐 [usePermissions] 检查权限 "${permission}": ${hasAccess}, 用户权限:`, authState.user.permissions);
return hasAccess;
};
const hasAnyPermission = (permissions: string[]): boolean => {
return permissions.some(permission => hasPermission(permission));
};
const hasAllPermissions = (permissions: string[]): boolean => {
return permissions.every(permission => hasPermission(permission));
};
const hasRole = (roleName: string): boolean => {
if (!authState.user?.roles) return false;
return authState.user.roles.includes(roleName);
};
const hasAnyRole = (roleNames: string[]): boolean => {
return roleNames.some(roleName => hasRole(roleName));
};
const hasAllRoles = (roleNames: string[]): boolean => {
return roleNames.every(roleName => hasRole(roleName));
};
return {
hasPermission,
hasAnyPermission,
hasAllPermissions,
hasRole,
hasAnyRole,
hasAllRoles
};
};

1
src/index.css Normal file
View File

@@ -0,0 +1 @@
/* 基础样式重置 - 不干扰 App.css 的设计 */

28
src/main.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Provider } from 'jotai'
import { BrowserRouter } from 'react-router-dom'
import './App.css'
import 'antd/dist/reset.css'
import App from './App.tsx'
import AuthGuard from './components/AuthGuard'
import { migrateStorage } from './utils/storageMigration'
// 执行存储迁移
migrateStorage();
const rootElement = document.getElementById('root')
if (!rootElement) throw new Error('Failed to find the root element')
const root = createRoot(rootElement)
root.render(
<StrictMode>
<Provider>
<BrowserRouter>
<AuthGuard>
<App />
</AuthGuard>
</BrowserRouter>
</Provider>
</StrictMode>
)

315
src/pages/AuditLogs.tsx Normal file
View File

@@ -0,0 +1,315 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Card, Table, Tag, Form, Select, Input, DatePicker, Button, Space, Typography, Tooltip, message, Row, Col } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import dayjs, { Dayjs } from 'dayjs';
import { AuditLogApiService } from '../services/auditLogApi';
import type { AuditLog, AuditLogListRequest } from '../types/auditLog';
const { RangePicker } = DatePicker;
const { Text } = Typography;
const OPERATION_TYPE_LABELS: Record<string, string> = {
balance_adjustment: '余额调整',
user_level_change: '用户等级修改',
user_status_change: '用户状态变更',
model_price_change: '模型价格修改',
permission_change: '权限变更',
config_change: '配置修改',
};
const STATUS_TAG_PROPS: Record<string, { color: string; text: string }> = {
success: { color: 'green', text: '成功' },
failed: { color: 'red', text: '失败' },
};
interface FilterValues {
operation_type?: string;
operator_email?: string;
target_email?: string;
time_range?: [Dayjs, Dayjs];
}
const formatKey = (key: string) => key.replace(/_/g, ' ');
const renderDetailValue = (value: any): React.ReactNode => {
if (value === null || value === undefined) {
return '-';
}
if (typeof value === 'object') {
return JSON.stringify(value);
}
if (typeof value === 'number') {
return value.toLocaleString();
}
return String(value);
};
const renderOperationDetails = (details?: Record<string, any> | null) => {
if (!details || Object.keys(details).length === 0) {
return <Text type="secondary">-</Text>;
}
return (
<div style={{ lineHeight: 1.6 }}>
{Object.entries(details).map(([key, value]) => (
<div key={key}>
<Text strong>{formatKey(key)}:</Text> <Text>{renderDetailValue(value)}</Text>
</div>
))}
</div>
);
};
const AuditLogs: React.FC = () => {
const [form] = Form.useForm<FilterValues>();
const [loading, setLoading] = useState(false);
const [logs, setLogs] = useState<AuditLog[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [filters, setFilters] = useState<AuditLogListRequest>({});
const fetchLogs = useCallback(async () => {
setLoading(true);
try {
const params: AuditLogListRequest = {
...filters,
page,
size: pageSize,
};
const data = await AuditLogApiService.getAuditLogs(params);
setLogs(data.list ?? []);
setTotal(data.total ?? 0);
if (data.page) setPage(data.page);
if (data.size) setPageSize(data.size);
} catch (error: any) {
console.error('获取审计日志失败', error);
message.error(error?.response?.data?.message || '获取审计日志失败');
} finally {
setLoading(false);
}
}, [filters, page, pageSize]);
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
const handleSearch = useCallback(() => {
form
.validateFields()
.then(values => {
const nextFilters: AuditLogListRequest = {};
if (values.operation_type) {
nextFilters.operation_type = values.operation_type;
}
if (values.operator_email) {
nextFilters.operator_email = values.operator_email.trim();
}
if (values.target_email) {
nextFilters.target_email = values.target_email.trim();
}
if (values.time_range && values.time_range.length === 2) {
nextFilters.start_time = values.time_range[0].format('YYYY-MM-DD HH:mm:ss');
nextFilters.end_time = values.time_range[1].format('YYYY-MM-DD HH:mm:ss');
}
setPage(1);
setFilters(nextFilters);
})
.catch(() => {
/* ignore validation errors */
});
}, [form]);
const handleReset = useCallback(() => {
form.resetFields();
setPage(1);
setPageSize(20);
setFilters({});
}, [form]);
const columns: ColumnsType<AuditLog> = useMemo(() => [
{
title: '操作时间',
dataIndex: 'operation_time',
key: 'operation_time',
width: 180,
render: (value: string) => {
const formatted = value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-';
return <Text>{formatted}</Text>;
},
sorter: false,
},
{
title: '操作类型',
dataIndex: 'operation_type',
key: 'operation_type',
width: 140,
render: (value: string) => {
const label = OPERATION_TYPE_LABELS[value] || value || '-';
return <Tag color="blue">{label}</Tag>;
},
},
{
title: '操作对象',
dataIndex: 'target_email',
key: 'target_email',
width: 200,
render: (value: string | null, record) => (
<div>
<div>{value || '-'}</div>
{record.target_type && (
<Text type="secondary" style={{ fontSize: 12 }}>
{record.target_type}{record.target_id ? ` #${record.target_id}` : ''}
</Text>
)}
</div>
),
},
{
title: '操作详情',
dataIndex: 'operation_details',
key: 'operation_details',
width: 260,
render: (details: Record<string, any> | null) => renderOperationDetails(details),
},
{
title: '操作人',
dataIndex: 'operator_email',
key: 'operator_email',
width: 200,
render: (value: string, record) => (
<div>
<div>{value || '-'}</div>
<Text type="secondary" style={{ fontSize: 12 }}>ID: {record.operator_id ?? '-'}</Text>
</div>
),
},
{
title: 'IP / UA',
dataIndex: 'ip_address',
key: 'ip_address',
width: 220,
render: (value: string | null, record) => (
<div>
<div>{value || '-'}</div>
{record.user_agent && (
<Tooltip title={record.user_agent} placement="topLeft">
<Text type="secondary" ellipsis style={{ maxWidth: 200, display: 'inline-block' }}>
{record.user_agent}
</Text>
</Tooltip>
)}
</div>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (value: string | null, record) => {
const statusKey = value ? value.toLowerCase() : 'success';
const tagProps = STATUS_TAG_PROPS[statusKey] || { color: 'default', text: value || '未知' };
return (
<Space direction="vertical" size={4}>
<Tag color={tagProps.color}>{tagProps.text}</Tag>
{record.error_message && (
<Tooltip title={record.error_message}>
<Text type="danger" ellipsis style={{ maxWidth: 180 }}>
{record.error_message}
</Text>
</Tooltip>
)}
</Space>
);
},
},
], []);
return (
<div style={{ padding: 24 }}>
<Card bordered={false} style={{ marginBottom: 16 }}>
<Form
form={form}
layout="vertical"
initialValues={{
operation_type: undefined,
operator_email: '',
target_email: '',
time_range: undefined,
}}
>
<Row gutter={16}>
<Col span={6}>
<Form.Item label="操作类型" name="operation_type">
<Select
placeholder="请选择操作类型"
allowClear
options={Object.entries(OPERATION_TYPE_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item label="操作人邮箱" name="operator_email">
<Input placeholder="请输入操作人邮箱" allowClear />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item label="操作对象邮箱" name="target_email">
<Input placeholder="请输入操作对象邮箱" allowClear />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item label="时间范围" name="time_range">
<RangePicker
style={{ width: '100%' }}
showTime
allowEmpty={[true, true]}
format="YYYY-MM-DD HH:mm:ss"
ranges={{
: [dayjs().startOf('day'), dayjs().endOf('day')],
7: [dayjs().subtract(6, 'day').startOf('day'), dayjs().endOf('day')],
30: [dayjs().subtract(29, 'day').startOf('day'), dayjs().endOf('day')],
}}
/>
</Form.Item>
</Col>
</Row>
<Row>
<Col span={24}>
<Space>
<Button type="primary" onClick={handleSearch}>
</Button>
<Button onClick={handleReset}></Button>
</Space>
</Col>
</Row>
</Form>
</Card>
<Card bordered={false}>
<Table<AuditLog>
rowKey="id"
loading={loading}
columns={columns}
dataSource={logs}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: totalValue => `${totalValue} 条记录`,
onChange: (nextPage, nextSize) => {
setPage(nextPage);
setPageSize(nextSize || 20);
},
}}
scroll={{ x: 1200 }}
/>
</Card>
</div>
);
};
export default AuditLogs;

42
src/pages/Dashboard.tsx Normal file
View File

@@ -0,0 +1,42 @@
import React from 'react';
const Dashboard: React.FC = () => {
return (
<section className="panel">
<h2></h2>
<div className="desc"></div>
<div className="kpis">
<div className="kpi">
<h3></h3>
<div className="num">1,234</div>
</div>
<div className="kpi">
<h3></h3>
<div className="num">567</div>
</div>
<div className="kpi">
<h3>Token消耗</h3>
<div className="num">12.5M</div>
</div>
<div className="kpi">
<h3></h3>
<div className="num" style={{color: 'var(--success)'}}></div>
</div>
</div>
<div className="flex-2">
<div className="panel">
<h2>7</h2>
<div className="chart"></div>
</div>
<div className="panel">
<h2>使</h2>
<div className="chart">使</div>
</div>
</div>
</section>
);
};
export default Dashboard;

47
src/pages/Finance.tsx Normal file
View File

@@ -0,0 +1,47 @@
import React from 'react';
const Finance: React.FC = () => {
return (
<section className="panel">
<h2></h2>
<div className="desc"></div>
<div className="kpis">
<div className="kpi">
<h3></h3>
<div className="num">¥123,456</div>
</div>
<div className="kpi">
<h3></h3>
<div className="num">¥78,901</div>
</div>
<div className="kpi">
<h3></h3>
<div className="num">¥44,555</div>
</div>
<div className="kpi">
<h3></h3>
<div className="num">36.1%</div>
</div>
</div>
<div className="flex-2">
<div className="panel">
<h2></h2>
<div className="chart"></div>
</div>
<div className="panel">
<h2></h2>
<div className="chart"></div>
</div>
</div>
<div className="panel">
<h2></h2>
<div className="chart"></div>
</div>
</section>
);
};
export default Finance;

View File

@@ -0,0 +1,544 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Card, Table, Typography, Form, Input, DatePicker, Button, Space, Select, Modal, InputNumber, message, Popconfirm, Tabs, Drawer } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { FinanceApiService } from '../services/api';
import dayjs from 'dayjs';
const { TabPane } = Tabs;
const FinanceMcpAccountRechargeRecords: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [size, setSize] = useState(20);
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const [modalVisible, setModalVisible] = useState(false);
const [modalForm] = Form.useForm();
const [providerAccounts, setProviderAccounts] = useState<any[]>([]);
const [loadingAccounts, setLoadingAccounts] = useState(false);
const [activeTab, setActiveTab] = useState('recharge-records');
const [balances, setBalances] = useState<any[]>([]);
const [loadingBalances, setLoadingBalances] = useState(false);
const [balanceHistoryVisible, setBalanceHistoryVisible] = useState(false);
const [balanceHistory, setBalanceHistory] = useState<any[]>([]);
const [loadingHistory, setLoadingHistory] = useState(false);
const [balanceModalVisible, setBalanceModalVisible] = useState(false);
const [balanceModalMode, setBalanceModalMode] = useState<'create' | 'edit'>('create');
const [editingBalanceRecord, setEditingBalanceRecord] = useState<any>(null);
const [balanceForm] = Form.useForm();
const [updatingBalance, setUpdatingBalance] = useState(false);
const buildParams = (p = page, s = size) => {
const vals = form.getFieldsValue();
const range = vals.range as any[] | undefined;
const start = range?.[0]?.format?.('YYYY-MM-DD') || undefined;
const end = range?.[1]?.format?.('YYYY-MM-DD') || undefined;
return {
page: p,
size: s,
provider: vals.provider,
account: vals.account,
start,
end,
} as any;
};
const fetchList = useCallback(async (p = page, s = size) => {
setLoading(true);
try {
const res = await FinanceApiService.listMcpAccountRechargeRecords(buildParams(p, s));
setData(res?.data || res?.list || []);
setTotal(res?.total || 0);
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || '获取数据失败');
} finally {
setLoading(false);
}
}, [form, message, page, size]);
const fetchProviderAccounts = useCallback(async () => {
setLoadingAccounts(true);
try {
const res = await FinanceApiService.getMcpProviderAccounts({ status: 'active', is_used: true });
const accounts = res?.data || res || [];
setProviderAccounts(accounts);
} catch (e: any) {
message.error('获取账号列表失败');
} finally {
setLoadingAccounts(false);
}
}, []);
const fetchBalances = useCallback(async () => {
setLoadingBalances(true);
try {
const res = await FinanceApiService.getMcpAccountBalances();
const balanceList = res?.data || res || [];
setBalances(balanceList);
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || '获取余额数据失败');
} finally {
setLoadingBalances(false);
}
}, []);
const fetchBalanceHistory = useCallback(async (providerId: string) => {
setLoadingHistory(true);
try {
const res = await FinanceApiService.getMcpAccountBalanceHistory(providerId);
const history = res?.data || res || [];
setBalanceHistory(history);
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || '获取余额历史失败');
} finally {
setLoadingHistory(false);
}
}, []);
useEffect(() => {
fetchProviderAccounts();
}, [fetchProviderAccounts]);
useEffect(() => {
if (activeTab === 'recharge-records') {
fetchList();
} else if (activeTab === 'balances') {
fetchBalances();
}
}, [activeTab, fetchBalances, fetchList]);
useEffect(() => {
if (activeTab === 'recharge-records') {
fetchList(page, size);
}
}, [activeTab, page, size, fetchList]);
const handleAdd = () => {
modalForm.resetFields();
modalForm.setFieldsValue({
recharge_date: dayjs(),
});
setModalVisible(true);
};
const handleDelete = async (id: string) => {
try {
await FinanceApiService.deleteMcpAccountRechargeRecord(id);
message.success('删除成功');
fetchList(page, size);
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || '删除失败');
}
};
const handleModalOk = async () => {
try {
const values = await modalForm.validateFields();
const payload = {
provider_id: values.provider_id,
amount: values.amount,
recharge_date: values.recharge_date.format('YYYY-MM-DD'),
remark: values.remark || '',
};
await FinanceApiService.createMcpAccountRechargeRecord(payload);
message.success('创建成功');
setModalVisible(false);
fetchList(page, size);
if (activeTab === 'balances') {
fetchBalances();
}
} catch (e: any) {
if (e?.errorFields) {
// 表单验证错误,不显示错误消息
return;
}
message.error(e?.response?.data?.message || e?.message || '操作失败');
}
};
const handleModalCancel = () => {
setModalVisible(false);
modalForm.resetFields();
};
const columns: ColumnsType<any> = useMemo(() => [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 100, ellipsis: true },
{ title: '服务商', dataIndex: 'provider', key: 'provider', width: 150 },
{ title: '账号', dataIndex: 'account', key: 'account', width: 200 },
{
title: '充值金额',
dataIndex: 'amount',
key: 'amount',
width: 120,
render: (amount: number, record: any) => {
if (amount === null || amount === undefined) return '-';
const currency = record.currency || 'USD';
return `$${amount.toFixed(2)} ${currency}`;
},
},
{
title: '充值日期',
dataIndex: 'recharge_date',
key: 'recharge_date',
width: 120,
},
{ title: '操作人', dataIndex: 'operator_name', key: 'operator_name', width: 120 },
{ title: '备注', dataIndex: 'remark', key: 'remark', width: 200, ellipsis: true },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
{
title: '操作',
key: 'actions',
fixed: 'right',
width: 120,
render: (_: any, record: any) => (
<Popconfirm
title="确认删除这条记录?"
onConfirm={() => handleDelete(record.id)}
okText="确认"
cancelText="取消"
>
<Button type="link" danger size="small">
</Button>
</Popconfirm>
),
},
], []);
const accountOptions = useMemo(() => {
return providerAccounts.map((acc: any) => {
const label = acc.account ? `${acc.provider} - ${acc.account}` : acc.provider;
return {
label,
value: acc.id,
provider: acc.provider,
account: acc.account,
};
});
}, [providerAccounts]);
const handleViewBalanceHistory = useCallback((providerId: string) => {
setBalanceHistoryVisible(true);
fetchBalanceHistory(providerId);
}, [fetchBalanceHistory]);
const balanceColumns: ColumnsType<any> = useMemo(() => [
{ title: '服务商', dataIndex: 'provider', key: 'provider', width: 150 },
{ title: '账号', dataIndex: 'account', key: 'account', width: 200 },
{
title: '当前余额',
dataIndex: 'balance',
key: 'balance',
width: 150,
render: (balance: number, record: any) => {
if (balance === null || balance === undefined) return '-';
const currency = record.currency || 'USD';
return `$${balance.toFixed(2)} ${currency}`;
},
},
{
title: '更新时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180,
},
{ title: '备注', dataIndex: 'remark', key: 'remark', width: 220, ellipsis: true },
{
title: '操作',
key: 'actions',
fixed: 'right',
width: 200,
render: (_: any, record: any) => (
<Space>
<Button type="link" size="small" onClick={() => handleViewBalanceHistory(record.provider_id)}>
</Button>
<Button
type="link"
size="small"
onClick={() => {
setBalanceModalMode('edit');
setEditingBalanceRecord(record);
balanceForm.setFieldsValue({
provider_id: record.provider_id,
balance: record.balance ?? 0,
remark: '',
});
setBalanceModalVisible(true);
}}
>
</Button>
</Space>
),
},
], [balanceForm, handleViewBalanceHistory]);
const balanceHistoryColumns: ColumnsType<any> = useMemo(() => [
{
title: '余额',
dataIndex: 'balance',
key: 'balance',
width: 150,
render: (balance: number, record: any) => {
if (balance === null || balance === undefined) return '-';
const currency = record.currency || 'USD';
return `$${balance.toFixed(2)} ${currency}`;
},
},
{ title: '备注', dataIndex: 'remark', key: 'remark', width: 220, ellipsis: true },
{ title: '更新时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
], []);
return (
<>
<Card
bordered={false}
title={<Typography.Title level={4} style={{ margin: 0 }}>MCP账号管理</Typography.Title>}
>
<Tabs activeKey={activeTab} onChange={setActiveTab}>
<TabPane tab="充值记录" key="recharge-records">
<Space style={{ marginBottom: 16 }}>
<Form form={form} onFinish={() => fetchList(1, size)} layout="inline">
<Form.Item name="provider" label="服务商">
<Input allowClear placeholder="服务商" style={{ width: 200 }} />
</Form.Item>
<Form.Item name="account" label="账号">
<Input allowClear placeholder="账号" style={{ width: 200 }} />
</Form.Item>
<Form.Item name="range" label="充值日期范围">
<DatePicker.RangePicker />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" onClick={() => { setPage(1); }}>
</Button>
<Button
onClick={() => {
form.resetFields();
fetchList(1, size);
setPage(1);
}}
>
</Button>
</Space>
</Form.Item>
</Form>
<Button type="primary" onClick={handleAdd}>
</Button>
</Space>
<Table
rowKey={(r) => r.id}
columns={columns}
dataSource={data}
loading={loading}
pagination={{
current: page,
pageSize: size,
total,
onChange: (p, s) => {
setPage(p);
setSize(s);
fetchList(p, s);
},
}}
scroll={{ x: 1400 }}
/>
</TabPane>
<TabPane tab="账户余额" key="balances">
<Space style={{ marginBottom: 16 }}>
<Button onClick={fetchBalances}></Button>
<Button
type="primary"
onClick={() => {
balanceForm.resetFields();
setBalanceModalMode('create');
setEditingBalanceRecord(null);
setBalanceModalVisible(true);
}}
>
</Button>
</Space>
<Table
rowKey={(r) => r.provider_id || r.id}
columns={balanceColumns}
dataSource={balances}
loading={loadingBalances}
scroll={{ x: 1000 }}
/>
</TabPane>
</Tabs>
</Card>
<Drawer
title="余额历史"
placement="right"
width={800}
open={balanceHistoryVisible}
onClose={() => {
setBalanceHistoryVisible(false);
setBalanceHistory([]);
}}
>
<Table
rowKey={(r) => r.id}
columns={balanceHistoryColumns}
dataSource={balanceHistory}
loading={loadingHistory}
pagination={false}
scroll={{ x: 800 }}
/>
</Drawer>
<Modal
title="添加充值记录"
open={modalVisible}
onOk={handleModalOk}
onCancel={handleModalCancel}
width={600}
>
<Form form={modalForm} layout="vertical">
<Form.Item
name="provider_id"
label="账号"
rules={[{ required: true, message: '请选择账号' }]}
>
<Select
placeholder="请选择账号"
loading={loadingAccounts}
showSearch
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
options={accountOptions}
/>
</Form.Item>
<Form.Item
name="amount"
label="充值金额(美元)"
rules={[
{ required: true, message: '请输入充值金额' },
{ type: 'number', min: 0.01, message: '金额必须大于0' },
]}
>
<InputNumber
style={{ width: '100%' }}
placeholder="请输入充值金额"
precision={2}
min={0.01}
step={0.01}
/>
</Form.Item>
<Form.Item
name="recharge_date"
label="充值日期"
rules={[{ required: true, message: '请选择充值日期' }]}
>
<DatePicker
style={{ width: '100%' }}
format="YYYY-MM-DD"
disabledDate={(current) => current && current > dayjs().endOf('day')}
/>
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={3} placeholder="请输入备注(可选)" />
</Form.Item>
</Form>
</Modal>
<Modal
title={balanceModalMode === 'create' ? '新增余额记录' : '调整余额'}
open={balanceModalVisible}
confirmLoading={updatingBalance}
onOk={async () => {
try {
const values = await balanceForm.validateFields();
setUpdatingBalance(true);
if (balanceModalMode === 'create') {
await FinanceApiService.createMcpAccountBalance({
provider_id: values.provider_id,
balance: values.balance,
remark: values.remark || '',
});
message.success('新增成功');
} else if (editingBalanceRecord) {
const providerId = editingBalanceRecord.provider_id || editingBalanceRecord.providerId;
await FinanceApiService.adjustMcpAccountBalance(providerId, {
balance: values.balance,
remark: values.remark || '',
});
message.success('调整成功');
}
setBalanceModalVisible(false);
balanceForm.resetFields();
setEditingBalanceRecord(null);
setBalanceModalMode('create');
fetchBalances();
} catch (e: any) {
if (e?.errorFields) {
return;
}
message.error(e?.response?.data?.message || e?.message || '操作失败');
} finally {
setUpdatingBalance(false);
}
}}
onCancel={() => {
setBalanceModalVisible(false);
balanceForm.resetFields();
setEditingBalanceRecord(null);
setBalanceModalMode('create');
}}
width={520}
destroyOnClose
>
<Form form={balanceForm} layout="vertical">
<Form.Item
name="provider_id"
label="账号"
rules={[{ required: true, message: '请选择账号' }]}
>
<Select
placeholder="请选择账号"
showSearch
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
options={accountOptions}
disabled={balanceModalMode === 'edit'}
/>
</Form.Item>
<Form.Item
name="balance"
label="余额(美元)"
rules={[
{ required: true, message: '请输入余额' },
{ type: 'number', min: 0, message: '余额不能小于0' },
]}
>
<InputNumber
style={{ width: '100%' }}
precision={8}
min={0}
step={0.01}
placeholder="请输入余额"
/>
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={3} placeholder="请输入备注(可选)" />
</Form.Item>
</Form>
</Modal>
</>
);
};
export default FinanceMcpAccountRechargeRecords;

View File

@@ -0,0 +1,85 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Card, Table, Typography, Form, Input, DatePicker, Button, Space } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { FinanceApiService } from '../services/api';
const FinanceMcpUsage: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [size, setSize] = useState(20);
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const buildParams = (p = page, s = size) => {
const vals = form.getFieldsValue();
const range = vals.range as any[] | undefined;
const start = range?.[0]?.format?.('YYYY-MM-DD') || undefined;
const end = range?.[1]?.format?.('YYYY-MM-DD') || undefined;
return { page: p, size: s, user_id: vals.user_id, project: vals.project, start, end } as any;
};
const fetchList = async (p = page, s = size) => {
setLoading(true);
try {
const res = await FinanceApiService.listMcpUsages(buildParams(p, s));
setData(res?.data || res?.list || []);
setTotal(res?.total || 0);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchList(); }, []);
const columns: ColumnsType<any> = useMemo(() => ([
{ title: '用户id', dataIndex: 'user_id', key: 'user_id' },
{ title: 'API分组', dataIndex: 'api_group', key: 'api_group' },
{ title: '对话id', dataIndex: 'project_id', key: 'project_id' },
{ title: '日期', dataIndex: 'day', key: 'day' },
{ title: '小时', dataIndex: 'hour', key: 'hour' },
{ title: '服务商', dataIndex: 'provider', key: 'provider' },
{ title: '账号', dataIndex: 'account', key: 'account' },
{ title: '模型', dataIndex: 'model', key: 'model' },
{ title: '代理类型', dataIndex: 'agent_type', key: 'agent_type' },
{ title: '任务id', dataIndex: 'chat_id', key: 'chat_id' },
{ title: '花费金额', dataIndex: 'cost', key: 'cost' },
{ title: '调用次数', dataIndex: 'call_count', key: 'call_count' },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
{ title: '修改时间', dataIndex: 'updated_at', key: 'updated_at' },
]), []);
return (
<Card bordered={false} title={<Typography.Title level={4} style={{ margin: 0 }}>MCP </Typography.Title>}>
<Form form={form} layout="inline" onFinish={() => fetchList(1, size)} style={{ marginBottom: 12 }}>
<Form.Item name="user_id" label="用户id">
<Input allowClear placeholder="用户id" style={{ width: 200 }} />
</Form.Item>
<Form.Item name="project" label="对话id">
<Input allowClear placeholder="对话id" style={{ width: 200 }} />
</Form.Item>
<Form.Item name="range" label="时间范围">
<DatePicker.RangePicker />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" onClick={() => { setPage(1); }}></Button>
<Button onClick={() => { form.resetFields(); fetchList(1, size); setPage(1); }}></Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey={(r) => r.id || `${r.user_id}-${r.day}-${r.hour}-${r.model}-${r.account}`}
columns={columns}
dataSource={data}
loading={loading}
pagination={{ current: page, pageSize: size, total, onChange: (p, s) => { setPage(p); setSize(s); fetchList(p, s); } }}
scroll={{ x: 1000 }}
/>
</Card>
);
};
export default FinanceMcpUsage;

View File

@@ -0,0 +1,567 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Card, Table, Typography, Form, Input, DatePicker, Button, Space, Select, Modal, InputNumber, message, Popconfirm, Tabs, Drawer, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { FinanceApiService } from '../services/api';
import dayjs from 'dayjs';
const { TabPane } = Tabs;
const FinanceModelAccountRechargeRecords: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [size, setSize] = useState(20);
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const [modalVisible, setModalVisible] = useState(false);
const [modalForm] = Form.useForm();
const [modelAccounts, setModelAccounts] = useState<any[]>([]);
const [loadingAccounts, setLoadingAccounts] = useState(false);
const [activeTab, setActiveTab] = useState('recharge-records');
const [balances, setBalances] = useState<any[]>([]);
const [loadingBalances, setLoadingBalances] = useState(false);
const [balanceHistoryVisible, setBalanceHistoryVisible] = useState(false);
const [balanceHistory, setBalanceHistory] = useState<any[]>([]);
const [loadingHistory, setLoadingHistory] = useState(false);
const [balanceModalVisible, setBalanceModalVisible] = useState(false);
const [balanceModalMode, setBalanceModalMode] = useState<'create' | 'edit'>('create');
const [editingBalanceRecord, setEditingBalanceRecord] = useState<any>(null);
const [balanceForm] = Form.useForm();
const [updatingBalance, setUpdatingBalance] = useState(false);
const buildParams = (p = page, s = size) => {
const vals = form.getFieldsValue();
const range = vals.range as any[] | undefined;
const start = range?.[0]?.format?.('YYYY-MM-DD') || undefined;
const end = range?.[1]?.format?.('YYYY-MM-DD') || undefined;
return {
page: p,
size: s,
provider: vals.provider,
account: vals.account,
start,
end,
} as any;
};
const fetchList = useCallback(async (p = page, s = size) => {
setLoading(true);
try {
const res = await FinanceApiService.listModelAccountRechargeRecords(buildParams(p, s));
setData(res?.data || res?.list || []);
setTotal(res?.total || 0);
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || '获取数据失败');
} finally {
setLoading(false);
}
}, [form, page, size]);
const fetchModelAccounts = useCallback(async () => {
setLoadingAccounts(true);
try {
const res = await FinanceApiService.getModelConfigAccounts({ enabled: true });
const accounts = res?.data || res || [];
setModelAccounts(accounts);
} catch (e: any) {
message.error('获取模型账号列表失败');
} finally {
setLoadingAccounts(false);
}
}, []);
const fetchBalances = useCallback(async () => {
setLoadingBalances(true);
try {
const res = await FinanceApiService.getModelAccountBalances();
const balanceList = res?.data || res || [];
setBalances(balanceList);
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || '获取余额数据失败');
} finally {
setLoadingBalances(false);
}
}, []);
const fetchBalanceHistory = useCallback(async (account: string) => {
setLoadingHistory(true);
try {
const res = await FinanceApiService.getModelAccountBalanceHistory(account);
const history = res?.data || res || [];
setBalanceHistory(history);
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || '获取余额历史失败');
} finally {
setLoadingHistory(false);
}
}, []);
useEffect(() => {
fetchModelAccounts();
}, [fetchModelAccounts]);
useEffect(() => {
if (activeTab === 'recharge-records') {
fetchList();
} else if (activeTab === 'balances') {
fetchBalances();
}
}, [activeTab, fetchBalances, fetchList]);
useEffect(() => {
if (activeTab === 'recharge-records') {
fetchList(page, size);
}
}, [activeTab, page, size, fetchList]);
const handleAdd = () => {
modalForm.resetFields();
modalForm.setFieldsValue({
recharge_date: dayjs(),
});
setModalVisible(true);
};
const handleDelete = async (id: string) => {
try {
await FinanceApiService.deleteModelAccountRechargeRecord(id);
message.success('删除成功');
fetchList(page, size);
} catch (e: any) {
message.error(e?.response?.data?.message || e?.message || '删除失败');
}
};
const handleModalOk = async () => {
try {
const values = await modalForm.validateFields();
const payload = {
account: values.account,
amount: values.amount,
recharge_date: values.recharge_date.format('YYYY-MM-DD'),
remark: values.remark || '',
};
await FinanceApiService.createModelAccountRechargeRecord(payload);
message.success('创建成功');
setModalVisible(false);
fetchList(page, size);
if (activeTab === 'balances') {
fetchBalances();
}
} catch (e: any) {
if (e?.errorFields) {
return;
}
message.error(e?.response?.data?.message || e?.message || '操作失败');
}
};
const handleModalCancel = () => {
setModalVisible(false);
modalForm.resetFields();
};
const columns: ColumnsType<any> = useMemo(() => [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 100, ellipsis: true },
{ title: '账号名称', dataIndex: 'provider', key: 'provider', width: 180 },
{ title: '账号', dataIndex: 'account', key: 'account', width: 200 },
{
title: '充值金额',
dataIndex: 'amount',
key: 'amount',
width: 120,
render: (amount: number, record: any) => {
if (amount === null || amount === undefined) return '-';
const currency = record.currency || 'USD';
return `$${amount.toFixed(2)} ${currency}`;
},
},
{
title: '充值日期',
dataIndex: 'recharge_date',
key: 'recharge_date',
width: 120,
},
{ title: '操作人', dataIndex: 'operator_name', key: 'operator_name', width: 120 },
{ title: '备注', dataIndex: 'remark', key: 'remark', width: 200, ellipsis: true },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
{
title: '操作',
key: 'actions',
fixed: 'right',
width: 120,
render: (_: any, record: any) => (
<Popconfirm
title="确认删除这条记录?"
onConfirm={() => handleDelete(record.id)}
okText="确认"
cancelText="取消"
>
<Button type="link" danger size="small">
</Button>
</Popconfirm>
),
},
], []);
const accountOptions = useMemo(() => {
return modelAccounts.map((acc: any) => {
const name = acc.name || acc.provider || acc.account || '未知账号';
const apiType = acc.api_type ? ` (${acc.api_type})` : '';
const label = `${name}${apiType} - ${acc.account || ''}`;
return {
label,
value: acc.account,
name: acc.name,
api_type: acc.api_type,
account: acc.account,
};
});
}, [modelAccounts]);
const handleViewBalanceHistory = useCallback((account: string) => {
setBalanceHistoryVisible(true);
fetchBalanceHistory(account);
}, [fetchBalanceHistory]);
const balanceColumns: ColumnsType<any> = useMemo(() => [
{ title: '账号名称', dataIndex: 'provider', key: 'provider', width: 180 },
{ title: '账号', dataIndex: 'account', key: 'account', width: 180 },
{
title: '当前余额',
dataIndex: 'balance',
key: 'balance',
width: 150,
render: (balance: number, record: any) => {
if (balance === null || balance === undefined) return '-';
const currency = record.currency || 'USD';
return `$${balance.toFixed(2)} ${currency}`;
},
},
{
title: '更新时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180,
},
{
title: '备注',
dataIndex: 'remark',
key: 'remark',
width: 220,
ellipsis: true,
render: (text: string) => (
<Tooltip title={text}>
<span>{text || '-'}</span>
</Tooltip>
),
},
{
title: '操作',
key: 'actions',
fixed: 'right',
width: 160,
render: (_: any, record: any) => (
<Space>
<Button type="link" size="small" onClick={() => handleViewBalanceHistory(record.account)}>
</Button>
<Button
type="link"
size="small"
onClick={() => {
setBalanceModalMode('edit');
setEditingBalanceRecord(record);
balanceForm.setFieldsValue({
account: record.account,
balance: record.balance ?? 0,
remark: '',
});
setBalanceModalVisible(true);
}}
>
</Button>
</Space>
),
},
], [balanceForm, handleViewBalanceHistory]);
const balanceHistoryColumns: ColumnsType<any> = useMemo(() => [
{
title: '余额',
dataIndex: 'balance',
key: 'balance',
width: 150,
render: (balance: number, record: any) => {
if (balance === null || balance === undefined) return '-';
const currency = record.currency || 'USD';
return `$${balance.toFixed(2)} ${currency}`;
},
},
{
title: '备注',
dataIndex: 'remark',
key: 'remark',
width: 220,
ellipsis: true,
render: (text: string) => (
<Tooltip title={text}>
<span>{text || '-'}</span>
</Tooltip>
),
},
{ title: '更新时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
], []);
return (
<>
<Card
bordered={false}
title={<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>}
>
<Tabs activeKey={activeTab} onChange={setActiveTab}>
<TabPane tab="充值记录" key="recharge-records">
<Space style={{ marginBottom: 16 }}>
<Form form={form} onFinish={() => fetchList(1, size)} layout="inline">
<Form.Item name="provider" label="账号名称">
<Input allowClear placeholder="服务商" style={{ width: 200 }} />
</Form.Item>
<Form.Item name="account" label="账号">
<Input allowClear placeholder="账号" style={{ width: 200 }} />
</Form.Item>
<Form.Item name="range" label="充值日期范围">
<DatePicker.RangePicker />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" onClick={() => { setPage(1); }}>
</Button>
<Button
onClick={() => {
form.resetFields();
fetchList(1, size);
setPage(1);
}}
>
</Button>
</Space>
</Form.Item>
</Form>
<Button type="primary" onClick={handleAdd}>
</Button>
</Space>
<Table
rowKey={(r) => r.id}
columns={columns}
dataSource={data}
loading={loading}
pagination={{
current: page,
pageSize: size,
total,
onChange: (p, s) => {
setPage(p);
setSize(s);
fetchList(p, s);
},
}}
scroll={{ x: 1400 }}
/>
</TabPane>
<TabPane tab="账户余额" key="balances">
<Space style={{ marginBottom: 16 }}>
<Button onClick={fetchBalances}></Button>
<Button
type="primary"
onClick={() => {
balanceForm.resetFields();
setBalanceModalMode('create');
setEditingBalanceRecord(null);
setBalanceModalVisible(true);
}}
>
</Button>
</Space>
<Table
rowKey={(r) => r.account || r.id}
columns={balanceColumns}
dataSource={balances}
loading={loadingBalances}
scroll={{ x: 1000 }}
/>
</TabPane>
</Tabs>
</Card>
<Drawer
title="余额历史"
placement="right"
width={800}
open={balanceHistoryVisible}
onClose={() => {
setBalanceHistoryVisible(false);
setBalanceHistory([]);
}}
>
<Table
rowKey={(r) => r.id}
columns={balanceHistoryColumns}
dataSource={balanceHistory}
loading={loadingHistory}
pagination={false}
scroll={{ x: 800 }}
/>
</Drawer>
<Modal
title="添加充值记录"
open={modalVisible}
onOk={handleModalOk}
onCancel={handleModalCancel}
width={600}
>
<Form form={modalForm} layout="vertical">
<Form.Item
name="account"
label="模型账号"
rules={[{ required: true, message: '请选择模型账号' }]}
>
<Select
placeholder="请选择模型账号"
loading={loadingAccounts}
showSearch
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
options={accountOptions}
/>
</Form.Item>
<Form.Item
name="amount"
label="充值金额(美元)"
rules={[
{ required: true, message: '请输入充值金额' },
{ type: 'number', min: 0.01, message: '金额必须大于0' },
]}
>
<InputNumber
style={{ width: '100%' }}
placeholder="请输入充值金额"
precision={2}
min={0.01}
step={0.01}
/>
</Form.Item>
<Form.Item
name="recharge_date"
label="充值日期"
rules={[{ required: true, message: '请选择充值日期' }]}
>
<DatePicker
style={{ width: '100%' }}
format="YYYY-MM-DD"
disabledDate={(current) => current && current > dayjs().endOf('day')}
/>
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={3} placeholder="请输入备注(可选)" />
</Form.Item>
</Form>
</Modal>
<Modal
title={balanceModalMode === 'create' ? '新增余额记录' : '调整余额'}
open={balanceModalVisible}
confirmLoading={updatingBalance}
onOk={async () => {
try {
const values = await balanceForm.validateFields();
setUpdatingBalance(true);
if (balanceModalMode === 'create') {
await FinanceApiService.createModelAccountBalance({
account: values.account,
balance: values.balance,
remark: values.remark || '',
});
message.success('新增成功');
} else if (editingBalanceRecord) {
await FinanceApiService.adjustModelAccountBalance(editingBalanceRecord.account, {
balance: values.balance,
remark: values.remark || '',
});
message.success('调整成功');
}
setBalanceModalVisible(false);
balanceForm.resetFields();
setEditingBalanceRecord(null);
setBalanceModalMode('create');
fetchBalances();
} catch (e: any) {
if (e?.errorFields) {
return;
}
message.error(e?.response?.data?.message || e?.message || '操作失败');
} finally {
setUpdatingBalance(false);
}
}}
onCancel={() => {
setBalanceModalVisible(false);
balanceForm.resetFields();
setEditingBalanceRecord(null);
setBalanceModalMode('create');
}}
width={520}
destroyOnClose
>
<Form form={balanceForm} layout="vertical">
<Form.Item
name="account"
label="模型账号"
rules={[{ required: true, message: '请选择模型账号' }]}
>
<Select
placeholder="请选择模型账号"
showSearch
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
options={accountOptions}
disabled={balanceModalMode === 'edit'}
/>
</Form.Item>
<Form.Item
name="balance"
label="余额(美元)"
rules={[
{ required: true, message: '请输入余额' },
{ type: 'number', min: 0, message: '余额不能小于0' },
]}
>
<InputNumber
style={{ width: '100%' }}
precision={8}
min={0}
step={0.01}
placeholder="请输入余额"
/>
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={3} placeholder="请输入备注(可选)" />
</Form.Item>
</Form>
</Modal>
</>
);
};
export default FinanceModelAccountRechargeRecords;

View File

@@ -0,0 +1,165 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Card, Table, Typography, Form, Input, DatePicker, Button, Space, Select, Popconfirm, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { FinanceApiService } from '../services/api';
const FinancePaymentRecords: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [size, setSize] = useState(20);
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const buildParams = (p = page, s = size) => {
const vals = form.getFieldsValue();
const range = vals.range as any[] | undefined;
const start = range?.[0]?.format?.('YYYY-MM-DD') || undefined;
const end = range?.[1]?.format?.('YYYY-MM-DD') || undefined;
return {
page: p,
size: s,
user_id: vals.user_id,
order_id: vals.order_id,
paypal_order_id: vals.paypal_order_id,
status: vals.status,
refund_status: vals.refund_status,
payer_email: vals.payer_email,
start,
end,
} as any;
};
const fetchList = async (p = page, s = size) => {
setLoading(true);
try {
const res = await FinanceApiService.listPaymentRecords(buildParams(p, s));
setData(res?.data || res?.list || []);
setTotal(res?.total || 0);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchList(); }, []);
const columns: ColumnsType<any> = useMemo(() => ([
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
{ title: '用户ID', dataIndex: 'user_id', key: 'user_id', width: 120 },
{ title: '订单ID', dataIndex: 'order_id', key: 'order_id', width: 150 },
{ title: 'PayPal订单ID', dataIndex: 'paypal_order_id', key: 'paypal_order_id', width: 180 },
{ title: 'PayPal捕获ID', dataIndex: 'paypal_capture_id', key: 'paypal_capture_id', width: 180 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
{
title: '支付金额',
dataIndex: 'amount',
key: 'amount',
width: 120,
render: (amount: number, record: any) => {
if (amount === null || amount === undefined) return '-';
const currency = record.currency || 'USD';
// amount 是最小货币单位,如美分,需要转换为实际金额
const actualAmount = amount / 100;
return `${actualAmount.toFixed(2)} ${currency}`;
}
},
{ title: '货币', dataIndex: 'currency', key: 'currency', width: 80 },
{ title: '付款人邮箱', dataIndex: 'payer_email', key: 'payer_email', width: 200 },
{ title: '付款人姓名', dataIndex: 'payer_name', key: 'payer_name', width: 150 },
{ title: '付款人ID', dataIndex: 'payer_id', key: 'payer_id', width: 150 },
{ title: '退款状态', dataIndex: 'refund_status', key: 'refund_status', width: 100 },
{
title: '已退款金额',
dataIndex: 'refunded_amount',
key: 'refunded_amount',
width: 120,
render: (amount: number, record: any) => {
if (amount === null || amount === undefined || amount === 0) return '-';
const currency = record.currency || 'USD';
const actualAmount = amount / 100;
return `${actualAmount.toFixed(2)} ${currency}`;
}
},
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
{ title: '更新时间', dataIndex: 'updated_at', key: 'updated_at', width: 180 },
{
title: '操作', key: 'actions', fixed: 'right', width: 140,
render: (_: any, record: any) => {
const canRefund = record?.status === 'COMPLETED' && record?.refund_status !== 'full';
if (!canRefund) return null;
const onRefund = async () => {
try {
message.loading({ content: '正在发起退款...', key: 'refund' });
await FinanceApiService.refundPaymentRecord({ order_id: record.order_id, paypal_capture_id: record.paypal_capture_id });
message.success({ content: '退款已提交', key: 'refund', duration: 2 });
fetchList(page, size);
} catch (e: any) {
const errorMsg = e?.response?.data?.message || e?.message || '退款失败';
message.error({ content: errorMsg, key: 'refund' });
}
};
return (
<Popconfirm title="确认对该订单发起退款?" onConfirm={onRefund} okText="确认" cancelText="取消">
<Button danger size="small">退</Button>
</Popconfirm>
);
}
}
]), []);
return (
<Card bordered={false} title={<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>}>
<Form form={form} layout="inline" onFinish={() => fetchList(1, size)} style={{ marginBottom: 12 }}>
<Form.Item name="user_id" label="用户ID">
<Input allowClear placeholder="用户ID" style={{ width: 180 }} />
</Form.Item>
<Form.Item name="order_id" label="订单ID">
<Input allowClear placeholder="订单ID" style={{ width: 180 }} />
</Form.Item>
<Form.Item name="paypal_order_id" label="PayPal订单ID">
<Input allowClear placeholder="PayPal订单ID" style={{ width: 200 }} />
</Form.Item>
<Form.Item name="status" label="订单状态">
<Select allowClear placeholder="订单状态" style={{ width: 150 }}
options={[
{ label: 'CREATED', value: 'CREATED' },
{ label: 'COMPLETED', value: 'COMPLETED' },
]}
/>
</Form.Item>
<Form.Item name="refund_status" label="退款状态">
<Select allowClear placeholder="退款状态" style={{ width: 150 }}
options={[
{ label: 'none', value: 'none' },
{ label: 'partial', value: 'partial' },
{ label: 'full', value: 'full' },
]}
/>
</Form.Item>
<Form.Item name="payer_email" label="付款人邮箱">
<Input allowClear placeholder="付款人邮箱" style={{ width: 200 }} />
</Form.Item>
<Form.Item name="range" label="时间范围">
<DatePicker.RangePicker />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" onClick={() => { setPage(1); }}></Button>
<Button onClick={() => { form.resetFields(); fetchList(1, size); setPage(1); }}></Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey={(r) => r.id}
columns={columns}
dataSource={data}
loading={loading}
pagination={{ current: page, pageSize: size, total, onChange: (p, s) => { setPage(p); setSize(s); fetchList(p, s); } }}
scroll={{ x: 1800 }}
/>
</Card>
);
};
export default FinancePaymentRecords;

View File

@@ -0,0 +1,79 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Card, Table, Typography, Form, Input, DatePicker, Button, Space } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { FinanceApiService } from '../services/api';
const FinanceSandboxCosts: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [size, setSize] = useState(20);
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const buildParams = (p = page, s = size) => {
const vals = form.getFieldsValue();
const range = vals.range as any[] | undefined;
const start = range?.[0]?.format?.('YYYY-MM-DD') || undefined;
const end = range?.[1]?.format?.('YYYY-MM-DD') || undefined;
return { page: p, size: s, user_id: vals.user_id, project: vals.project, start, end } as any;
};
const fetchList = async (p = page, s = size) => {
setLoading(true);
try {
const res = await FinanceApiService.listSandboxRecords(buildParams(p, s));
setData(res?.data || res?.list || []);
setTotal(res?.total || 0);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchList(); }, []);
const columns: ColumnsType<any> = useMemo(() => ([
{ title: '用户id', dataIndex: 'user_id', key: 'user_id' },
{ title: '对话id', dataIndex: 'project_id', key: 'project_id' },
{ title: '任务id', dataIndex: 'chat_id', key: 'chat_id' },
{ title: '花费额度', dataIndex: 'total_cost_balance', key: 'total_cost_balance' },
{ title: '花费金额', dataIndex: 'total_cost_usd', key: 'total_cost_usd' },
{ title: '支付状态', dataIndex: 'billing_status', key: 'billing_status' },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
{ title: '修改时间', dataIndex: 'updated_at', key: 'updated_at' },
]), []);
return (
<Card bordered={false} title={<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>}>
<Form form={form} layout="inline" onFinish={() => fetchList(1, size)} style={{ marginBottom: 12 }}>
<Form.Item name="user_id" label="用户id">
<Input allowClear placeholder="用户id" style={{ width: 200 }} />
</Form.Item>
<Form.Item name="project" label="对话id">
<Input allowClear placeholder="对话id" style={{ width: 200 }} />
</Form.Item>
<Form.Item name="range" label="时间范围">
<DatePicker.RangePicker />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" onClick={() => { setPage(1); }}></Button>
<Button onClick={() => { form.resetFields(); fetchList(1, size); setPage(1); }}></Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey={(r) => r.sandbox_id || `${r.sandbox_id}-${r.created_at}`}
columns={columns}
dataSource={data}
loading={loading}
pagination={{ current: page, pageSize: size, total, onChange: (p, s) => { setPage(p); setSize(s); fetchList(p, s); } }}
scroll={{ x: 1200 }}
/>
</Card>
);
};
export default FinanceSandboxCosts;

View File

@@ -0,0 +1,92 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Card, Table, Typography, Form, Input, DatePicker, Button, Space } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { FinanceApiService } from '../services/api';
const FinanceTokenUsage: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [size, setSize] = useState(20);
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const buildParams = (p = page, s = size) => {
const vals = form.getFieldsValue();
const range = vals.range as any[] | undefined;
const start = range?.[0]?.format?.('YYYY-MM-DD') || undefined;
const end = range?.[1]?.format?.('YYYY-MM-DD') || undefined;
return { page: p, size: s, user_id: vals.user_id, project: vals.project, start, end } as any;
};
const fetchList = async (p = page, s = size) => {
setLoading(true);
try {
const res = await FinanceApiService.listTokenUsages(buildParams(p, s));
setData(res?.data || res?.list || []);
setTotal(res?.total || 0);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchList(); }, []);
const columns: ColumnsType<any> = useMemo(() => ([
{ title: '用户id', dataIndex: 'user_id', key: 'user_id' },
{ title: 'API分组', dataIndex: 'api_group', key: 'api_group' },
{ title: '对话id', dataIndex: 'project_id', key: 'project_id' },
{ title: '日期', dataIndex: 'day', key: 'day' },
{ title: '小时', dataIndex: 'hour', key: 'hour' },
{ title: '服务商', dataIndex: 'provider', key: 'provider' },
{ title: '账号', dataIndex: 'account', key: 'account' },
{ title: '模型', dataIndex: 'model', key: 'model' },
{ title: '代理类型', dataIndex: 'agent_type', key: 'agent_type' },
{ title: '任务id', dataIndex: 'chat_id', key: 'chat_id' },
{ title: '花费金额(总)', dataIndex: 'cost', key: 'cost' },
{ title: '提示Tokens', dataIndex: 'prompt_token', key: 'prompt_token' },
{ title: '输出Tokens', dataIndex: 'completion_token', key: 'completion_token' },
{ title: '缓存创建Tokens', dataIndex: 'cache_create_token', key: 'cache_create_token' },
{ title: '缓存命中Tokens', dataIndex: 'cache_read_token', key: 'cache_read_token' },
{ title: '提示花费', dataIndex: 'prompt_cost', key: 'prompt_cost' },
{ title: '输出花费', dataIndex: 'completion_cost', key: 'completion_cost' },
{ title: '缓存创建花费', dataIndex: 'cache_create_cost', key: 'cache_create_cost' },
{ title: '缓存命花费', dataIndex: 'cache_read_cost', key: 'cache_read_cost' },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
{ title: '修改时间', dataIndex: 'updated_at', key: 'updated_at' },
]), []);
return (
<Card bordered={false} title={<Typography.Title level={4} style={{ margin: 0 }}>Token </Typography.Title>}>
<Form form={form} layout="inline" onFinish={() => fetchList(1, size)} style={{ marginBottom: 12 }}>
<Form.Item name="user_id" label="用户id">
<Input allowClear placeholder="用户id" style={{ width: 200 }} />
</Form.Item>
<Form.Item name="project" label="对话id">
<Input allowClear placeholder="对话id" style={{ width: 200 }} />
</Form.Item>
<Form.Item name="range" label="时间范围(按天)">
<DatePicker.RangePicker />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" onClick={() => { setPage(1); }}></Button>
<Button onClick={() => { form.resetFields(); fetchList(1, size); setPage(1); }}></Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey={(r) => r.id || `${r.user_id}-${r.day}-${r.hour}-${r.model}-${r.account}`}
columns={columns}
dataSource={data}
loading={loading}
pagination={{ current: page, pageSize: size, total, onChange: (p, s) => { setPage(p); setSize(s); fetchList(p, s); } }}
scroll={{ x: 1200 }}
/>
</Card>
);
};
export default FinanceTokenUsage;

View File

@@ -0,0 +1,111 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Card, Table, Typography, Form, Input, DatePicker, Button, Space, Select } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { FinanceApiService } from '../services/api';
const FinanceTransactionLogs: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [size, setSize] = useState(20);
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const buildParams = (p = page, s = size) => {
const vals = form.getFieldsValue();
const range = vals.range as any[] | undefined;
const start = range?.[0]?.format?.('YYYY-MM-DD') || undefined;
const end = range?.[1]?.format?.('YYYY-MM-DD') || undefined;
return {
page: p,
size: s,
user_id: vals.user_id,
order_id: vals.order_id,
type: vals.type,
status: vals.status,
start,
end,
} as any;
};
const fetchList = async (p = page, s = size) => {
setLoading(true);
try {
const res = await FinanceApiService.listTransactionLogs(buildParams(p, s));
setData(res?.data || res?.list || []);
setTotal(res?.total || 0);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchList(); }, []);
const columns: ColumnsType<any> = useMemo(() => ([
{ title: '流水ID', dataIndex: 'transaction_id', key: 'transaction_id' },
{ title: '用户id', dataIndex: 'user_id', key: 'user_id' },
{ title: '订单id', dataIndex: 'order_id', key: 'order_id' },
{ title: '流水类型', dataIndex: 'type', key: 'type' },
{ title: '金额(最小单位)', dataIndex: 'amount', key: 'amount' },
{ title: '货币', dataIndex: 'currency', key: 'currency' },
{ title: '操作前余额', dataIndex: 'balance_before', key: 'balance_before' },
{ title: '操作后余额', dataIndex: 'balance_after', key: 'balance_after' },
{ title: '状态', dataIndex: 'status', key: 'status' },
{ title: '描述', dataIndex: 'description', key: 'description' },
{ title: 'PayPal交易ID', dataIndex: 'paypal_transaction_id', key: 'paypal_transaction_id' },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
]), []);
return (
<Card bordered={false} title={<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>}>
<Form form={form} layout="inline" onFinish={() => fetchList(1, size)} style={{ marginBottom: 12 }}>
<Form.Item name="user_id" label="用户id">
<Input allowClear placeholder="用户id" style={{ width: 180 }} />
</Form.Item>
<Form.Item name="order_id" label="订单id">
<Input allowClear placeholder="订单id" style={{ width: 180 }} />
</Form.Item>
<Form.Item name="type" label="流水类型">
<Select allowClear placeholder="recharge/refund/consume" style={{ width: 200 }}
options={[
{ label: 'recharge', value: 'recharge' },
{ label: 'refund', value: 'refund' },
{ label: 'consume', value: 'consume' },
]}
/>
</Form.Item>
<Form.Item name="status" label="状态">
<Select allowClear placeholder="success/failed/pending" style={{ width: 200 }}
options={[
{ label: 'success', value: 'success' },
{ label: 'failed', value: 'failed' },
{ label: 'pending', value: 'pending' },
]}
/>
</Form.Item>
<Form.Item name="range" label="时间范围">
<DatePicker.RangePicker />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" onClick={() => { setPage(1); }}></Button>
<Button onClick={() => { form.resetFields(); fetchList(1, size); setPage(1); }}></Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey={(r) => r.id || r.transaction_id}
columns={columns}
dataSource={data}
loading={loading}
pagination={{ current: page, pageSize: size, total, onChange: (p, s) => { setPage(p); setSize(s); fetchList(p, s); } }}
scroll={{ x: 1200 }}
/>
</Card>
);
};
export default FinanceTransactionLogs;

View File

@@ -0,0 +1,532 @@
import React, { useEffect, useMemo, useState } from 'react'
import { GoalfyMaxUsersApi } from '../services/api'
import { getAllUserLevelConfigs } from '../services/userLevelConfigApi'
import type { UserLevelConfig } from '../types/userLevelConfig'
import { Table, Button, Modal, Form, Input, Tag, Space, Popconfirm, Select, message, Row, Col, Card, Statistic } from 'antd'
import { EditOutlined, DeleteOutlined, StopOutlined, CheckCircleOutlined, UserOutlined, PlusOutlined, DollarOutlined, MinusCircleOutlined } from '@ant-design/icons'
type User = {
id: number
user_id: number
username: string
email: string
nickname?: string
avatar?: string
is_banned: boolean
ban_reason?: string
last_login_at?: string
created_at?: string
user_level_code?: string
user_level_id?: number
user_level?: {
id: number
level_name: string
level_code: string
}
version?: number // 版本1-用户版 2-观察版 3-用户版和观察版
goalfy_hub_permission?: number // 是否具备GoalfyHub权限 (0: 不具备, 1: 具备)
balance?: number // 用户余额(美元)
}
const { Option } = Select
export default function GoalfyMaxUsers() {
const [loading, setLoading] = useState(false)
const [list, setList] = useState<User[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [size, setSize] = useState(10)
const [keyword, setKeyword] = useState('')
const [status, setStatus] = useState<number | undefined>(undefined)
const [editOpen, setEditOpen] = useState(false)
const [editing, setEditing] = useState<User | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [form] = Form.useForm()
const [createForm] = Form.useForm()
const [userLevels, setUserLevels] = useState<UserLevelConfig[]>([])
const [addBalanceOpen, setAddBalanceOpen] = useState(false)
const [addingBalanceUser, setAddingBalanceUser] = useState<User | null>(null)
const [addBalanceForm] = Form.useForm()
const [deductBalanceOpen, setDeductBalanceOpen] = useState(false)
const [deductingBalanceUser, setDeductingBalanceUser] = useState<User | null>(null)
const [deductBalanceForm] = Form.useForm()
// 加载用户等级配置
const fetchUserLevels = async () => {
try {
const levels = await getAllUserLevelConfigs()
// 显示所有等级(包括已禁用的),以便在编辑时能正确回显
setUserLevels(levels)
} catch (e) {
console.error('获取用户等级配置失败', e)
}
}
useEffect(() => {
fetchUserLevels()
}, [])
const params = useMemo(() => ({ username: keyword, email: '', status, page, size }), [keyword, status, page, size])
const fetchList = async () => {
setLoading(true)
try {
const res = await GoalfyMaxUsersApi.list(params)
setList(res?.data?.users ?? [])
setTotal(res?.data?.total ?? 0)
} catch (e) {
message.error('获取用户列表失败')
} finally {
setLoading(false)
}
}
useEffect(() => { fetchList() }, [params.page, params.size])
const openEdit = (u: User) => {
setEditing(u)
const mappedLevelId = (() => {
if (u.user_level_id) return u.user_level_id
if (u.user_level_code) {
const level = userLevels.find(l => l.level_code === u.user_level_code)
return level?.id
}
if (u.user_level?.level_code) {
const level = userLevels.find(l => l.level_code === u.user_level?.level_code)
return level?.id
}
return undefined
})()
// 将version值转换为下拉框多选数组
// version: 1-用户版, 2-观察版, 3-用户版和观察版
const versionValue = u.version ?? 1
const versionOptions: string[] = []
if (versionValue === 1 || versionValue === 3) {
versionOptions.push('1') // 用户版
}
if (versionValue === 2 || versionValue === 3) {
versionOptions.push('2') // 观察版
}
form.setFieldsValue({
nickname: u.nickname,
email: u.email,
user_level_id: mappedLevelId,
version: versionOptions,
goalfy_hub_permission: u.goalfy_hub_permission
})
setEditOpen(true)
}
const submitEdit = async () => {
try {
const values = await form.validateFields()
if (!editing) return
const payload: any = {
nickname: values.nickname,
email: values.email,
avatar: values.avatar,
goalfy_hub_permission: values.goalfy_hub_permission,
}
if ('avatar' in payload) delete payload.avatar
if (values.user_level_id) {
const level = userLevels.find(l => l.id === values.user_level_id)
if (level) {
payload.user_level_code = level.level_code
}
delete payload.user_level_id
}
// 处理version字段将下拉框多选数组转换为INT值
// [] -> 1 (默认用户版), ['1'] -> 1, ['2'] -> 2, ['1', '2'] -> 3
if (values.version && Array.isArray(values.version)) {
if (values.version.length === 0) {
payload.version = 1 // 默认用户版
} else if (values.version.length === 1) {
payload.version = parseInt(values.version[0])
} else if (values.version.length === 2) {
payload.version = 3 // 用户版和观察版
}
} else {
// 如果没有选择,保持原值或默认值
payload.version = editing.version ?? 1
}
await GoalfyMaxUsersApi.update(editing.id, payload)
message.success('更新成功')
setEditOpen(false)
fetchList()
} catch {}
}
const openCreate = () => {
createForm.resetFields()
setCreateOpen(true)
}
const submitCreate = async () => {
try {
const values = await createForm.validateFields()
const payload: any = { ...values }
if ('avatar' in payload) delete payload.avatar
if (values.user_level_id) {
const level = userLevels.find(l => l.id === values.user_level_id)
if (level) {
payload.user_level_code = level.level_code
}
delete payload.user_level_id
}
// 处理version字段将下拉框多选数组转换为INT值
// [] -> 1 (默认用户版), ['1'] -> 1, ['2'] -> 2, ['1', '2'] -> 3
if (values.version && Array.isArray(values.version)) {
if (values.version.length === 0) {
payload.version = 1 // 默认用户版
} else if (values.version.length === 1) {
payload.version = parseInt(values.version[0])
} else if (values.version.length === 2) {
payload.version = 3 // 用户版和观察版
}
} else {
payload.version = 1 // 默认用户版
}
await GoalfyMaxUsersApi.create(payload)
message.success('创建成功')
setCreateOpen(false)
fetchList()
} catch (error: any) {
message.error(error?.response?.data?.message || '创建失败')
}
}
const handleDelete = async (u: User) => {
await GoalfyMaxUsersApi.delete(u.id)
message.success('删除成功')
fetchList()
}
const handleBan = async (u: User) => {
Modal.confirm({
title: `封禁用户 ${u.username}`,
content: (
<Form form={form} layout="vertical" initialValues={{ reason: '违规行为' }}>
<Form.Item name="reason" label="封禁原因" rules={[{ required: true, message: '请输入封禁原因' }]}>
<Input placeholder="请输入封禁原因"/>
</Form.Item>
</Form>
),
onOk: async () => {
const { reason } = form.getFieldsValue()
await GoalfyMaxUsersApi.ban(u.id, reason)
message.success('已封禁')
fetchList()
}
})
}
const handleUnban = async (u: User) => {
await GoalfyMaxUsersApi.unban(u.id)
message.success('已解封')
fetchList()
}
const openAddBalance = (u: User) => {
setAddingBalanceUser(u)
addBalanceForm.resetFields()
setAddBalanceOpen(true)
}
const submitAddBalance = async () => {
try {
const values = await addBalanceForm.validateFields()
if (!addingBalanceUser) return
await GoalfyMaxUsersApi.addBalance(addingBalanceUser.id, values.amount)
message.success('增加余额成功')
setAddBalanceOpen(false)
fetchList()
} catch (error: any) {
message.error(error?.response?.data?.message || '增加余额失败')
}
}
const openDeductBalance = (u: User) => {
setDeductingBalanceUser(u)
deductBalanceForm.resetFields()
setDeductBalanceOpen(true)
}
const submitDeductBalance = async () => {
try {
const values = await deductBalanceForm.validateFields()
if (!deductingBalanceUser) return
await GoalfyMaxUsersApi.deductBalance(deductingBalanceUser.id, values.amount)
message.success('减少余额成功')
setDeductBalanceOpen(false)
fetchList()
} catch (error: any) {
message.error(error?.response?.data?.message || '减少余额失败')
}
}
const columns = [
{ title: '用户名', dataIndex: 'username', key: 'username' },
{ title: '邮箱', dataIndex: 'email', key: 'email' },
{ title: '昵称', dataIndex: 'nickname', key: 'nickname' },
{
title: '用户等级',
dataIndex: 'user_level',
key: 'user_level',
render: (_: any, record: User) => {
const displayName = (() => {
if (record.user_level?.level_name) return record.user_level.level_name
if (record.user_level_code) {
const level = userLevels.find(l => l.level_code === record.user_level_code)
if (level?.level_name) return level.level_name
}
return null
})()
return displayName ? (
<Tag color="blue">{displayName}</Tag>
) : (
<Tag color="default"></Tag>
)
}
},
{
title: '版本',
dataIndex: 'version',
key: 'version',
render: (version: number) => {
let tagText = '未知'
let color = 'default'
if (version === 1) {
tagText = '用户版'
color = 'blue'
} else if (version === 2) {
tagText = '观察版'
color = 'green'
} else if (version === 3) {
tagText = '用户版和观察版'
color = 'purple'
}
return <Tag color={color}>{tagText}</Tag>
},
},
{
title: 'GoalfyHub 权限',
dataIndex: 'goalfy_hub_permission',
key: 'goalfy_hub_permission',
render: (permission: number) => {
return permission === 1 ? <Tag color="green"></Tag> : <Tag color="red"></Tag>
},
},
{
title: '余额(美元)',
dataIndex: 'balance',
key: 'balance',
render: (balance: number | undefined) => {
const amount = balance ?? 0
return <span>${amount.toFixed(2)}</span>
}
},
{ title: '状态', dataIndex: 'is_banned', key: 'is_banned', render: (v: boolean) => v ? <Tag color="red"></Tag> : <Tag color="green"></Tag> },
{ title: '最后登录', dataIndex: 'last_login_at', key: 'last_login_at' },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at' },
{
title: '操作', key: 'action', render: (_: any, u: User) => (
<Space>
<Button type="link" icon={<EditOutlined />} onClick={() => openEdit(u)}></Button>
<Button type="link" icon={<DollarOutlined />} onClick={() => openAddBalance(u)}></Button>
<Button type="link" icon={<MinusCircleOutlined />} onClick={() => openDeductBalance(u)}></Button>
{!u.is_banned && <Button type="link" icon={<StopOutlined />} onClick={() => handleBan(u)}></Button>}
{u.is_banned && <Button type="link" icon={<CheckCircleOutlined />} onClick={() => handleUnban(u)}></Button>}
<Popconfirm title="确定删除该用户?" onConfirm={() => handleDelete(u)}>
<Button type="link" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
)
}
]
const totalUsers = total
const bannedUsers = (list || []).filter(i => i.is_banned).length
const normalUsers = totalUsers - bannedUsers
return (
<div>
{/* 统计卡片 */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}>
<Card>
<Statistic title="GoalfyMax用户总数" value={totalUsers} prefix={<UserOutlined />} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="正常" value={normalUsers} valueStyle={{ color: '#3f8600' }} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="封禁" value={bannedUsers} valueStyle={{ color: '#cf1322' }} />
</Card>
</Col>
</Row>
{/* 搜索与筛选 */}
<Space style={{ marginBottom: 16 }}>
<Input placeholder="搜索用户名" value={keyword} onChange={e=>setKeyword(e.target.value)} allowClear style={{ width: 240 }} />
<Select placeholder="状态" allowClear style={{ width: 160 }} value={status as any} onChange={(v)=>{ setStatus(v as number | undefined); setPage(1) }}>
<Option value={1}></Option>
<Option value={0}></Option>
</Select>
<Button onClick={()=>{ setPage(1); fetchList() }}></Button>
<Button onClick={()=>{ setKeyword(''); setStatus(undefined); setPage(1); fetchList() }}></Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}></Button>
</Space>
{/* 列表 */}
<Table
columns={columns as any}
dataSource={list}
loading={loading}
rowKey="id"
pagination={{
current: page,
pageSize: size,
total,
showSizeChanger: true,
showQuickJumper: true,
onChange: (p, s) => { setPage(p); setSize(s || 10) }
}}
/>
{/* 编辑弹窗 */}
<Modal open={editOpen} title={`编辑用户 ${editing?.username || ''}`} onCancel={()=>setEditOpen(false)} onOk={submitEdit} okText="保存">
<Form layout="vertical" form={form}>
<Form.Item name="nickname" label="昵称">
<Input />
</Form.Item>
<Form.Item name="email" label="邮箱" rules={[{ type: 'email', message: '邮箱格式不正确' }]}>
<Input />
</Form.Item>
<Form.Item name="user_level_id" label="用户等级">
<Select placeholder="请选择用户等级" allowClear>
{userLevels.map(level => (
<Option key={level.id} value={level.id} disabled={level.status !== 1}>
{level.level_name} ({level.level_code}){level.status !== 1 ? ' [已禁用]' : ''}
</Option>
))}
</Select>
</Form.Item>
<Form.Item name="version" label="版本">
<Select mode="multiple" placeholder="请选择版本" allowClear>
<Option value="1"></Option>
<Option value="2"></Option>
<Option value="3"></Option>
</Select>
</Form.Item>
<Form.Item label="GoalfyHub 权限" name="goalfy_hub_permission">
<Select placeholder="请选择GoalfyHub权限" allowClear>
<Option value={0}></Option>
<Option value={1}></Option>
</Select>
</Form.Item>
</Form>
</Modal>
{/* 创建用户弹窗 */}
<Modal open={createOpen} title="新增用户" onCancel={()=>setCreateOpen(false)} onOk={submitCreate} okText="创建">
<Form layout="vertical" form={createForm}>
<Form.Item name="username" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}>
<Input placeholder="请输入用户名" />
</Form.Item>
<Form.Item name="email" label="邮箱" rules={[{ required: true, message: '请输入邮箱' }, { type: 'email', message: '邮箱格式不正确' }]}>
<Input placeholder="请输入邮箱" />
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请输入密码' }, { min: 8, message: '密码至少8位' }]}>
<Input.Password placeholder="请输入密码" />
</Form.Item>
<Form.Item name="nickname" label="昵称">
<Input placeholder="请输入昵称" />
</Form.Item>
<Form.Item name="user_level_id" label="用户等级">
<Select placeholder="请选择用户等级" allowClear>
{userLevels.filter(level => level.status === 1).map(level => (
<Option key={level.id} value={level.id}>
{level.level_name} ({level.level_code})
</Option>
))}
</Select>
</Form.Item>
<Form.Item name="version" label="版本" initialValue={['1']}>
<Select mode="multiple" placeholder="请选择版本" allowClear>
<Option value="1"></Option>
<Option value="2"></Option>
</Select>
</Form.Item>
</Form>
</Modal>
{/* 增加余额弹窗 */}
<Modal open={addBalanceOpen} title={`增加余额 - ${addingBalanceUser?.username || ''}`} onCancel={()=>setAddBalanceOpen(false)} onOk={submitAddBalance} okText="确认">
<Form layout="vertical" form={addBalanceForm}>
<Form.Item
name="amount"
label="增加金额(美元)"
rules={[
{ required: true, message: '请输入增加金额' },
{ type: 'number', min: 0.01, message: '金额必须大于0' }
]}
getValueFromEvent={(e) => parseFloat(e.target.value) || 0}
getValueProps={(value) => ({ value: value || '' })}
>
<Input
type="number"
step="0.01"
placeholder="请输入要增加的美元金额"
prefix="$"
style={{ width: '100%' }}
/>
</Form.Item>
{addingBalanceUser && (
<div style={{ marginTop: 16, padding: 12, background: '#f5f5f5', borderRadius: 4 }}>
<div>: <strong>${addingBalanceUser.balance?.toFixed(2) || '0.00'}</strong></div>
</div>
)}
</Form>
</Modal>
{/* 减少余额弹窗 */}
<Modal open={deductBalanceOpen} title={`减少余额 - ${deductingBalanceUser?.username || ''}`} onCancel={()=>setDeductBalanceOpen(false)} onOk={submitDeductBalance} okText="确认">
<Form layout="vertical" form={deductBalanceForm}>
<Form.Item
name="amount"
label="减少金额(美元)"
rules={[
{ required: true, message: '请输入减少金额' },
{ type: 'number', min: 0.01, message: '金额必须大于0' }
]}
getValueFromEvent={(e) => parseFloat(e.target.value) || 0}
getValueProps={(value) => ({ value: value || '' })}
>
<Input
type="number"
step="0.01"
placeholder="请输入要减少的美元金额"
prefix="$"
style={{ width: '100%' }}
/>
</Form.Item>
{deductingBalanceUser && (
<div style={{ marginTop: 16, padding: 12, background: '#f5f5f5', borderRadius: 4 }}>
<div>: <strong>${deductingBalanceUser.balance?.toFixed(2) || '0.00'}</strong></div>
</div>
)}
</Form>
</Modal>
</div>
)
}

954
src/pages/InviteCodes.tsx Normal file
View File

@@ -0,0 +1,954 @@
import React, { useState, useEffect } from 'react';
import {
Card,
Table,
Button,
Space,
Tag,
Popconfirm,
message,
Input,
Select,
DatePicker,
Row,
Col,
Statistic,
Modal,
Form,
Badge,
Tabs,
} from 'antd';
import { PlusOutlined, DeleteOutlined, CopyOutlined, EditOutlined, BellOutlined, CheckOutlined, CloseOutlined } from '@ant-design/icons';
import { InviteCodeApiService } from '../services/inviteCodeApi';
import { InviteCodeApplicationApiService } from '../services/inviteCodeApplicationApi';
import { getAllUserLevelConfigs } from '../services/userLevelConfigApi';
import type { InviteCode, InviteCodeStatistics, InviteCodeCreateRequest, InviteCodeUpdateRequest, ClientOption } from '../types/inviteCode';
import type { InviteCodeApplication, InviteCodeApplicationStatistics } from '../types/inviteCodeApplication';
import type { UserLevelConfig } from '../types/userLevelConfig';
import dayjs from 'dayjs';
const { RangePicker } = DatePicker;
const { Option } = Select;
const InviteCodes: React.FC = () => {
const [inviteCodes, setInviteCodes] = useState<InviteCode[]>([]);
const [statistics, setStatistics] = useState<InviteCodeStatistics | null>(null);
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
const [createModalVisible, setCreateModalVisible] = useState(false);
const [editModalVisible, setEditModalVisible] = useState(false);
const [editingInviteCode, setEditingInviteCode] = useState<InviteCode | null>(null);
const [clientOptions, setClientOptions] = useState<ClientOption[]>([]);
const [userLevels, setUserLevels] = useState<UserLevelConfig[]>([]);
const [createForm] = Form.useForm();
const [pagination, setPagination] = useState({
current: 1,
pageSize: 20,
total: 0,
});
// 申请管理相关状态
const [applications, setApplications] = useState<InviteCodeApplication[]>([]);
const [applicationStatistics, setApplicationStatistics] = useState<InviteCodeApplicationStatistics | null>(null);
const [pendingCount, setPendingCount] = useState(0);
const [applicationModalVisible, setApplicationModalVisible] = useState(false);
const [selectedApplications, setSelectedApplications] = useState<number[]>([]);
const [rejectModalVisible, setRejectModalVisible] = useState(false);
const [rejectingApplication, setRejectingApplication] = useState<InviteCodeApplication | null>(null);
const [applicationPagination, setApplicationPagination] = useState({
current: 1,
pageSize: 20,
total: 0,
});
// 搜索条件
const [filters, setFilters] = useState({
code: '',
is_used: undefined as boolean | undefined,
dateRange: null as [dayjs.Dayjs, dayjs.Dayjs] | null,
});
// 获取邀请码列表
const fetchInviteCodes = async () => {
setLoading(true);
try {
const params: any = {
page: pagination.current,
size: pagination.pageSize,
};
if (filters.code) params.code = filters.code;
if (filters.is_used !== undefined) params.is_used = filters.is_used;
if (filters.dateRange) {
params.start_time = filters.dateRange[0].format('YYYY-MM-DD');
params.end_time = filters.dateRange[1].format('YYYY-MM-DD');
}
const response = await InviteCodeApiService.getInviteCodeList(params);
setInviteCodes(response.list);
setPagination(prev => ({ ...prev, total: response.total }));
} catch (error) {
message.error('获取邀请码列表失败');
} finally {
setLoading(false);
}
};
// 获取统计信息
const fetchStatistics = async () => {
try {
const stats = await InviteCodeApiService.getStatistics();
setStatistics(stats);
} catch (error) {
console.error('获取统计信息失败:', error);
}
};
// 获取客户端选项
const fetchClientOptions = async () => {
try {
const options = await InviteCodeApiService.getClientOptions();
setClientOptions(options);
} catch (error) {
console.error('获取客户端选项失败:', error);
}
};
// 获取用户等级列表
const fetchUserLevels = async () => {
try {
const levels = await getAllUserLevelConfigs();
// 只显示启用状态的等级
setUserLevels(levels.filter(level => level.status === 1));
} catch (error) {
console.error('获取用户等级列表失败:', error);
}
};
// 获取申请列表
const fetchApplications = async () => {
setLoading(true);
try {
const params = {
status: 'pending' as const,
page: applicationPagination.current,
size: applicationPagination.pageSize,
};
const response = await InviteCodeApplicationApiService.getApplicationList(params);
setApplications(response.list);
setApplicationPagination(prev => ({ ...prev, total: response.total }));
} catch (error) {
message.error('获取申请列表失败');
} finally {
setLoading(false);
}
};
// 获取待处理申请数量
const fetchPendingCount = async () => {
try {
const count = await InviteCodeApplicationApiService.getPendingCount();
setPendingCount(count);
} catch (error) {
console.error('获取待处理数量失败:', error);
}
};
// 获取申请统计
const fetchApplicationStatistics = async () => {
try {
const stats = await InviteCodeApplicationApiService.getStatistics();
setApplicationStatistics(stats);
} catch (error) {
console.error('获取申请统计失败:', error);
}
};
// 创建邀请码
const handleCreate = async (values: any) => {
setCreating(true);
try {
// 解析邮箱列表(支持换行和逗号分隔)
let emails: string[] = [];
if (values.emails) {
const emailText = values.emails.trim();
if (emailText) {
// 先按换行分割,再按逗号分割
const rawEmails = emailText
.split(/[\n,]/)
.map((email: string) => email.trim())
.filter((email: string) => email.length > 0);
// 去重处理(转换为小写后去重)
const emailSet = new Set<string>();
emails = rawEmails.filter(email => {
const lowerEmail = email.toLowerCase();
if (emailSet.has(lowerEmail)) {
return false; // 已存在,跳过
}
emailSet.add(lowerEmail);
return true; // 新邮箱,保留
});
}
}
const requestData: InviteCodeCreateRequest = {
emails: emails.length > 0 ? emails : undefined,
user_level_id: values.user_level_id,
expires_at: values.expires_at ? dayjs(values.expires_at).toISOString() : undefined,
};
const response = await InviteCodeApiService.createInviteCode(requestData);
// 处理响应(可能是单个对象或数组)
const createdCodes = Array.isArray(response) ? response : [response];
const count = createdCodes.length;
if (count === 1) {
message.success('邀请码创建成功');
} else {
message.success(`成功创建 ${count} 个邀请码`);
}
setCreateModalVisible(false);
createForm.resetFields(); // 清空表单
fetchInviteCodes();
fetchStatistics();
} catch (error: any) {
message.error(error?.response?.data?.message || '创建邀请码失败');
} finally {
setCreating(false);
}
};
// 编辑邀请码
const handleEdit = async (values: InviteCodeUpdateRequest) => {
if (!editingInviteCode) return;
setCreating(true);
try {
await InviteCodeApiService.updateInviteCode(editingInviteCode.id, values);
message.success('邀请码更新成功');
setEditModalVisible(false);
setEditingInviteCode(null);
fetchInviteCodes();
fetchStatistics();
} catch (error) {
message.error('更新邀请码失败');
} finally {
setCreating(false);
}
};
// 打开编辑模态框
const openEditModal = (inviteCode: InviteCode) => {
setEditingInviteCode(inviteCode);
setEditModalVisible(true);
};
// 删除邀请码
const handleDelete = async (id: number) => {
try {
await InviteCodeApiService.deleteInviteCode(id);
message.success('删除成功');
fetchInviteCodes();
fetchStatistics();
} catch (error) {
message.error('删除失败');
}
};
// 复制邀请码
const handleCopyCode = (code: string) => {
navigator.clipboard.writeText(code);
message.success('邀请码已复制到剪贴板');
};
// 复制邀请链接
const handleCopyLink = (inviteUrl?: string, code?: string) => {
const link = inviteUrl || `${window.location.origin}/register?invite=${code ?? ''}`;
navigator.clipboard.writeText(link);
message.success('邀请链接已复制到剪贴板');
};
// 搜索
const handleSearch = () => {
setPagination(prev => ({ ...prev, current: 1 }));
fetchInviteCodes();
};
// 重置搜索
const handleReset = () => {
setFilters({
code: '',
is_used: undefined,
dateRange: null,
});
setPagination(prev => ({ ...prev, current: 1 }));
};
// 审批通过申请
const handleApproveApplication = async (applicationId: number, validDays: number = 3) => {
try {
await InviteCodeApplicationApiService.approveApplication({
application_id: applicationId,
valid_days: validDays,
});
message.success('申请已通过,邀请码已发送至申请人邮箱');
fetchApplications();
fetchPendingCount();
fetchInviteCodes();
fetchStatistics();
} catch (error) {
message.error('审批失败');
}
};
// 审批拒绝申请
const handleRejectApplication = async (applicationId: number, rejectReason?: string) => {
try {
await InviteCodeApplicationApiService.rejectApplication({
application_id: applicationId,
reject_reason: rejectReason,
});
message.success('申请已拒绝');
fetchApplications();
fetchPendingCount();
setRejectModalVisible(false);
setRejectingApplication(null);
} catch (error) {
message.error('拒绝失败');
}
};
// 批量审批通过
const handleBatchApprove = async () => {
if (selectedApplications.length === 0) {
message.warning('请选择要审批的申请');
return;
}
try {
await InviteCodeApplicationApiService.batchApproveApplications({
application_ids: selectedApplications,
valid_days: 3,
});
message.success(`成功审批通过 ${selectedApplications.length} 个申请`);
setSelectedApplications([]);
fetchApplications();
fetchPendingCount();
fetchInviteCodes();
fetchStatistics();
} catch (error) {
message.error('批量审批失败');
}
};
// 批量审批拒绝
const handleBatchReject = async (rejectReason?: string) => {
if (selectedApplications.length === 0) {
message.warning('请选择要拒绝的申请');
return;
}
try {
await InviteCodeApplicationApiService.batchRejectApplications({
application_ids: selectedApplications,
reject_reason: rejectReason,
});
message.success(`成功拒绝 ${selectedApplications.length} 个申请`);
setSelectedApplications([]);
fetchApplications();
fetchPendingCount();
} catch (error) {
message.error('批量拒绝失败');
}
};
useEffect(() => {
fetchInviteCodes();
fetchStatistics();
fetchClientOptions();
fetchUserLevels();
fetchPendingCount();
fetchApplicationStatistics();
}, [pagination.current, pagination.pageSize]);
// 申请列表表格列
const applicationColumns = [
{
title: '申请邮箱',
dataIndex: 'email',
key: 'email',
},
{
title: '申请理由',
dataIndex: 'reason',
key: 'reason',
render: (reason: string | undefined) => reason || '-',
ellipsis: true,
},
{
title: '申请时间',
dataIndex: 'created_at',
key: 'created_at',
render: (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm:ss'),
},
{
title: '操作',
key: 'action',
render: (_: any, record: InviteCodeApplication) => (
<Space>
<Button
type="primary"
size="small"
icon={<CheckOutlined />}
onClick={() => {
Modal.confirm({
title: '确认通过',
content: `确定要通过邮箱 ${record.email} 的申请吗默认有效期3天。`,
onOk: () => handleApproveApplication(record.id),
});
}}
>
</Button>
<Button
danger
size="small"
icon={<CloseOutlined />}
onClick={() => {
setRejectingApplication(record);
setRejectModalVisible(true);
}}
>
</Button>
</Space>
),
},
];
const columns = [
{
title: '邀请码',
dataIndex: 'code',
key: 'code',
render: (code: string) => (
<Space>
<span style={{ fontFamily: 'monospace', fontWeight: 'bold' }}>{code}</span>
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopyCode(code)}
>
</Button>
</Space>
),
},
{
title: '邀请链接',
dataIndex: 'invite_url',
key: 'invite_url',
render: (_: any, record: InviteCode) => (
<Space>
<span style={{ color: '#555' }}>{record.invite_url ?? '-'}</span>
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopyLink(record.invite_url, record.code)}
>
</Button>
</Space>
),
},
{
title: '是否使用',
dataIndex: 'is_used',
key: 'is_used',
render: (isUsed: boolean) => (
<Tag color={isUsed ? 'red' : 'green'}>
{isUsed ? '已使用' : '未使用'}
</Tag>
),
},
{
title: '邮箱',
dataIndex: 'email',
key: 'email',
render: (email: string | undefined) => (
email ? <span>{email}</span> : <span>-</span>
),
},
{
title: '用户等级',
dataIndex: 'user_level_name',
key: 'user_level_name',
render: (userLevelName: string | undefined) => (
userLevelName ? <Tag color="purple">{userLevelName}</Tag> : <span>-</span>
),
},
{
title: '客户端',
dataIndex: 'client_label',
key: 'client_label',
render: (clientLabel: string | undefined) => (
clientLabel ? <Tag color="blue">{clientLabel}</Tag> : <span>-</span>
),
},
{
title: '过期时间',
dataIndex: 'expires_at',
key: 'expires_at',
render: (expiresAt: string | null) => {
if (!expiresAt) {
return <Tag color="blue"></Tag>;
}
const isExpired = dayjs(expiresAt).isBefore(dayjs());
return (
<Tag color={isExpired ? 'red' : 'green'}>
{dayjs(expiresAt).format('YYYY-MM-DD HH:mm:ss')}
{isExpired && ' (已过期)'}
</Tag>
);
},
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
render: (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm:ss'),
},
{
title: '操作',
key: 'action',
render: (record: InviteCode) => (
<Space>
<Button
type="text"
icon={<EditOutlined />}
onClick={() => openEditModal(record)}
disabled={record.is_used}
>
</Button>
<Popconfirm
title="确定要删除这个邀请码吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Button type="text" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div style={{ padding: '24px' }}>
<Card title="邀请码管理" style={{ marginBottom: 16 }}>
{/* 统计信息 */}
{statistics && (
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}>
<Statistic title="总邀请码" value={statistics.total} />
</Col>
<Col span={6}>
<Statistic title="已使用" value={statistics.used} valueStyle={{ color: '#cf1322' }} />
</Col>
<Col span={6}>
<Statistic title="未使用" value={statistics.unused} valueStyle={{ color: '#3f8600' }} />
</Col>
<Col span={6}>
<Statistic title="今日新增" value={statistics.today_created} />
</Col>
</Row>
)}
{/* 操作栏 */}
<Space style={{ marginBottom: 16 }}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
createForm.resetFields(); // 打开时重置表单
setCreateModalVisible(true);
}}
>
</Button>
<Badge count={pendingCount} offset={[-5, 0]}>
<Button
icon={<BellOutlined />}
onClick={() => {
setApplicationModalVisible(true);
fetchApplications();
}}
>
({pendingCount})
</Button>
</Badge>
</Space>
{/* 搜索栏 */}
<Space style={{ marginBottom: 16 }}>
<Input
placeholder="搜索邀请码"
value={filters.code}
onChange={(e) => setFilters(prev => ({ ...prev, code: e.target.value }))}
style={{ width: 200 }}
/>
<Select
placeholder="使用状态"
value={filters.is_used}
onChange={(value) => setFilters(prev => ({ ...prev, is_used: value }))}
style={{ width: 120 }}
allowClear
>
<Option value={false}>使</Option>
<Option value={true}>使</Option>
</Select>
<RangePicker
value={filters.dateRange}
onChange={(dates) => setFilters(prev => ({ ...prev, dateRange: dates }))}
/>
<Button type="primary" onClick={handleSearch}>
</Button>
<Button onClick={handleReset}>
</Button>
</Space>
{/* 表格 */}
<Table
columns={columns}
dataSource={inviteCodes}
rowKey="id"
loading={loading}
pagination={{
...pagination,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) => `${range[0]}-${range[1]} 条/共 ${total}`,
onChange: (page, pageSize) => {
setPagination(prev => ({ ...prev, current: page, pageSize: pageSize || 20 }));
},
}}
/>
</Card>
{/* 创建邀请码模态框 */}
<Modal
title="创建邀请码"
open={createModalVisible}
onCancel={() => {
setCreateModalVisible(false);
createForm.resetFields(); // 关闭时清空表单
}}
footer={null}
width={500}
>
<Form
form={createForm}
layout="vertical"
onFinish={handleCreate}
initialValues={{
expires_at: dayjs().add(3, 'day'),
user_level_id: 1, // 默认用户等级ID为1
}}
>
<Form.Item
label="邮箱"
name="emails"
rules={[
{ required: false },
{
validator: (_, value) => {
if (!value || value.trim() === '') {
return Promise.resolve();
}
const emailText = value.trim();
const emails = emailText.split(/[\n,]/).map((e: string) => e.trim()).filter((e: string) => e.length > 0);
const emailRegex = /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/;
for (const email of emails) {
if (!emailRegex.test(email)) {
return Promise.reject(new Error(`邮箱格式不正确: ${email}`));
}
}
return Promise.resolve();
},
},
]}
extra="每行一个邮箱,或使用逗号分隔。支持多个邮箱,将为每个邮箱创建一个邀请码。"
>
<Input.TextArea
rows={4}
placeholder="请输入邮箱地址,例如:&#10;user1@example.com&#10;user2@example.com&#10;或user1@example.com, user2@example.com"
/>
</Form.Item>
<Form.Item
label="用户等级"
name="user_level_id"
rules={[{ required: false }]}
extra="选择用户等级将关联到创建的邀请码默认等级1"
>
<Select placeholder="请选择用户等级默认等级1" allowClear>
{userLevels.map(level => (
<Option key={level.id} value={level.id}>
{level.level_name} ({level.level_code})
</Option>
))}
</Select>
</Form.Item>
<Form.Item
label="过期时间"
name="expires_at"
rules={[{ required: false, message: '请选择过期时间' }]}
>
<DatePicker
showTime
format="YYYY-MM-DD HH:mm:ss"
style={{ width: '100%' }}
placeholder="选择过期时间可选默认3天后过期"
/>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" loading={creating}>
</Button>
<Button onClick={() => setCreateModalVisible(false)}>
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
{/* 编辑邀请码模态框 */}
<Modal
title="编辑邀请码"
open={editModalVisible}
onCancel={() => {
setEditModalVisible(false);
setEditingInviteCode(null);
}}
footer={null}
width={500}
>
<Form
key={editingInviteCode?.id} // 添加 key 确保表单重新渲染
layout="vertical"
onFinish={handleEdit}
initialValues={{
code: editingInviteCode?.code,
client_id: editingInviteCode?.client_id,
email: editingInviteCode?.email,
user_level_id: editingInviteCode?.user_level_id,
expires_at: editingInviteCode?.expires_at ? dayjs(editingInviteCode.expires_at) : null,
}}
>
<Form.Item
label="邀请码"
name="code"
>
<Input disabled />
</Form.Item>
<Form.Item
label="邮箱"
name="email"
rules={[
{ required: false },
{ type: 'email', message: '邮箱格式不正确' },
]}
>
<Input placeholder="请输入邮箱地址" />
</Form.Item>
<Form.Item
label="用户等级"
name="user_level_id"
rules={[{ required: false }]}
>
<Select placeholder="请选择用户等级(可选)" allowClear>
{userLevels.map(level => (
<Option key={level.id} value={level.id}>
{level.level_name} ({level.level_code})
</Option>
))}
</Select>
</Form.Item>
<Form.Item
label="客户端"
name="client_id"
rules={[{ required: false, message: '请选择客户端' }]}
>
<Select placeholder="请选择客户端" allowClear>
{clientOptions.map(option => (
<Option key={option.key} value={option.key}>
{option.value}
</Option>
))}
</Select>
</Form.Item>
<Form.Item
label="过期时间"
name="expires_at"
rules={[{ required: false, message: '请选择过期时间' }]}
>
<DatePicker
showTime
format="YYYY-MM-DD HH:mm:ss"
style={{ width: '100%' }}
placeholder="选择过期时间(可选,设置为空表示永不过期)"
allowClear
/>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" loading={creating}>
</Button>
<Button onClick={() => {
setEditModalVisible(false);
setEditingInviteCode(null);
}}>
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
{/* 待处理申请弹窗 */}
<Modal
title="待处理申请"
open={applicationModalVisible}
onCancel={() => {
setApplicationModalVisible(false);
setSelectedApplications([]);
}}
width={900}
footer={[
<Button key="cancel" onClick={() => {
setApplicationModalVisible(false);
setSelectedApplications([]);
}}>
</Button>,
<Button
key="batch-reject"
danger
onClick={() => {
if (selectedApplications.length === 0) {
message.warning('请选择要拒绝的申请');
return;
}
Modal.confirm({
title: '批量拒绝',
content: `确定要拒绝选中的 ${selectedApplications.length} 个申请吗?`,
onOk: () => handleBatchReject(),
});
}}
disabled={selectedApplications.length === 0}
>
({selectedApplications.length})
</Button>,
<Button
key="batch-approve"
type="primary"
onClick={() => {
if (selectedApplications.length === 0) {
message.warning('请选择要通过的申请');
return;
}
Modal.confirm({
title: '批量通过',
content: `确定要通过选中的 ${selectedApplications.length} 个申请吗默认有效期3天。`,
onOk: handleBatchApprove,
});
}}
disabled={selectedApplications.length === 0}
>
({selectedApplications.length})
</Button>,
]}
>
<Table
columns={applicationColumns}
dataSource={applications}
rowKey="id"
loading={loading}
rowSelection={{
selectedRowKeys: selectedApplications,
onChange: (selectedRowKeys) => {
setSelectedApplications(selectedRowKeys as number[]);
},
}}
pagination={{
...applicationPagination,
onChange: (page, pageSize) => {
setApplicationPagination(prev => ({ ...prev, current: page, pageSize: pageSize || 20 }));
fetchApplications();
},
}}
/>
</Modal>
{/* 拒绝申请弹窗 */}
<Modal
title="拒绝申请"
open={rejectModalVisible}
onCancel={() => {
setRejectModalVisible(false);
setRejectingApplication(null);
}}
footer={null}
width={500}
>
<Form
layout="vertical"
onFinish={(values) => {
if (rejectingApplication) {
handleRejectApplication(rejectingApplication.id, values.reject_reason);
}
}}
>
<Form.Item label="申请邮箱">
<Input value={rejectingApplication?.email} disabled />
</Form.Item>
<Form.Item
label="拒绝原因"
name="reject_reason"
rules={[{ required: false, message: '请输入拒绝原因' }]}
>
<Input.TextArea
rows={4}
placeholder="请输入拒绝原因(选填)"
/>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button onClick={() => {
setRejectModalVisible(false);
setRejectingApplication(null);
}}>
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default InviteCodes;

View File

@@ -0,0 +1,517 @@
import React, { useEffect, useState } from 'react';
import {
Table,
Button,
Modal,
Form,
Input,
InputNumber,
Tag,
Space,
Popconfirm,
message,
Card,
Select,
Switch,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
CheckCircleOutlined,
StopOutlined,
} from '@ant-design/icons';
import type { MCPProvider } from '../types/mcpProvider';
import {
getMCPProviderList,
createMCPProvider,
updateMCPProvider,
deleteMCPProvider,
updateMCPProviderStatus,
updateMCPProviderIsUsed,
} from '../services/mcpProviderApi';
const { Option } = Select;
const { TextArea } = Input;
export default function MCPProviderPricing() {
const [loading, setLoading] = useState(false);
const [list, setList] = useState<MCPProvider[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [size, setSize] = useState(10);
const [editOpen, setEditOpen] = useState(false);
const [editing, setEditing] = useState<MCPProvider | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [form] = Form.useForm();
const [createForm] = Form.useForm();
const [searchForm] = Form.useForm();
const fetchList = async (searchParams: any = {}) => {
setLoading(true);
try {
const params = {
page,
page_size: size,
...searchParams,
};
const res = await getMCPProviderList(params);
setList(res?.data ?? []);
setTotal(res?.total ?? 0);
} catch (e: any) {
message.error(e?.response?.data?.message || '获取列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchList();
}, [page, size]);
const openEdit = (provider: MCPProvider) => {
setEditing(provider);
form.setFieldsValue({
provider: provider.provider,
base_url: provider.base_url,
auth: provider.auth,
account: provider.account,
price_type: provider.price_type,
price: provider.price,
floating_ratio: provider.floating_ratio,
is_used: provider.is_used,
status: provider.status,
description: provider.description,
});
setEditOpen(true);
};
const submitEdit = async () => {
try {
const values = await form.validateFields();
if (!editing) return;
await updateMCPProvider(editing.id, values);
message.success('更新成功');
setEditOpen(false);
fetchList();
} catch (error: any) {
message.error(error?.response?.data?.message || '更新失败');
}
};
const openCreate = () => {
createForm.resetFields();
createForm.setFieldsValue({
is_used: true,
status: 'active',
floating_ratio: 0.3,
});
setCreateOpen(true);
};
const submitCreate = async () => {
try {
const values = await createForm.validateFields();
await createMCPProvider(values);
message.success('创建成功');
setCreateOpen(false);
fetchList();
} catch (error: any) {
message.error(error?.response?.data?.message || '创建失败');
}
};
const handleDelete = async (provider: MCPProvider) => {
try {
await deleteMCPProvider(provider.id);
message.success('删除成功');
fetchList();
} catch (error: any) {
message.error(error?.response?.data?.message || '删除失败');
}
};
const handleToggleStatus = async (provider: MCPProvider) => {
try {
const newStatus = provider.status === 'active' ? 'inactive' : 'active';
await updateMCPProviderStatus(provider.id, { status: newStatus });
message.success(newStatus === 'active' ? '已启用' : '已禁用');
fetchList();
} catch (error: any) {
message.error(error?.response?.data?.message || '状态更新失败');
}
};
const handleToggleIsUsed = async (provider: MCPProvider, isUsed: boolean) => {
try {
await updateMCPProviderIsUsed(provider.id, { is_used: isUsed });
message.success(isUsed ? '已启用' : '已停用');
fetchList();
} catch (error: any) {
message.error(error?.response?.data?.message || '状态更新失败');
}
};
const handleSearch = (values: any) => {
setPage(1);
fetchList(values);
};
const handleReset = () => {
searchForm.resetFields();
setPage(1);
fetchList();
};
const columns = [
{
title: '供应商',
dataIndex: 'provider',
key: 'provider',
width: 150,
},
{
title: 'Base URL',
dataIndex: 'base_url',
key: 'base_url',
ellipsis: true,
width: 200,
},
{
title: '账户',
dataIndex: 'account',
key: 'account',
width: 120,
render: (text: string) => text || '-',
},
{
title: '价格类型',
dataIndex: 'price_type',
key: 'price_type',
width: 120,
},
{
title: '价格',
dataIndex: 'price',
key: 'price',
width: 100,
render: (value: number) => value.toFixed(6),
},
{
title: '浮动比例',
dataIndex: 'floating_ratio',
key: 'floating_ratio',
width: 100,
render: (value: number) => `${(value * 100).toFixed(2)}%`,
},
{
title: '使用中',
dataIndex: 'is_used',
key: 'is_used',
width: 100,
render: (value: boolean, record: MCPProvider) => (
<Switch
checked={value}
onChange={(checked) => handleToggleIsUsed(record, checked)}
/>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (value: string) =>
value === 'active' ? (
<Tag color="green"></Tag>
) : (
<Tag color="red"></Tag>
),
},
{
title: '更新时间',
dataIndex: 'updated_at',
key: 'updated_at',
width: 180,
render: (text: string) => new Date(text).toLocaleString('zh-CN'),
},
{
title: '操作',
key: 'action',
width: 250,
fixed: 'right' as const,
render: (_: any, provider: MCPProvider) => (
<Space>
<Button
type="link"
icon={<EditOutlined />}
onClick={() => openEdit(provider)}
>
</Button>
<Button
type="link"
icon={
provider.status === 'active' ? (
<StopOutlined />
) : (
<CheckCircleOutlined />
)
}
onClick={() => handleToggleStatus(provider)}
>
{provider.status === 'active' ? '禁用' : '启用'}
</Button>
<Popconfirm
title="确定删除该配置?"
onConfirm={() => handleDelete(provider)}
>
<Button type="link" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Card>
<div style={{ marginBottom: 16 }}>
<Form
form={searchForm}
layout="inline"
onFinish={handleSearch}
style={{ marginBottom: 16 }}
>
<Form.Item name="provider">
<Input placeholder="供应商名称" style={{ width: 150 }} />
</Form.Item>
<Form.Item name="status">
<Select placeholder="状态" allowClear style={{ width: 120 }}>
<Option value="active"></Option>
<Option value="inactive"></Option>
</Select>
</Form.Item>
<Form.Item name="is_used">
<Select placeholder="使用中" allowClear style={{ width: 120 }}>
<Option value="true"></Option>
<Option value="false"></Option>
</Select>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button onClick={handleReset}></Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={openCreate}
>
</Button>
</Space>
</Form.Item>
</Form>
</div>
<Table
dataSource={list}
columns={columns}
rowKey="id"
loading={loading}
scroll={{ x: 1400 }}
pagination={{
current: page,
pageSize: size,
total: total,
showSizeChanger: true,
showTotal: (total) => `${total}`,
onChange: (p, s) => {
setPage(p);
setSize(s);
},
}}
/>
</Card>
{/* 编辑弹窗 */}
<Modal
title="编辑MCP供应商配置"
open={editOpen}
onOk={submitEdit}
onCancel={() => setEditOpen(false)}
width={600}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item
name="provider"
label="供应商"
rules={[{ required: true, message: '请输入供应商名称' }]}
>
<Input placeholder="请输入供应商名称" />
</Form.Item>
<Form.Item
name="base_url"
label="Base URL"
rules={[
{ required: true, message: '请输入Base URL' },
{ type: 'url', message: '请输入有效的URL' },
]}
>
<Input placeholder="请输入Base URL" />
</Form.Item>
<Form.Item name="auth" label="认证信息">
<Input.Password placeholder="请输入认证信息" />
</Form.Item>
<Form.Item
name="account"
label="账户"
rules={[{ max: 200, message: '账户名称不能超过200个字符' }]}
>
<Input placeholder="请输入账户名称" />
</Form.Item>
<Form.Item
name="price_type"
label="价格类型"
rules={[{ required: true, message: '请输入价格类型' }]}
>
<Input placeholder="请输入价格类型per_token, per_request等" />
</Form.Item>
<Form.Item
name="price"
label="价格"
rules={[{ required: true, message: '请输入价格' }]}
>
<InputNumber
placeholder="请输入价格"
min={0}
precision={6}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item
name="floating_ratio"
label="浮动比例"
rules={[
{ type: 'number', min: 0, max: 5, message: '浮动比例应在0-5之间' },
]}
>
<InputNumber
placeholder="请输入浮动比例"
min={0}
max={5}
precision={2}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item name="is_used" label="使用中" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="status" label="状态">
<Select>
<Option value="active"></Option>
<Option value="inactive"></Option>
</Select>
</Form.Item>
<Form.Item name="description" label="描述">
<TextArea rows={3} placeholder="请输入描述信息" />
</Form.Item>
</Form>
</Modal>
{/* 创建弹窗 */}
<Modal
title="新建MCP供应商配置"
open={createOpen}
onOk={submitCreate}
onCancel={() => setCreateOpen(false)}
width={600}
destroyOnClose
>
<Form form={createForm} layout="vertical">
<Form.Item
name="provider"
label="供应商"
rules={[{ required: true, message: '请输入供应商名称' }]}
>
<Input placeholder="请输入供应商名称" />
</Form.Item>
<Form.Item
name="base_url"
label="Base URL"
rules={[
{ required: true, message: '请输入Base URL' },
{ type: 'url', message: '请输入有效的URL' },
]}
>
<Input placeholder="请输入Base URL" />
</Form.Item>
<Form.Item name="auth" label="认证信息">
<Input.Password placeholder="请输入认证信息" />
</Form.Item>
<Form.Item
name="account"
label="账户"
rules={[{ max: 200, message: '账户名称不能超过200个字符' }]}
>
<Input placeholder="请输入账户名称" />
</Form.Item>
<Form.Item
name="price_type"
label="价格类型"
rules={[{ required: true, message: '请输入价格类型' }]}
>
<Input placeholder="请输入价格类型per_token, per_request等" />
</Form.Item>
<Form.Item
name="price"
label="价格"
rules={[{ required: true, message: '请输入价格' }]}
>
<InputNumber
placeholder="请输入价格"
min={0}
precision={6}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item
name="floating_ratio"
label="浮动比例"
rules={[
{ type: 'number', min: 0, max: 5, message: '浮动比例应在0-5之间' },
]}
>
<InputNumber
placeholder="请输入浮动比例"
min={0}
max={5}
precision={2}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item name="is_used" label="使用中" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="status" label="状态">
<Select>
<Option value="active"></Option>
<Option value="inactive"></Option>
</Select>
</Form.Item>
<Form.Item name="description" label="描述">
<TextArea rows={3} placeholder="请输入描述信息" />
</Form.Item>
</Form>
</Modal>
</div>
);
}

566
src/pages/MessagePush.tsx Normal file
View File

@@ -0,0 +1,566 @@
import React, { useState, useEffect } from 'react';
import {
Card,
Form,
Input,
Button,
Space,
message,
Table,
Tag,
Modal,
Select,
DatePicker,
Row,
Col,
Typography,
Divider,
Drawer,
Statistic,
Tooltip,
} from 'antd';
import {
SendOutlined,
SearchOutlined,
UserOutlined,
EyeOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { MessagePushApiService } from '../services/api';
import dayjs from 'dayjs';
const { TextArea } = Input;
const { Option } = Select;
const { RangePicker } = DatePicker;
const { Title, Text } = Typography;
interface PushLogItem {
id: number;
title: string;
content: string;
target_users: string; // JSON字符串
sender_id: number;
sender_name: string;
status: number;
success_count: number;
fail_count: number;
error_message?: string;
sent_at?: string;
created_at: string;
updated_at: string;
}
interface UserSearchItem {
id: number;
username: string;
email: string;
}
const MessagePush: React.FC = () => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [logs, setLogs] = useState<PushLogItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
// 所有用户列表
const [allUsers, setAllUsers] = useState<UserSearchItem[]>([]);
const [usersLoading, setUsersLoading] = useState(false);
// 详情查看
const [detailVisible, setDetailVisible] = useState(false);
const [selectedLog, setSelectedLog] = useState<PushLogItem | null>(null);
// 筛选条件
const [filters, setFilters] = useState({
status: undefined as number | undefined,
sender_id: undefined as number | undefined,
dateRange: undefined as [dayjs.Dayjs, dayjs.Dayjs] | undefined,
});
// 获取所有用户
const fetchAllUsers = async () => {
setUsersLoading(true);
try {
const response = await MessagePushApiService.searchUsers('', 1000); // 获取所有用户
if (response.code === 200) {
setAllUsers(response.data.users || []);
}
} catch (error) {
console.error('获取用户列表失败:', error);
} finally {
setUsersLoading(false);
}
};
// 获取推送记录列表
const fetchLogs = async () => {
setLoading(true);
try {
const params: any = {
page,
page_size: pageSize,
};
if (filters.status !== undefined) params.status = filters.status;
if (filters.sender_id) params.sender_id = filters.sender_id;
if (filters.dateRange) {
params.start_time = filters.dateRange[0].format('YYYY-MM-DD HH:mm:ss');
params.end_time = filters.dateRange[1].format('YYYY-MM-DD HH:mm:ss');
}
const response = await MessagePushApiService.getPushLogs(params);
if (response.code === 200) {
setLogs(response.data.list);
setTotal(response.data.total);
}
} catch (error) {
message.error('获取推送记录失败');
} finally {
setLoading(false);
}
};
// 发送消息
const handleSend = async () => {
try {
const values = await form.validateFields();
if (!values.title?.trim()) {
message.error('请输入消息标题');
return;
}
if (values.title.trim().length > 100) {
message.error('标题不能超过100个字符');
return;
}
if (!values.content?.trim()) {
message.error('请输入消息内容');
return;
}
if (!values.user_ids || values.user_ids.length === 0) {
message.error('请选择目标用户');
return;
}
setLoading(true);
const response = await MessagePushApiService.sendMessage({
title: values.title.trim(),
content: values.content.trim(),
user_ids: values.user_ids,
});
if (response.code === 200) {
message.success('消息发送成功');
form.resetFields();
fetchLogs();
}
} catch (error) {
message.error('发送失败,请重试');
} finally {
setLoading(false);
}
};
// 查看详情
const handleViewDetail = async (id: number) => {
try {
const response = await MessagePushApiService.getPushLogDetail(id);
if (response.code === 200) {
setSelectedLog(response.data);
setDetailVisible(true);
}
} catch (error) {
message.error('获取详情失败');
}
};
// 搜索记录
const handleSearch = () => {
setPage(1);
fetchLogs();
};
// 重置筛选
const handleReset = () => {
setFilters({
status: undefined,
sender_id: undefined,
dateRange: undefined,
});
setPage(1);
fetchLogs();
};
useEffect(() => {
fetchLogs();
fetchAllUsers(); // 加载所有用户
}, [page, pageSize]);
// 表格列定义
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
},
{
title: '标题',
dataIndex: 'title',
key: 'title',
width: 200,
render: (text: string) => (
<div style={{ maxWidth: 240 }}>
{text?.length > 20 ? `${text.substring(0, 20)}...` : text}
</div>
),
},
{
title: '消息内容',
dataIndex: 'content',
key: 'content',
width: 200,
render: (text: string) => (
<div style={{ maxWidth: 200 }}>
{text.length > 50 ? `${text.substring(0, 50)}...` : text}
</div>
),
},
{
title: '目标用户',
dataIndex: 'target_users',
key: 'target_users',
width: 120,
render: (targetUsers: string) => {
try {
const userIds = JSON.parse(targetUsers);
return (
<Tooltip title={`用户ID: ${userIds.join(', ')}`}>
<span>{userIds.length}</span>
</Tooltip>
);
} catch {
return '-';
}
},
},
{
title: '发送人',
dataIndex: 'sender_name',
key: 'sender_name',
width: 100,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: number) => {
const statusMap = {
0: { text: '待发送', color: 'default' },
1: { text: '发送中', color: 'processing' },
2: { text: '发送成功', color: 'success' },
3: { text: '发送失败', color: 'error' },
};
const config = statusMap[status as keyof typeof statusMap] || { text: '未知', color: 'default' };
return <Tag color={config.color}>{config.text}</Tag>;
},
},
{
title: '成功/失败',
key: 'counts',
width: 120,
render: (record: PushLogItem) => (
<span>
<Text type="success">{record.success_count}</Text> /
<Text type="danger">{record.fail_count}</Text>
</span>
),
},
{
title: '发送时间',
dataIndex: 'sent_at',
key: 'sent_at',
width: 150,
render: (sentAt: string) => sentAt ? dayjs(sentAt).format('YYYY-MM-DD HH:mm:ss') : '-',
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 150,
render: (createdAt: string) => dayjs(createdAt).format('YYYY-MM-DD HH:mm:ss'),
},
{
title: '操作',
key: 'action',
width: 100,
render: (record: PushLogItem) => (
<Space>
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => handleViewDetail(record.id)}
>
</Button>
</Space>
),
},
];
return (
<div className="message-push-page">
{/* 消息发送 */}
<Card title="消息推送" style={{ marginBottom: 16 }}>
<Form form={form} layout="vertical">
<Form.Item
label="标题"
name="title"
rules={[
{ required: true, message: '请输入消息标题' },
{ max: 100, message: '标题不能超过100字符' }
]}
>
<Input placeholder="请输入标题..." maxLength={100} />
</Form.Item>
<Form.Item
label="消息内容"
name="content"
rules={[
{ required: true, message: '请输入消息内容' },
{ max: 2000, message: '消息内容不能超过2000字符' }
]}
>
<TextArea
rows={4}
placeholder="请输入消息内容..."
maxLength={2000}
showCount
/>
</Form.Item>
<Form.Item
label="目标用户"
name="user_ids"
rules={[{ required: true, message: '请选择目标用户' }]}
>
<Select
mode="multiple"
placeholder="请选择目标用户"
style={{ width: '100%' }}
loading={usersLoading}
options={allUsers.map(user => ({
label: `${user.username} (${user.email})`,
value: user.id
}))}
filterOption={(input, option) => {
const label = option?.label as string || '';
return label.toLowerCase().includes(input.toLowerCase());
}}
showSearch
allowClear
/>
</Form.Item>
<Form.Item>
<Space>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSend}
loading={loading}
>
</Button>
<Button onClick={() => form.resetFields()}>
</Button>
</Space>
</Form.Item>
</Form>
</Card>
{/* 推送记录 */}
<Card title="推送记录">
{/* 筛选条件 */}
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col span={6}>
<Select
placeholder="状态筛选"
value={filters.status}
onChange={(value) => setFilters({ ...filters, status: value })}
allowClear
style={{ width: '100%' }}
>
<Option value={0}></Option>
<Option value={1}></Option>
<Option value={2}></Option>
<Option value={3}></Option>
</Select>
</Col>
<Col span={6}>
<RangePicker
value={filters.dateRange}
onChange={(dates) => setFilters({ ...filters, dateRange: dates })}
showTime
style={{ width: '100%' }}
/>
</Col>
<Col span={4}>
<Space>
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>
</Button>
<Button onClick={handleReset}></Button>
</Space>
</Col>
</Row>
<Table
columns={columns}
dataSource={logs}
loading={loading}
rowKey="id"
pagination={{
current: page,
pageSize: pageSize,
total: total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
onChange: (page, pageSize) => {
setPage(page);
setPageSize(pageSize);
},
}}
/>
</Card>
{/* 详情抽屉 */}
<Drawer
title="推送记录详情"
width={600}
open={detailVisible}
onClose={() => setDetailVisible(false)}
>
{selectedLog && (
<div>
<div>
<Text strong></Text>
<div style={{ marginTop: 8 }}>
{selectedLog.title || '-'}
</div>
</div>
<Divider />
<div>
<Text strong></Text>
<div style={{ marginTop: 8, padding: 12, background: '#f5f5f5', borderRadius: 4 }}>
{selectedLog.content}
</div>
</div>
<Divider />
<div>
<Text strong></Text>
<div style={{ marginTop: 8 }}>
{(() => {
try {
const userIds = JSON.parse(selectedLog.target_users);
return (
<div>
<Text> {userIds.length} </Text>
<div style={{ marginTop: 8 }}>
<Text code>{userIds.join(', ')}</Text>
</div>
</div>
);
} catch {
return <Text>-</Text>;
}
})()}
</div>
</div>
<Divider />
<Row gutter={[16, 16]}>
<Col span={12}>
<Text strong></Text>
<Text>{selectedLog.sender_name}</Text>
</Col>
<Col span={12}>
<Text strong></Text>
{(() => {
const statusMap = {
0: { text: '待发送', color: 'default' },
1: { text: '发送中', color: 'processing' },
2: { text: '发送成功', color: 'success' },
3: { text: '发送失败', color: 'error' },
};
const config = statusMap[selectedLog.status as keyof typeof statusMap] || { text: '未知', color: 'default' };
return <Tag color={config.color}>{config.text}</Tag>;
})()}
</Col>
</Row>
<Divider />
<Row gutter={[16, 16]}>
<Col span={8}>
<Statistic title="成功数量" value={selectedLog.success_count} valueStyle={{ color: '#3f8600' }} />
</Col>
<Col span={8}>
<Statistic title="失败数量" value={selectedLog.fail_count} valueStyle={{ color: '#cf1322' }} />
</Col>
<Col span={8}>
<Statistic title="总计" value={selectedLog.success_count + selectedLog.fail_count} />
</Col>
</Row>
{selectedLog.error_message && (
<>
<Divider />
<div>
<Text strong></Text>
<div style={{ marginTop: 8, padding: 12, background: '#fff2f0', borderRadius: 4, color: '#cf1322' }}>
{selectedLog.error_message}
</div>
</div>
</>
)}
<Divider />
<Row gutter={[16, 16]}>
<Col span={12}>
<Text strong></Text>
<Text>{dayjs(selectedLog.created_at).format('YYYY-MM-DD HH:mm:ss')}</Text>
</Col>
{selectedLog.sent_at && (
<Col span={12}>
<Text strong></Text>
<Text>{dayjs(selectedLog.sent_at).format('YYYY-MM-DD HH:mm:ss')}</Text>
</Col>
)}
</Row>
</div>
)}
</Drawer>
</div>
);
};
export default MessagePush;

83
src/pages/Monitoring.tsx Normal file
View File

@@ -0,0 +1,83 @@
import React, { useState, useEffect } from 'react';
import { QuotaApiService } from '../services/api';
interface QuotaHistoryItem {
id: number;
user_id: string;
api_group: string;
project_id: string;
day: string;
account: string;
model: string;
quota_used: number;
quota_used_text?: string;
created_at: string;
}
const Monitoring: React.FC = () => {
const [quotaData, setQuotaData] = useState<QuotaHistoryItem[]>([]);
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
// 初始化日期范围最近30天
useEffect(() => {
const today = new Date();
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(today.getDate() - 30);
setEndDate(today.toISOString().split('T')[0]);
setStartDate(thirtyDaysAgo.toISOString().split('T')[0]);
}, []);
// 计算统计数据
const stats = React.useMemo(() => {
if (quotaData.length === 0) {
return {
totalQuota: 0,
totalUsers: 0,
totalProjects: 0,
totalModels: 0,
};
}
const totalQuota = quotaData.reduce((sum, item) => sum + item.quota_used, 0);
const uniqueUsers = new Set(quotaData.map(item => item.user_id)).size;
const uniqueProjects = new Set(quotaData.map(item => item.project_id)).size;
const uniqueModels = new Set(quotaData.map(item => item.model)).size;
return {
totalQuota,
totalUsers: uniqueUsers,
totalProjects: uniqueProjects,
totalModels: uniqueModels,
};
}, [quotaData]);
return (
<section className="panel">
<h2>Token 使</h2>
<div className="desc">使</div>
<div className="kpis">
<div className="kpi">
<h3> Token 使</h3>
<div className="num">{stats.totalQuota.toLocaleString()}</div>
</div>
<div className="kpi">
<h3></h3>
<div className="num">{stats.totalUsers}</div>
</div>
<div className="kpi">
<h3></h3>
<div className="num">{stats.totalProjects}</div>
</div>
<div className="kpi">
<h3></h3>
<div className="num">{stats.totalModels}</div>
</div>
</div>
</section>
);
};
export default Monitoring;

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { useAuth } from '../hooks/useAuth';
const NoPermission: React.FC = () => {
const { logout } = useAuth();
return (
<div style={{
minHeight: '60vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
textAlign: 'center',
gap: 16,
padding: 24,
}}>
<div style={{ fontSize: 72, lineHeight: 1, color: '#ff4d4f' }}>403</div>
<div style={{ fontSize: 18, fontWeight: 600 }}>访</div>
<div style={{ color: '#666' }}> sys_admin </div>
<button
onClick={async () => {
console.log('🔁 [NoPermission] 点击返回登录开始完整登出并清理IdP会话');
try { await logout(); } catch (e) { console.warn('⚠️ [NoPermission] 登出异常', e); }
}}
style={{
padding: '8px 16px',
borderRadius: 6,
border: '1px solid #1677ff',
background: '#1677ff',
color: '#fff',
cursor: 'pointer',
marginTop: 8,
}}
>
</button>
</div>
);
};
export default NoPermission;

51
src/pages/Operations.tsx Normal file
View File

@@ -0,0 +1,51 @@
import React from 'react';
import { Card, Button, Space } from 'antd';
import { useNavigate } from 'react-router-dom';
const Operations: React.FC = () => {
const navigate = useNavigate();
return (
<section className="panel">
<h2></h2>
<div className="desc">广</div>
<div style={{ margin: '16px 0' }}>
<Card>
<Space wrap>
<Button type="primary" onClick={() => navigate('/mcp-provider-pricing')}>
MCP
</Button>
</Space>
</Card>
</div>
<div className="flex-3">
<div className="panel">
<h2></h2>
<div className="chart"></div>
</div>
<div className="panel">
<h2></h2>
<div className="chart"></div>
</div>
<div className="panel">
<h2></h2>
<div className="chart"></div>
</div>
</div>
<div className="flex-2">
<div className="panel">
<h2></h2>
<div className="chart"></div>
</div>
<div className="panel">
<h2></h2>
<div className="chart"></div>
</div>
</div>
</section>
);
};
export default Operations;

42
src/pages/Overview.tsx Normal file
View File

@@ -0,0 +1,42 @@
import React from 'react';
const Overview: React.FC = () => {
return (
<section className="panel">
<h2></h2>
<div className="desc"></div>
<div className="kpis">
<div className="kpi">
<h3></h3>
<div className="num">1,234</div>
</div>
<div className="kpi">
<h3></h3>
<div className="num">567</div>
</div>
<div className="kpi">
<h3>Token消耗</h3>
<div className="num">12.5M</div>
</div>
<div className="kpi">
<h3></h3>
<div className="num" style={{color: 'var(--success)'}}></div>
</div>
</div>
<div className="flex-2">
<div className="panel">
<h2>7</h2>
<div className="chart"></div>
</div>
<div className="panel">
<h2>使</h2>
<div className="chart">使</div>
</div>
</div>
</section>
);
};
export default Overview;

View File

@@ -0,0 +1,634 @@
import React, { useState, useEffect } from 'react';
import {
Table,
Button,
Modal,
Form,
Input,
message,
Space,
Popconfirm,
Tag,
Card,
Row,
Col,
Statistic,
Tree,
Checkbox
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
TeamOutlined,
KeyOutlined,
SettingOutlined
} from '@ant-design/icons';
import PermissionGuard from '../components/PermissionGuard';
import { usePermissions } from '../hooks/usePermissions';
import { RoleApiService } from '../services/roleApi';
import type { Role, RoleCreateRequest, RoleUpdateRequest, RoleListRequest } from '../services/roleApi';
interface Role {
id: number;
name: string;
description: string;
status: number;
permissions: string[];
createdAt: string;
}
interface Permission {
id: number;
name: string;
code: string;
module: string;
children?: Permission[];
}
const RoleManagement: React.FC = () => {
const [roles, setRoles] = useState<Role[]>([]);
const [permissions, setPermissions] = useState<Permission[]>([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [permissionModalVisible, setPermissionModalVisible] = useState(false);
const [editingRole, setEditingRole] = useState<Role | null>(null);
const [selectedPermissions, setSelectedPermissions] = useState<string[]>([]);
const [form] = Form.useForm();
const { hasPermission } = usePermissions();
// 模拟数据
const mockRoles: Role[] = [
{
id: 1,
name: '超级管理员',
description: '拥有所有权限的超级管理员',
status: 1,
permissions: ['user:read', 'user:create', 'user:update', 'user:delete', 'role:read', 'role:create'],
createdAt: '2024-01-01 10:00:00'
},
{
id: 2,
name: '管理员',
description: '普通管理员,拥有大部分权限',
status: 1,
permissions: ['user:read', 'user:create', 'user:update', 'role:read'],
createdAt: '2024-01-02 09:00:00'
},
{
id: 3,
name: '操作员',
description: '基础操作权限',
status: 1,
permissions: ['user:read', 'role:read'],
createdAt: '2024-01-03 11:00:00'
}
];
const mockPermissions: Permission[] = [
{
id: 1,
name: '用户管理',
code: 'user',
module: 'user',
children: [
{ id: 11, name: '查看用户列表', code: 'user:read', module: 'user' },
{ id: 12, name: '创建用户', code: 'user:create', module: 'user' },
{ id: 13, name: '编辑用户', code: 'user:update', module: 'user' },
{ id: 14, name: '删除用户', code: 'user:delete', module: 'user' },
{ id: 15, name: '重置用户密码', code: 'user:reset_password', module: 'user' },
{ id: 16, name: '管理用户状态', code: 'user:status', module: 'user' }
]
},
{
id: 2,
name: '角色管理',
code: 'role',
module: 'role',
children: [
{ id: 21, name: '查看角色列表', code: 'role:read', module: 'role' },
{ id: 22, name: '创建角色', code: 'role:create', module: 'role' },
{ id: 23, name: '编辑角色', code: 'role:update', module: 'role' },
{ id: 24, name: '删除角色', code: 'role:delete', module: 'role' },
{ id: 25, name: '分配角色权限', code: 'role:assign_permission', module: 'role' }
]
},
{
id: 3,
name: '配额管理',
code: 'quota',
module: 'quota',
children: [
{ id: 31, name: '查看配额', code: 'quota:read', module: 'quota' },
{ id: 32, name: '设置配额', code: 'quota:write', module: 'quota' },
{ id: 33, name: '配额历史', code: 'quota:history', module: 'quota' }
]
}
];
const fetchRoles = async () => {
setLoading(true);
try {
console.log('🔍 [RoleManagement] 获取角色列表');
const response = await RoleApiService.getRoles({
page: 1,
size: 100
});
console.log('🔍 [RoleManagement] 角色列表响应:', response);
// 处理不同的响应格式
let rolesData = [];
if (response.data && response.data.roles && Array.isArray(response.data.roles)) {
// 新格式response.data.roles
rolesData = response.data.roles;
console.log('🔍 [RoleManagement] 使用新格式数据,角色数量:', rolesData.length);
} else if (response.data && Array.isArray(response.data)) {
// 直接数组格式
rolesData = response.data;
console.log('🔍 [RoleManagement] 使用直接数组格式,角色数量:', rolesData.length);
} else if (response.roles && Array.isArray(response.roles)) {
// 旧格式response.roles
rolesData = response.roles;
console.log('🔍 [RoleManagement] 使用旧格式数据,角色数量:', rolesData.length);
} else if (Array.isArray(response)) {
// 直接响应就是数组
rolesData = response;
console.log('🔍 [RoleManagement] 使用直接响应数组,角色数量:', rolesData.length);
}
console.log('🔍 [RoleManagement] 处理后的角色数据:', rolesData);
console.log('🔍 [RoleManagement] 第一个角色的页面权限:', rolesData[0]?.pages);
setRoles(rolesData);
} catch (error) {
console.error('❌ [RoleManagement] 获取角色列表失败:', error);
message.error('获取角色列表失败');
setRoles([]); // 确保设置为空数组
} finally {
setLoading(false);
}
};
const fetchPermissions = async () => {
try {
console.log('🔍 [RoleManagement] 获取页面列表');
// 使用带认证的API调用
const response = await fetch('/api/admin/pages', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json'
}
});
const result = await response.json();
console.log('🔍 [RoleManagement] 页面列表响应:', result);
if (result.success && result.data) {
// 将页面数据转换为权限树结构
const pagePermissions = result.data.map((page: any) => ({
id: page.id,
name: page.name,
code: page.path,
module: page.path.replace('/', ''),
children: [
{ id: page.id * 10 + 1, name: '查看', code: `${page.path}:read`, module: page.path.replace('/', '') },
{ id: page.id * 10 + 2, name: '创建', code: `${page.path}:create`, module: page.path.replace('/', '') },
{ id: page.id * 10 + 3, name: '编辑', code: `${page.path}:update`, module: page.path.replace('/', '') },
{ id: page.id * 10 + 4, name: '删除', code: `${page.path}:delete`, module: page.path.replace('/', '') }
]
}));
setPermissions(pagePermissions);
} else {
console.warn('🔍 [RoleManagement] 页面列表获取失败,使用默认数据');
// 使用默认的页面数据
const defaultPages = [
{ id: 1, name: '总览', path: '/overview' },
{ id: 2, name: '运营', path: '/operations' },
{ id: 3, name: '监控', path: '/monitoring' },
{ id: 4, name: '财务', path: '/finance' },
{ id: 5, name: '系统管理', path: '/system' }
];
const pagePermissions = defaultPages.map((page: any) => ({
id: page.id,
name: page.name,
code: page.path,
module: page.path.replace('/', ''),
children: [
{ id: page.id * 10 + 1, name: '查看', code: `${page.path}:read`, module: page.path.replace('/', '') },
{ id: page.id * 10 + 2, name: '创建', code: `${page.path}:create`, module: page.path.replace('/', '') },
{ id: page.id * 10 + 3, name: '编辑', code: `${page.path}:update`, module: page.path.replace('/', '') },
{ id: page.id * 10 + 4, name: '删除', code: `${page.path}:delete`, module: page.path.replace('/', '') }
]
}));
setPermissions(pagePermissions);
}
} catch (error) {
console.error('❌ [RoleManagement] 获取页面列表失败:', error);
message.error('获取页面列表失败');
// 使用默认数据
const defaultPages = [
{ id: 1, name: '总览', path: '/overview' },
{ id: 2, name: '运营', path: '/operations' },
{ id: 3, name: '监控', path: '/monitoring' },
{ id: 4, name: '财务', path: '/finance' },
{ id: 5, name: '系统管理', path: '/system' }
];
const pagePermissions = defaultPages.map((page: any) => ({
id: page.id,
name: page.name,
code: page.path,
module: page.path.replace('/', ''),
children: [
{ id: page.id * 10 + 1, name: '查看', code: `${page.path}:read`, module: page.path.replace('/', '') },
{ id: page.id * 10 + 2, name: '创建', code: `${page.path}:create`, module: page.path.replace('/', '') },
{ id: page.id * 10 + 3, name: '编辑', code: `${page.path}:update`, module: page.path.replace('/', '') },
{ id: page.id * 10 + 4, name: '删除', code: `${page.path}:delete`, module: page.path.replace('/', '') }
]
}));
setPermissions(pagePermissions);
}
};
const handleCreate = () => {
setEditingRole(null);
form.resetFields();
setModalVisible(true);
};
const handleEdit = (role: Role) => {
setEditingRole(role);
form.setFieldsValue({
name: role.name,
description: role.description,
status: role.status
});
setModalVisible(true);
};
const handleSubmit = async (values: any) => {
try {
if (editingRole) {
// 更新角色
console.log('🔍 [RoleManagement] 更新角色:', editingRole.id, values);
await RoleApiService.updateRole(editingRole.id, values);
message.success('角色更新成功');
} else {
// 创建角色
console.log('🔍 [RoleManagement] 创建角色:', values);
await RoleApiService.createRole(values);
message.success('角色创建成功');
}
setModalVisible(false);
fetchRoles(); // 重新获取角色列表
} catch (error) {
console.error('❌ [RoleManagement] 角色操作失败:', error);
message.error('操作失败');
}
};
const handleDelete = async (id: number) => {
try {
console.log('🔍 [RoleManagement] 删除角色:', id);
await RoleApiService.deleteRole(id);
message.success('角色删除成功');
fetchRoles(); // 重新获取角色列表
} catch (error) {
console.error('❌ [RoleManagement] 删除角色失败:', error);
message.error('删除失败');
}
};
const handleStatusChange = async (id: number, status: number) => {
try {
const updatedRoles = roles.map(role =>
role.id === id ? { ...role, status } : role
);
setRoles(updatedRoles);
message.success('角色状态更新成功');
} catch (error) {
message.error('状态更新失败');
}
};
const handlePermissionAssign = async (role: Role) => {
try {
console.log('🔍 [RoleManagement] 获取角色页面权限:', role.id);
setEditingRole(role);
// 优先使用角色数据中的页面权限如果没有则从API获取
if (role.pages && Array.isArray(role.pages)) {
console.log('🔍 [RoleManagement] 使用角色数据中的页面权限:', role.pages);
const pagePermissions = role.pages.map((page: any) => page.path);
setSelectedPermissions(pagePermissions);
setPermissionModalVisible(true);
return;
}
// 从API获取角色当前的页面权限
const response = await fetch(`/api/admin/rbac/roles/${role.id}/page-permissions`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json'
}
});
const result = await response.json();
console.log('🔍 [RoleManagement] 角色页面权限:', result);
if (result.success && result.data) {
// 将页面权限转换为权限代码
const pagePermissions = result.data.map((page: any) => page.path);
setSelectedPermissions(pagePermissions);
} else {
setSelectedPermissions([]);
}
setPermissionModalVisible(true);
} catch (error) {
console.error('❌ [RoleManagement] 获取角色权限失败:', error);
message.error('获取角色权限失败');
setSelectedPermissions([]);
setPermissionModalVisible(true);
}
};
const handlePermissionSubmit = async () => {
try {
if (!editingRole) return;
console.log('🔍 [RoleManagement] 分配角色页面权限:', editingRole.id, selectedPermissions);
// 将页面路径转换为页面ID
const pageIds = selectedPermissions.map(path => {
const page = permissions.find(p => p.code === path);
return page?.id;
}).filter(id => id !== undefined);
const response = await fetch(`/api/admin/rbac/role-page-permissions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
roleId: editingRole.id,
pageIds: pageIds
})
});
const result = await response.json();
console.log('🔍 [RoleManagement] 权限分配响应:', result);
if (result.success || response.ok) {
setPermissionModalVisible(false);
message.success('页面权限分配成功');
fetchRoles(); // 重新获取角色列表
} else {
message.error(result.message || '权限分配失败');
}
} catch (error) {
console.error('❌ [RoleManagement] 权限分配失败:', error);
message.error('权限分配失败');
}
};
const columns = [
{
title: '角色名称',
dataIndex: 'name',
key: 'name',
render: (text: string) => (
<Space>
<TeamOutlined />
{text}
</Space>
),
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
},
{
title: '页面权限',
dataIndex: 'pages',
key: 'pages',
render: (pages: any[]) => (
<div>
{pages && pages.length > 0 ? (
<div>
<Tag color="blue">
<KeyOutlined /> {pages.length}
</Tag>
<div style={{ marginTop: 4, fontSize: '12px', color: '#666' }}>
{pages.map(page => page.name).join(', ')}
</div>
</div>
) : (
<Tag color="red"></Tag>
)}
</div>
),
},
{
title: '角色等级',
dataIndex: 'level',
key: 'level',
render: (level: number) => (
<Tag color={level === 1 ? 'red' : level === 2 ? 'orange' : level === 3 ? 'blue' : level === 4 ? 'green' : 'default'}>
L{level}
</Tag>
),
},
{
title: '默认角色',
dataIndex: 'isDefault',
key: 'isDefault',
render: (isDefault: boolean) => (
<Tag color={isDefault ? 'green' : 'default'}>
{isDefault ? '是' : '否'}
</Tag>
),
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
},
{
title: '操作',
key: 'action',
render: (_, record: Role) => (
<Space>
<PermissionGuard permission="role:update">
<Button
type="link"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
>
</Button>
</PermissionGuard>
<PermissionGuard permission="role:assign_permission">
<Button
type="link"
icon={<SettingOutlined />}
onClick={() => handlePermissionAssign(record)}
>
</Button>
</PermissionGuard>
<PermissionGuard permission="role:delete">
<Popconfirm
title="确定要删除这个角色吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Button type="link" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</PermissionGuard>
</Space>
),
},
];
useEffect(() => {
fetchRoles();
fetchPermissions();
}, []);
const totalRoles = roles.length;
const activeRoles = roles.filter(role => role.status === 1).length;
const inactiveRoles = roles.filter(role => role.status === 0).length;
return (
<div>
{/* 统计卡片 */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={8}>
<Card>
<Statistic
title="总角色数"
value={totalRoles}
prefix={<TeamOutlined />}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="活跃角色"
value={activeRoles}
valueStyle={{ color: '#3f8600' }}
prefix={<TeamOutlined />}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="禁用角色"
value={inactiveRoles}
valueStyle={{ color: '#cf1322' }}
prefix={<TeamOutlined />}
/>
</Card>
</Col>
</Row>
{/* 操作栏 */}
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<h2></h2>
<PermissionGuard permission="role:create">
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
</Button>
</PermissionGuard>
</div>
{/* 角色表格 */}
<Table
columns={columns}
dataSource={roles}
loading={loading}
rowKey="id"
pagination={{
total: roles.length,
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
}}
/>
{/* 角色表单弹窗 */}
<Modal
title={editingRole ? '编辑角色' : '创建角色'}
open={modalVisible}
onCancel={() => setModalVisible(false)}
onOk={() => form.submit()}
width={600}
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
>
<Form.Item
name="name"
label="角色名称"
rules={[{ required: true, message: '请输入角色名称' }]}
>
<Input />
</Form.Item>
<Form.Item
name="description"
label="描述"
>
<Input.TextArea rows={3} />
</Form.Item>
{editingRole && (
<Form.Item
name="status"
label="状态"
>
<Select>
<Option value={1}></Option>
<Option value={0}></Option>
</Select>
</Form.Item>
)}
</Form>
</Modal>
{/* 页面权限分配弹窗 */}
<Modal
title={`分配页面权限 - ${editingRole?.name}`}
open={permissionModalVisible}
onCancel={() => setPermissionModalVisible(false)}
onOk={handlePermissionSubmit}
width={800}
>
<div>
<p style={{ marginBottom: 16, color: '#666' }}>
访访
</p>
<Tree
checkable
checkedKeys={selectedPermissions}
onCheck={(checkedKeys) => setSelectedPermissions(checkedKeys as string[])}
treeData={permissions.map(permission => ({
title: `${permission.name} (${permission.code})`,
key: permission.code,
children: permission.children?.map(child => ({
title: child.name,
key: child.code,
}))
}))}
/>
</div>
</Modal>
</div>
);
};
export default RoleManagement;

283
src/pages/SystemConfigs.tsx Normal file
View File

@@ -0,0 +1,283 @@
import React, { useEffect, useState } from 'react';
import {
Table,
Button,
Modal,
Form,
Input,
Tag,
Space,
Popconfirm,
message,
Row,
Col,
Card,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
CheckCircleOutlined,
StopOutlined,
} from '@ant-design/icons';
import type { SystemConfig } from '../types/systemConfig';
import {
getSystemConfigList,
createSystemConfig,
updateSystemConfig,
deleteSystemConfig,
updateSystemConfigStatus,
} from '../services/systemConfigApi';
export default function SystemConfigs() {
const [loading, setLoading] = useState(false);
const [list, setList] = useState<SystemConfig[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [size, setSize] = useState(10);
const [editOpen, setEditOpen] = useState(false);
const [editing, setEditing] = useState<SystemConfig | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [form] = Form.useForm();
const [createForm] = Form.useForm();
const fetchList = async () => {
setLoading(true);
try {
const res = await getSystemConfigList({ page, size });
setList(res?.data ?? []);
setTotal(res?.total ?? 0);
} catch (e) {
message.error('获取列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchList();
}, [page, size]);
const openEdit = (config: SystemConfig) => {
setEditing(config);
form.setFieldsValue({
name: config.name,
value: config.value,
type: config.type,
desc: config.desc,
});
setEditOpen(true);
};
const submitEdit = async () => {
try {
const values = await form.validateFields();
if (!editing) return;
await updateSystemConfig(editing.id, values);
message.success('更新成功');
setEditOpen(false);
fetchList();
} catch (error) {
message.error('更新失败');
}
};
const openCreate = () => {
createForm.resetFields();
setCreateOpen(true);
};
const submitCreate = async () => {
try {
const values = await createForm.validateFields();
await createSystemConfig(values);
message.success('创建成功');
setCreateOpen(false);
fetchList();
} catch (error: any) {
message.error(error?.response?.data?.message || '创建失败');
}
};
const handleDelete = async (config: SystemConfig) => {
try {
await deleteSystemConfig(config.id);
message.success('删除成功');
fetchList();
} catch (error) {
message.error('删除失败');
}
};
const handleToggleStatus = async (config: SystemConfig) => {
try {
const newStatus = config.status === 1 ? 0 : 1;
await updateSystemConfigStatus(config.id, { status: newStatus });
message.success(newStatus === 1 ? '已启用' : '已禁用');
fetchList();
} catch (error) {
message.error('状态更新失败');
}
};
const columns = [
{
title: '配置标识',
dataIndex: 'key',
key: 'key',
width: 200,
},
{
title: '配置名称',
dataIndex: 'name',
key: 'name',
width: 150,
},
{
title: '配置值',
dataIndex: 'value',
key: 'value',
ellipsis: true,
},
{
title: '配置描述',
dataIndex: 'desc',
key: 'desc',
ellipsis: true,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 80,
render: (value: number) =>
value === 1 ? <Tag color="green"></Tag> : <Tag color="red"></Tag>,
},
{
title: '操作',
key: 'action',
width: 250,
render: (_: any, config: SystemConfig) => (
<Space>
<Button type="link" icon={<EditOutlined />} onClick={() => openEdit(config)}>
</Button>
<Button
type="link"
icon={config.status === 1 ? <StopOutlined /> : <CheckCircleOutlined />}
onClick={() => handleToggleStatus(config)}
>
{config.status === 1 ? '禁用' : '启用'}
</Button>
<Popconfirm title="确定删除该配置?" onConfirm={() => handleDelete(config)}>
<Button type="link" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={24}>
<Card>
<div style={{ marginBottom: 16 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</div>
<Table
dataSource={list}
columns={columns}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize: size,
total: total,
onChange: (p, s) => {
setPage(p);
setSize(s);
},
}}
/>
</Card>
</Col>
</Row>
{/* 编辑弹窗 */}
<Modal
title="编辑配置"
open={editOpen}
onOk={submitEdit}
onCancel={() => setEditOpen(false)}
width={600}
>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label="配置名称"
rules={[{ required: true, message: '请输入配置名称' }]}
>
<Input placeholder="请输入配置名称" />
</Form.Item>
<Form.Item
name="value"
label="配置值"
rules={[{ required: true, message: '请输入配置值' }]}
>
<Input.TextArea rows={4} placeholder="请输入配置值" />
</Form.Item>
<Form.Item name="type" label="配置类型">
<Input placeholder="请输入配置类型string, int, bool, json" />
</Form.Item>
<Form.Item name="desc" label="配置描述">
<Input.TextArea rows={3} placeholder="请输入配置描述" />
</Form.Item>
</Form>
</Modal>
{/* 创建弹窗 */}
<Modal
title="新建配置"
open={createOpen}
onOk={submitCreate}
onCancel={() => setCreateOpen(false)}
width={600}
>
<Form form={createForm} layout="vertical">
<Form.Item
name="key"
label="配置标识"
rules={[{ required: true, message: '请输入配置标识' }]}
>
<Input placeholder="请输入配置标识唯一app_name" />
</Form.Item>
<Form.Item
name="name"
label="配置名称"
rules={[{ required: true, message: '请输入配置名称' }]}
>
<Input placeholder="请输入配置名称" />
</Form.Item>
<Form.Item
name="value"
label="配置值"
rules={[{ required: true, message: '请输入配置值' }]}
>
<Input.TextArea rows={4} placeholder="请输入配置值" />
</Form.Item>
<Form.Item name="type" label="配置类型">
<Input placeholder="请输入配置类型string, int, bool, json" />
</Form.Item>
<Form.Item name="desc" label="配置描述">
<Input.TextArea rows={3} placeholder="请输入配置描述" />
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import React from 'react';
const SystemHealth: React.FC = () => {
return (
<section className="panel">
<h2></h2>
<div className="desc"></div>
<div className="flex-3">
<div className="panel">
<h2></h2>
<div className="chart">(%)</div>
</div>
<div className="panel">
<h2></h2>
<div className="chart">P50 / P95 </div>
</div>
<div className="panel">
<h2></h2>
<div className="chart">5xx//</div>
</div>
</div>
</section>
);
};
export default SystemHealth;

View File

@@ -0,0 +1,27 @@
import React from 'react';
const TokenAnalytics: React.FC = () => {
return (
<section className="panel">
<h2>Token 使</h2>
<div className="desc">使</div>
<div className="flex-3">
<div className="panel">
<h2>使</h2>
<div className="chart">Token使用量趋势图占位</div>
</div>
<div className="panel">
<h2></h2>
<div className="chart">Token使用量排行占位</div>
</div>
<div className="panel">
<h2></h2>
<div className="chart">Token使用分布占位</div>
</div>
</div>
</section>
);
};
export default TokenAnalytics;

190
src/pages/TokenHistory.tsx Normal file
View File

@@ -0,0 +1,190 @@
import React from 'react'
import { QuotaApiService } from '../services/api'
import { Form, DatePicker, Input, Select, Button, Space, Dropdown, Checkbox, type MenuProps } from 'antd'
import dayjs, { Dayjs } from 'dayjs'
interface QuotaHistoryItem {
id: number;
user_id: string;
api_group: string;
project_id: string;
day: string;
account: string;
model: string;
quota_used: number;
quota_used_text?: string;
created_at: string;
}
export default function TokenHistoryPage() {
const [loading, setLoading] = React.useState(false)
const [data, setData] = React.useState<QuotaHistoryItem[]>([])
const [startDate, setStartDate] = React.useState('')
const [endDate, setEndDate] = React.useState('')
const [qhUserId, setQhUserId] = React.useState('')
const [qhApiGroup, setQhApiGroup] = React.useState('')
const [qhProjectId, setQhProjectId] = React.useState('')
const [qhPeriod, setQhPeriod] = React.useState<'daily'|'monthly'>('daily')
const [qhGroupBy, setQhGroupBy] = React.useState<string[]>(['user_id'])
const [groupDropOpen, setGroupDropOpen] = React.useState(false)
const groupOptions = [
{ label: '用户', value: 'user_id' },
{ label: 'API组', value: 'api_group' },
{ label: '项目', value: 'project_id' },
{ label: '服务商', value: 'provider' },
{ label: '模型', value: 'model' },
]
React.useEffect(() => {
const today = new Date()
const from = new Date()
from.setDate(today.getDate() - 30)
setEndDate(today.toISOString().split('T')[0])
setStartDate(from.toISOString().split('T')[0])
}, [])
const handleSearch = async () => {
setLoading(true)
try {
const params: any = {
start_date: startDate,
end_date: endDate,
period: qhPeriod,
group_by: qhGroupBy
}
if (qhUserId) params.user_id = qhUserId
if (qhApiGroup) params.api_group = qhApiGroup
if (qhProjectId) params.project_id = qhProjectId
const response: any = await QuotaApiService.getQuotaHistory(params)
let rows: any = []
if (Array.isArray(response)) rows = response
else if (Array.isArray(response?.data)) rows = response.data
else if (typeof response?.data === 'string') { try { rows = JSON.parse(response.data) } catch { rows = [] } }
if (Array.isArray(rows)) {
const normalized: QuotaHistoryItem[] = rows.map((row: any) => ({
id: row.ID ?? 0,
user_id: row.UserID ?? '',
api_group: row.ApiGroup ?? '',
project_id: row.ProjectID ?? '',
day: row.Day ?? '',
account: row.Account ?? '',
model: row.Model ?? '',
quota_used: typeof row.QuotaUsed === 'number' ? row.QuotaUsed : Number(row.QuotaUsed ?? 0),
quota_used_text: row.QuotaUsed !== undefined && row.QuotaUsed !== null ? String(row.QuotaUsed) : undefined,
created_at: row.CreatedAt ?? '',
}))
setData(normalized)
}
} finally {
setLoading(false)
}
}
const handleReset = () => {
setData([])
setQhUserId('')
setQhApiGroup('')
setQhProjectId('')
setQhPeriod('daily')
setQhGroupBy(['user_id'])
const today = new Date()
const from = new Date()
from.setDate(today.getDate() - 30)
setEndDate(today.toISOString().split('T')[0])
setStartDate(from.toISOString().split('T')[0])
}
return (
<section className="panel">
<h2>Token 使</h2>
<div className="desc">ID</div>
<Form layout="inline" className="form-row" onFinish={handleSearch}>
<Form.Item label="开始日期">
<DatePicker
value={startDate ? dayjs(startDate) : undefined}
onChange={(d: Dayjs|null)=> setStartDate(d ? d.format('YYYY-MM-DD') : '')}
/>
</Form.Item>
<Form.Item>
<span className="form-split"></span>
</Form.Item>
<Form.Item label="结束日期">
<DatePicker
value={endDate ? dayjs(endDate) : undefined}
onChange={(d: Dayjs|null)=> setEndDate(d ? d.format('YYYY-MM-DD') : '')}
/>
</Form.Item>
<Form.Item label="用户ID">
<Input placeholder="可选" value={qhUserId} onChange={(e)=>setQhUserId(e.target.value)} style={{width:160}} />
</Form.Item>
<Form.Item label="API组">
<Input placeholder="可选" value={qhApiGroup} onChange={(e)=>setQhApiGroup(e.target.value)} style={{width:140}} />
</Form.Item>
<Form.Item label="项目ID">
<Input placeholder="可选" value={qhProjectId} onChange={(e)=>setQhProjectId(e.target.value)} style={{width:140}} />
</Form.Item>
<Form.Item label="统计周期">
<Select
value={qhPeriod}
onChange={(v)=>setQhPeriod(v)}
style={{width:120}}
options={[{label:'按日', value:'daily'}, {label:'按月', value:'monthly'}]}
/>
</Form.Item>
<Form.Item label="分组维度">
<Select
mode="multiple"
value={qhGroupBy}
onChange={(vals)=>setQhGroupBy(vals)}
style={{minWidth:280}}
options={groupOptions}
placeholder="选择分组"
maxTagCount="responsive"
/>
</Form.Item>
<Form.Item>
<Space>
<Button htmlType="submit" loading={loading}> Token </Button>
<Button type="primary" onClick={handleReset}></Button>
</Space>
</Form.Item>
</Form>
<div className="panel full">
<div className="table-container">
<table className="table" data-sortable>
<thead>
<tr>
<th></th>
<th>ID</th>
<th></th>
</tr>
</thead>
<tbody>
{data.length > 0 ? (
data.slice(0, 200).map((item, idx) => (
<tr key={`${item.day}-${item.user_id}-${idx}`}>
<td>{item.day}</td>
<td>{item.user_id}</td>
<td>{item.quota_used_text ?? String(item.quota_used)}</td>
</tr>
))
) : (
<tr>
<td colSpan={3} style={{textAlign: 'center', color: 'var(--muted)'}}>
Token
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</section>
)
}

489
src/pages/UserFeedback.tsx Normal file
View File

@@ -0,0 +1,489 @@
import React, { useState, useEffect } from 'react';
import {
Card,
Table,
Button,
Input,
Select,
Space,
Tag,
message,
Popconfirm,
Image,
Row,
Col,
Statistic,
DatePicker,
Drawer,
Typography,
Divider,
} from 'antd';
import { SearchOutlined, EyeOutlined, CheckOutlined, DeleteOutlined } from '@ant-design/icons';
import { UserFeedbackApiService } from '../services/api';
import dayjs from 'dayjs';
const { Option } = Select;
const { RangePicker } = DatePicker;
const { Title, Text } = Typography;
interface FeedbackItem {
id: number;
user_id: number;
content: string;
file_keys: string[]; // 原始的文件key可能为JSON字符串
file_contents?: string[]; // 后端返回的Base64编码图片内容
status: number;
handled_by?: number;
handled_at?: string;
created_at: string;
updated_at: string;
}
const UserFeedback: React.FC = () => {
const [loading, setLoading] = useState(false);
const [data, setData] = useState<FeedbackItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
// 筛选条件
const [filters, setFilters] = useState({
status: undefined as number | undefined,
user_id: undefined as number | undefined,
keyword: '',
dateRange: null as [dayjs.Dayjs, dayjs.Dayjs] | null,
});
// 详情抽屉
const [detailVisible, setDetailVisible] = useState(false);
const [selectedFeedback, setSelectedFeedback] = useState<FeedbackItem | null>(null);
// 统计信息
const [statistics, setStatistics] = useState({
total: 0,
unhandled: 0,
handled: 0,
today_count: 0,
});
// 获取反馈列表
const fetchList = async () => {
setLoading(true);
try {
const params: any = {
page,
page_size: pageSize,
};
if (filters.status !== undefined) params.status = filters.status;
if (filters.user_id) params.user_id = filters.user_id;
if (filters.keyword) params.keyword = filters.keyword;
if (filters.dateRange) {
params.start_time = filters.dateRange[0].format('YYYY-MM-DD HH:mm:ss');
params.end_time = filters.dateRange[1].format('YYYY-MM-DD HH:mm:ss');
}
const response = await UserFeedbackApiService.getFeedbackList(params);
if (response.code === 200) {
// 处理file_keys字段将JSON字符串转换为数组
const processedList = response.data.list.map((item: any) => {
let fileKeys = [];
if (typeof item.file_keys === 'string') {
try {
fileKeys = JSON.parse(item.file_keys);
} catch (e) {
console.error('解析file_keys JSON失败:', e, item.file_keys);
fileKeys = [];
}
} else if (Array.isArray(item.file_keys)) {
fileKeys = item.file_keys;
}
console.log('处理后的文件数据:', { id: item.id, file_keys: fileKeys });
return {
...item,
file_keys: fileKeys
};
});
setData(processedList);
setTotal(response.data.total);
}
} catch (error) {
message.error('获取反馈列表失败');
} finally {
setLoading(false);
}
};
// 获取统计信息
const fetchStatistics = async () => {
try {
const response = await UserFeedbackApiService.getFeedbackStatistics();
if (response.code === 200) {
setStatistics(response.data);
}
} catch (error) {
console.error('获取统计信息失败:', error);
}
};
useEffect(() => {
fetchList();
fetchStatistics();
}, [page, pageSize]);
// 查看详情
const handleViewDetail = async (id: number) => {
try {
const response = await UserFeedbackApiService.getFeedbackDetail(id);
if (response.code === 200) {
// 处理file_keys字段
let fileKeys = [];
if (typeof response.data.file_keys === 'string') {
try {
fileKeys = JSON.parse(response.data.file_keys);
} catch (e) {
console.error('解析详情file_keys JSON失败:', e, response.data.file_keys);
fileKeys = [];
}
} else if (Array.isArray(response.data.file_keys)) {
fileKeys = response.data.file_keys;
}
console.log('详情页文件数据:', { id: response.data.id, file_keys: fileKeys });
const processedData = {
...response.data,
file_keys: fileKeys,
file_contents: response.data.file_contents || [], // 获取Base64图片内容
};
setSelectedFeedback(processedData);
setDetailVisible(true);
}
} catch (error) {
message.error('获取反馈详情失败');
}
};
// 切换处理状态
const handleMarkHandled = async (id: number, currentStatus: number) => {
try {
const response = await UserFeedbackApiService.markFeedbackHandled(id, '');
if (response.code === 200) {
const newStatus = currentStatus === 0 ? 1 : 0;
message.success(newStatus === 1 ? '标记为已处理成功' : '标记为未处理成功');
fetchList();
fetchStatistics();
}
} catch (error) {
message.error('状态切换失败');
}
};
// 删除反馈
const handleDelete = async (id: number) => {
try {
const response = await UserFeedbackApiService.deleteFeedback(id);
if (response.code === 200) {
message.success('删除成功');
fetchList();
fetchStatistics();
}
} catch (error) {
message.error('删除失败');
}
};
// 搜索
const handleSearch = () => {
setPage(1);
fetchList();
};
// 重置
const handleReset = () => {
setFilters({
status: undefined,
user_id: undefined,
keyword: '',
dateRange: null,
});
setPage(1);
};
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
},
{
title: '用户ID',
dataIndex: 'user_id',
key: 'user_id',
width: 100,
},
{
title: '反馈内容',
dataIndex: 'content',
key: 'content',
ellipsis: true,
render: (text: string) => (
<div style={{ maxWidth: 200 }}>
{text.length > 50 ? `${text.substring(0, 50)}...` : text}
</div>
),
},
{
title: '文件',
dataIndex: 'file_keys',
key: 'file_keys',
width: 80,
render: (fileKeys: string[]) => (
<div>
{fileKeys && fileKeys.length > 0 ? (
<span style={{ color: '#1890ff' }}>{fileKeys.length}</span>
) : (
<span>-</span>
)}
</div>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: number) => (
<Tag color={status === 1 ? 'green' : 'orange'}>
{status === 1 ? '已处理' : '未处理'}
</Tag>
),
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180,
render: (text: string) => dayjs(text).format('YYYY-MM-DD HH:mm:ss'),
},
{
title: '操作',
key: 'action',
width: 200,
render: (_, record: FeedbackItem) => (
<Space>
<Button
type="link"
icon={<EyeOutlined />}
onClick={() => handleViewDetail(record.id)}
>
</Button>
<Button
type="link"
icon={<CheckOutlined />}
onClick={() => handleMarkHandled(record.id, record.status)}
>
{record.status === 0 ? '标记处理' : '标记未处理'}
</Button>
<Popconfirm
title="确定要删除这个反馈吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Button type="link" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div style={{ padding: '24px' }}>
<Title level={2}></Title>
{/* 统计卡片 */}
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card>
<Statistic title="总反馈数" value={statistics.total} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="未处理" value={statistics.unhandled} valueStyle={{ color: '#cf1322' }} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="已处理" value={statistics.handled} valueStyle={{ color: '#3f8600' }} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic title="今日新增" value={statistics.today_count} />
</Card>
</Col>
</Row>
{/* 筛选条件 */}
<Card style={{ marginBottom: 16 }}>
<Row gutter={16}>
<Col span={6}>
<Input
placeholder="搜索反馈内容"
value={filters.keyword}
onChange={(e) => setFilters({ ...filters, keyword: e.target.value })}
allowClear
/>
</Col>
<Col span={4}>
<Input
placeholder="用户ID"
value={filters.user_id || ''}
onChange={(e) => setFilters({ ...filters, user_id: e.target.value ? Number(e.target.value) : undefined })}
allowClear
/>
</Col>
<Col span={4}>
<Select
placeholder="状态"
value={filters.status}
onChange={(value) => setFilters({ ...filters, status: value })}
allowClear
style={{ width: '100%' }}
>
<Option value={0}></Option>
<Option value={1}></Option>
</Select>
</Col>
<Col span={6}>
<RangePicker
value={filters.dateRange}
onChange={(dates) => setFilters({ ...filters, dateRange: dates })}
showTime
style={{ width: '100%' }}
/>
</Col>
<Col span={4}>
<Space>
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>
</Button>
<Button onClick={handleReset}></Button>
</Space>
</Col>
</Row>
</Card>
{/* 列表 */}
<Card>
<Table
columns={columns}
dataSource={data}
loading={loading}
rowKey="id"
pagination={{
current: page,
pageSize: pageSize,
total: total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
onChange: (page, pageSize) => {
setPage(page);
setPageSize(pageSize);
},
}}
/>
</Card>
{/* 详情抽屉 */}
<Drawer
title="反馈详情"
width={600}
open={detailVisible}
onClose={() => setDetailVisible(false)}
>
{selectedFeedback && (
<div>
<Row gutter={[16, 16]}>
<Col span={12}>
<Text strong>ID</Text>
<Text>{selectedFeedback.id}</Text>
</Col>
<Col span={12}>
<Text strong>ID</Text>
<Text>{selectedFeedback.user_id}</Text>
</Col>
<Col span={12}>
<Text strong></Text>
<Tag color={selectedFeedback.status === 1 ? 'green' : 'orange'}>
{selectedFeedback.status === 1 ? '已处理' : '未处理'}
</Tag>
</Col>
<Col span={12}>
<Text strong></Text>
<Text>{dayjs(selectedFeedback.created_at).format('YYYY-MM-DD HH:mm:ss')}</Text>
</Col>
</Row>
<Divider />
<div>
<Text strong></Text>
<div style={{ marginTop: 8, padding: 12, background: '#f5f5f5', borderRadius: 4 }}>
{selectedFeedback.content}
</div>
</div>
{selectedFeedback.file_contents && selectedFeedback.file_contents.length > 0 && (
<>
<Divider />
<div>
<Text strong></Text>
<div style={{ marginTop: 8 }}>
{selectedFeedback.file_contents.map((fileContent, index) => (
<div key={index} style={{ marginBottom: 8 }}>
<Image
src={fileContent}
alt={`反馈图片 ${index + 1}`}
style={{ maxWidth: '100%', borderRadius: '4px', border: '1px solid #eee' }}
/>
</div>
))}
</div>
</div>
</>
)}
{selectedFeedback.status === 1 && (
<>
<Divider />
<Row gutter={[16, 16]}>
<Col span={12}>
<Text strong>ID</Text>
<Text>{selectedFeedback.handled_by}</Text>
</Col>
<Col span={12}>
<Text strong></Text>
<Text>{dayjs(selectedFeedback.handled_at).format('YYYY-MM-DD HH:mm:ss')}</Text>
</Col>
</Row>
</>
)}
</div>
)}
</Drawer>
</div>
);
};
export default UserFeedback;

View File

@@ -0,0 +1,328 @@
import React, { useEffect, useState } from 'react';
import {
Table,
Button,
Modal,
Form,
Input,
InputNumber,
Space,
Popconfirm,
message,
Row,
Col,
Card,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
} from '@ant-design/icons';
import type { UserLevelConfig } from '../types/userLevelConfig';
import {
getUserLevelConfigList,
createUserLevelConfig,
updateUserLevelConfig,
deleteUserLevelConfig,
} from '../services/userLevelConfigApi';
export default function UserLevelConfigs() {
const [loading, setLoading] = useState(false);
const [list, setList] = useState<UserLevelConfig[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [size, setSize] = useState(10);
const [editOpen, setEditOpen] = useState(false);
const [editing, setEditing] = useState<UserLevelConfig | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [form] = Form.useForm();
const [createForm] = Form.useForm();
const fetchList = async () => {
setLoading(true);
try {
const res = await getUserLevelConfigList({ page, size });
console.log('用户等级配置 API 响应:', res);
// 处理响应数据:如果是数组直接使用,否则取 data 字段
if (Array.isArray(res)) {
setList(res);
setTotal(res.length);
} else {
setList(res?.data ?? []);
setTotal(res?.total ?? 0);
}
} catch (e) {
console.error('获取列表失败:', e);
message.error('获取列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchList();
}, [page, size]);
const openEdit = (config: UserLevelConfig) => {
setEditing(config);
form.setFieldsValue({
level_name: config.level_name,
project_limit: config.project_limit,
coder_vm_limit: config.coder_vm_limit,
browser_vm_limit: config.browser_vm_limit,
process_limit: config.process_limit,
description: config.description,
sort_order: config.sort_order,
});
setEditOpen(true);
};
const submitEdit = async () => {
try {
const values = await form.validateFields();
if (!editing) return;
await updateUserLevelConfig(editing.id, values);
message.success('更新成功');
setEditOpen(false);
fetchList();
} catch (error) {
message.error('更新失败');
}
};
const openCreate = () => {
createForm.resetFields();
setCreateOpen(true);
};
const submitCreate = async () => {
try {
const values = await createForm.validateFields();
await createUserLevelConfig(values);
message.success('创建成功');
setCreateOpen(false);
fetchList();
} catch (error: any) {
message.error(error?.response?.data?.message || '创建失败');
}
};
const handleDelete = async (config: UserLevelConfig) => {
try {
await deleteUserLevelConfig(config.id);
message.success('删除成功');
fetchList();
} catch (error) {
message.error('删除失败');
}
};
const columns = [
{
title: '等级名称',
dataIndex: 'level_name',
key: 'level_name',
},
{
title: '等级代码',
dataIndex: 'level_code',
key: 'level_code',
},
{
title: '项目数限制',
dataIndex: 'project_limit',
key: 'project_limit',
render: (value: number) => (value === 0 ? '不限' : value),
},
{
title: 'Coder VM上限',
dataIndex: 'coder_vm_limit',
key: 'coder_vm_limit',
render: (value: number) => (value === 0 ? '不限' : value),
},
{
title: 'Browser VM上限',
dataIndex: 'browser_vm_limit',
key: 'browser_vm_limit',
render: (value: number) => (value === 0 ? '不限' : value),
},
{
title: '进程上限',
dataIndex: 'process_limit',
key: 'process_limit',
render: (value: number) => (value === 0 ? '不限' : value),
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
},
{
title: '排序',
dataIndex: 'sort_order',
key: 'sort_order',
},
{
title: '操作',
key: 'action',
render: (_: any, config: UserLevelConfig) => (
<Space>
<Button type="link" icon={<EditOutlined />} onClick={() => openEdit(config)}>
</Button>
<Popconfirm title="确定删除该等级配置?" onConfirm={() => handleDelete(config)}>
<Button type="link" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={24}>
<Card>
<div style={{ marginBottom: 16 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</div>
<Table
dataSource={list}
columns={columns}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize: size,
total: total,
onChange: (p, s) => {
setPage(p);
setSize(s);
},
}}
/>
</Card>
</Col>
</Row>
{/* 编辑弹窗 */}
<Modal
title="编辑等级配置"
open={editOpen}
onOk={submitEdit}
onCancel={() => setEditOpen(false)}
>
<Form form={form} layout="vertical">
<Form.Item
name="level_name"
label="等级名称"
rules={[{ required: true, message: '请输入等级名称' }]}
>
<Input placeholder="请输入等级名称" />
</Form.Item>
<Form.Item
name="project_limit"
label="项目数限制0表示不限"
rules={[{ required: true, message: '请输入项目数限制' }]}
>
<InputNumber min={0} style={{ width: '100%' }} placeholder="请输入项目数限制" />
</Form.Item>
<Form.Item
name="coder_vm_limit"
label="Coder VM上限0表示不限"
rules={[{ required: true, message: '请输入Coder VM上限' }]}
>
<InputNumber min={0} style={{ width: '100%' }} placeholder="请输入Coder VM上限" />
</Form.Item>
<Form.Item
name="browser_vm_limit"
label="Browser VM上限0表示不限"
rules={[{ required: true, message: '请输入Browser VM上限' }]}
>
<InputNumber min={0} style={{ width: '100%' }} placeholder="请输入Browser VM上限" />
</Form.Item>
<Form.Item
name="process_limit"
label="进程上限0表示不限"
rules={[{ required: true, message: '请输入进程上限' }]}
>
<InputNumber min={0} style={{ width: '100%' }} placeholder="请输入进程上限" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input.TextArea rows={3} placeholder="请输入描述" />
</Form.Item>
<Form.Item name="sort_order" label="排序顺序">
<InputNumber min={0} style={{ width: '100%' }} placeholder="请输入排序顺序" />
</Form.Item>
</Form>
</Modal>
{/* 创建弹窗 */}
<Modal
title="新建等级配置"
open={createOpen}
onOk={submitCreate}
onCancel={() => setCreateOpen(false)}
>
<Form form={createForm} layout="vertical">
<Form.Item
name="level_name"
label="等级名称"
rules={[{ required: true, message: '请输入等级名称' }]}
>
<Input placeholder="请输入等级名称" />
</Form.Item>
<Form.Item
name="level_code"
label="等级代码"
rules={[{ required: true, message: '请输入等级代码' }]}
>
<Input placeholder="请输入等级代码vip_plus" />
</Form.Item>
<Form.Item
name="project_limit"
label="项目数限制0表示不限"
rules={[{ required: true, message: '请输入项目数限制' }]}
initialValue={0}
>
<InputNumber min={0} style={{ width: '100%' }} placeholder="请输入项目数限制" />
</Form.Item>
<Form.Item
name="coder_vm_limit"
label="Coder VM上限0表示不限"
rules={[{ required: true, message: '请输入Coder VM上限' }]}
initialValue={0}
>
<InputNumber min={0} style={{ width: '100%' }} placeholder="请输入Coder VM上限" />
</Form.Item>
<Form.Item
name="browser_vm_limit"
label="Browser VM上限0表示不限"
rules={[{ required: true, message: '请输入Browser VM上限' }]}
initialValue={0}
>
<InputNumber min={0} style={{ width: '100%' }} placeholder="请输入Browser VM上限" />
</Form.Item>
<Form.Item
name="process_limit"
label="进程上限0表示不限"
rules={[{ required: true, message: '请输入进程上限' }]}
initialValue={0}
>
<InputNumber min={0} style={{ width: '100%' }} placeholder="请输入进程上限" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input.TextArea rows={3} placeholder="请输入描述" />
</Form.Item>
<Form.Item name="sort_order" label="排序顺序" initialValue={0}>
<InputNumber min={0} style={{ width: '100%' }} placeholder="请输入排序顺序" />
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,585 @@
import React, { useState, useEffect } from 'react';
import {
Table,
Button,
Modal,
Form,
Input,
Select,
message,
Space,
Popconfirm,
Tag,
Card,
Row,
Col,
Statistic,
Transfer
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
UserOutlined,
TeamOutlined,
LockOutlined,
UnlockOutlined,
SettingOutlined
} from '@ant-design/icons';
// 移除权限控制相关导入
import { UserApiService } from '../services/userApi';
import { RoleApiService } from '../services/roleApi';
import { GoalfyMaxUsersApi } from '../services/api';
import type { User, UserCreateRequest, UserUpdateRequest, UserListRequest } from '../services/userApi';
import type { Role } from '../services/roleApi';
const { Option } = Select;
// GoalfyMax用户类型
interface GoalfyMaxUser {
id: number;
user_id: number;
username: string;
email: string;
nickname?: string;
}
const UserManagement: React.FC = () => {
const [users, setUsers] = useState<User[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0
});
const [form] = Form.useForm();
// 变更角色相关状态
const [changeRoleModalVisible, setChangeRoleModalVisible] = useState(false);
const [changeRoleForm] = Form.useForm();
const [goalfyMaxUsers, setGoalfyMaxUsers] = useState<GoalfyMaxUser[]>([]);
const [loadingGoalfyUsers, setLoadingGoalfyUsers] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
const fetchUsers = async (page = 1, size = 10) => {
setLoading(true);
try {
const response = await UserApiService.getUsers({
page,
size
});
setUsers(response.users);
setPagination(prev => ({
...prev,
current: page,
pageSize: size,
total: response.total
}));
} catch (error) {
console.error('获取用户列表失败:', error);
message.error('获取用户列表失败');
setUsers([]); // 重置用户列表
} finally {
setLoading(false);
}
};
const fetchRoles = async () => {
try {
console.log('🔍 [UserManagement] 获取角色列表');
const response = await RoleApiService.getRoles({
page: 1,
size: 100
});
console.log('🔍 [UserManagement] 角色列表响应:', response);
// 处理不同的响应格式
let rolesData = [];
if (response.data && response.data.roles && Array.isArray(response.data.roles)) {
rolesData = response.data.roles;
} else if (response.data && Array.isArray(response.data)) {
rolesData = response.data;
} else if (response.roles && Array.isArray(response.roles)) {
rolesData = response.roles;
} else if (Array.isArray(response)) {
rolesData = response;
}
console.log('🔍 [UserManagement] 处理后的角色数据:', rolesData);
setRoles(rolesData);
} catch (error) {
console.error('❌ [UserManagement] 获取角色列表失败:', error);
message.error('获取角色列表失败');
setRoles([]);
}
};
const handleCreate = () => {
setEditingUser(null);
form.resetFields();
setModalVisible(true);
};
// 打开变更角色模态框
const handleOpenChangeRole = async () => {
setChangeRoleModalVisible(true);
changeRoleForm.resetFields();
setSelectedUserId(null);
// 加载GoalfyMax用户列表
await fetchGoalfyMaxUsers();
};
// 获取GoalfyMax用户列表
const fetchGoalfyMaxUsers = async () => {
setLoadingGoalfyUsers(true);
try {
const res = await GoalfyMaxUsersApi.list({
page: 1,
size: 1000, // 获取所有用户,可以根据需要调整
});
setGoalfyMaxUsers(res?.data?.users ?? []);
} catch (e) {
message.error('获取用户列表失败');
console.error('获取GoalfyMax用户列表失败', e);
} finally {
setLoadingGoalfyUsers(false);
}
};
// 当选择的用户改变时,检查当前角色
const handleUserSelectChange = async (userId: number) => {
setSelectedUserId(userId);
try {
const roleInfo = await UserApiService.checkUserRole(userId);
changeRoleForm.setFieldsValue({
system_role: roleInfo.current_role,
});
} catch (error: any) {
console.error('检查用户角色失败:', error);
message.error(error?.response?.data?.message || '检查用户角色失败');
// 如果检查失败默认设置为custom
changeRoleForm.setFieldsValue({
system_role: 'custom',
});
}
};
// 提交变更角色
const handleChangeRoleSubmit = async (values: any) => {
if (!values.user_id) {
message.error('请选择用户');
return;
}
try {
await UserApiService.changeUserSystemRole(values.user_id, values.system_role);
message.success('角色变更成功');
setChangeRoleModalVisible(false);
changeRoleForm.resetFields();
setSelectedUserId(null);
// 刷新用户列表
fetchUsers(pagination.current, pagination.pageSize);
} catch (error: any) {
console.error('变更角色失败:', error);
message.error(error?.response?.data?.message || '变更角色失败');
}
};
const handleEdit = async (user: User) => {
setEditingUser(user);
console.log('🔍 [UserManagement] 编辑用户:', user);
console.log('🔍 [UserManagement] 用户角色:', user.role);
// 从用户数据中获取当前角色ID
let currentRoleId = undefined;
if (user.role && user.role.id) {
currentRoleId = user.role.id;
}
console.log('🔍 [UserManagement] 设置表单角色ID:', currentRoleId);
form.setFieldsValue({
username: user.username,
email: user.email,
nickname: user.nickname,
roleId: currentRoleId, // 设置当前角色
status: user.status,
ssoProvider: user.ssoProvider
});
setModalVisible(true);
};
const handleSubmit = async (values: any) => {
try {
if (editingUser) {
// 更新用户信息(包括角色)
await UserApiService.updateUser(editingUser.id, values);
message.success('用户更新成功');
} else {
// 创建用户(包括角色)
await UserApiService.createUser(values);
message.success('用户创建成功');
}
setModalVisible(false);
fetchUsers(pagination.current, pagination.pageSize);
} catch (error) {
console.error('操作失败:', error);
message.error('操作失败');
}
};
const handleDelete = async (id: number) => {
try {
await UserApiService.deleteUser(id);
message.success('用户删除成功');
fetchUsers(pagination.current, pagination.pageSize);
} catch (error) {
console.error('删除失败:', error);
message.error('删除失败');
}
};
const handleStatusChange = async (id: number, status: number) => {
try {
await UserApiService.updateUserStatus(id, status);
message.success('用户状态更新成功');
fetchUsers(pagination.current, pagination.pageSize);
} catch (error) {
console.error('状态更新失败:', error);
message.error('状态更新失败');
}
};
const columns = [
{
title: '用户名',
dataIndex: 'username',
key: 'username',
render: (text: string) => (
<Space>
<UserOutlined />
{text}
</Space>
),
},
{
title: '邮箱',
dataIndex: 'email',
key: 'email',
},
{
title: '昵称',
dataIndex: 'nickname',
key: 'nickname',
},
{
title: '角色',
dataIndex: 'role',
key: 'role',
render: (role: any, record: User) => {
// 显示用户角色
if (role && role.name) {
return (
<Tag
color={role.level === 1 ? 'red' : role.level === 2 ? 'orange' : role.level === 3 ? 'blue' : role.level === 4 ? 'green' : 'default'}
>
<TeamOutlined /> {role.name} (L{role.level})
</Tag>
);
}
// 兼容旧数据
return (
<Tag color={record.role === '超级管理员' ? 'red' : 'blue'}>
<TeamOutlined /> {record.role}
</Tag>
);
},
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: number) => (
<Tag color={status === 1 ? 'green' : 'red'}>
{status === 1 ? '正常' : '禁用'}
</Tag>
),
},
{
title: 'SSO提供商',
dataIndex: 'ssoProvider',
key: 'ssoProvider',
render: (provider: string) => (
<Tag color="blue">{provider || 'default'}</Tag>
),
},
{
title: '登录次数',
dataIndex: 'loginCount',
key: 'loginCount',
},
{
title: '最后登录',
dataIndex: 'lastLoginAt',
key: 'lastLoginAt',
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
},
{
title: '操作',
key: 'action',
render: (_, record: User) => (
<Space>
<Button
type="link"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
>
</Button>
<Button
type="link"
icon={record.status === 1 ? <LockOutlined /> : <UnlockOutlined />}
onClick={() => handleStatusChange(record.id, record.status === 1 ? 0 : 1)}
>
{record.status === 1 ? '禁用' : '启用'}
</Button>
<Popconfirm
title="确定要删除这个用户吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
>
<Button type="link" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
useEffect(() => {
fetchUsers(1, 10);
fetchRoles(); // 获取角色列表
}, []);
const totalUsers = pagination.total;
const activeUsers = (users || []).filter(user => user.status === 1).length;
const inactiveUsers = (users || []).filter(user => user.status === 0).length;
return (
<div>
{/* 统计卡片 */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}>
<Card>
<Statistic
title="后台用户总数"
value={totalUsers}
prefix={<UserOutlined />}
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="正常用户"
value={activeUsers}
valueStyle={{ color: '#3f8600' }}
prefix={<UserOutlined />}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="禁用用户"
value={inactiveUsers}
valueStyle={{ color: '#cf1322' }}
prefix={<LockOutlined />}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="系统管理员"
value={(users || []).filter(user => user.role === '系统管理员').length}
valueStyle={{ color: '#722ed1' }}
prefix={<TeamOutlined />}
/>
</Card>
</Col>
</Row>
{/* 操作栏 */}
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<h2></h2>
<Button type="primary" icon={<SettingOutlined />} onClick={handleOpenChangeRole}>
</Button>
</div>
{/* 用户表格 */}
<Table
columns={columns}
dataSource={users}
loading={loading}
rowKey="id"
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: pagination.total,
showSizeChanger: true,
showQuickJumper: true,
onChange: (page, size) => {
fetchUsers(page, size);
},
onShowSizeChange: (current, size) => {
fetchUsers(current, size);
}
}}
/>
{/* 用户表单弹窗 */}
<Modal
title={editingUser ? '编辑用户' : '创建用户'}
open={modalVisible}
onCancel={() => setModalVisible(false)}
onOk={() => form.submit()}
width={600}
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
>
<Form.Item
name="username"
label="用户名"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input disabled={!!editingUser} />
</Form.Item>
<Form.Item
name="email"
label="邮箱"
rules={[{ required: true, message: '请输入邮箱' }]}
>
<Input />
</Form.Item>
{!editingUser && (
<Form.Item
name="password"
label="密码"
rules={[{ required: true, message: '请输入密码' }, { min: 8, message: '密码至少8位' }]}
>
<Input.Password placeholder="请输入密码" />
</Form.Item>
)}
<Form.Item
name="nickname"
label="昵称"
>
<Input />
</Form.Item>
<Form.Item
name="roleId"
label="角色"
rules={[{ required: true, message: '请选择角色' }]}
>
<Select placeholder="请选择角色">
{roles.map(role => (
<Option key={role.id} value={role.id}>
{role.name} (L{role.level}) - {role.description}
</Option>
))}
</Select>
</Form.Item>
<Form.Item
name="ssoProvider"
label="SSO提供商"
rules={[{ required: true, message: '请输入SSO提供商' }]}
>
<Input placeholder="如: default, google, github" />
</Form.Item>
{editingUser && (
<Form.Item
name="status"
label="状态"
>
<Select>
<Option value={1}></Option>
<Option value={0}></Option>
</Select>
</Form.Item>
)}
</Form>
</Modal>
{/* 变更用户角色弹窗 */}
<Modal
title="变更用户角色"
open={changeRoleModalVisible}
onCancel={() => {
setChangeRoleModalVisible(false);
changeRoleForm.resetFields();
setSelectedUserId(null);
}}
onOk={() => changeRoleForm.submit()}
width={600}
okText="变更"
>
<Form
form={changeRoleForm}
layout="vertical"
onFinish={handleChangeRoleSubmit}
>
<Form.Item
name="user_id"
label="选择用户"
rules={[{ required: true, message: '请选择用户' }]}
>
<Select
placeholder="请选择GoalfyMax用户"
showSearch
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase()) ||
(option?.value ?? '').toString().includes(input)
}
loading={loadingGoalfyUsers}
onChange={handleUserSelectChange}
options={goalfyMaxUsers.map(user => ({
value: user.user_id,
label: `${user.username} (${user.email})${user.nickname ? ` - ${user.nickname}` : ''}`,
}))}
/>
</Form.Item>
<Form.Item
name="system_role"
label="系统角色"
rules={[{ required: true, message: '请选择系统角色' }]}
>
<Select placeholder="请选择系统角色">
<Option value="custom"></Option>
<Option value="sys_admin"></Option>
</Select>
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default UserManagement;

View File

@@ -0,0 +1,202 @@
import React from 'react'
import { Table, Button, Modal, Form, Input, InputNumber, Switch, Space } from 'antd'
import { QuotaApiService } from '../services/api'
type QuotaRow = {
id: number
user_id: string
project_limit: number
coder_vm_limit: number
browser_vm_limit: number
process_limit: number
enabled?: boolean
description?: string
}
export default function UserProjectQuotaPage() {
const [loading, setLoading] = React.useState(false)
const [rows, setRows] = React.useState<QuotaRow[]>([])
const [open, setOpen] = React.useState(false)
const [editOpen, setEditOpen] = React.useState(false)
const [currentRow, setCurrentRow] = React.useState<QuotaRow | null>(null)
const [form] = Form.useForm()
const [editForm] = Form.useForm()
const fetchList = async () => {
setLoading(true)
try {
const resp = await QuotaApiService.listUserProjectQuota({ page: 1, size: 100 })
// 兼容 {code,message,data} 或直接数组
const data = Array.isArray(resp?.data) ? resp.data : Array.isArray(resp) ? resp : (resp?.data?.list || [])
setRows(Array.isArray(data) ? data : [])
} finally {
setLoading(false)
}
}
React.useEffect(() => { fetchList() }, [])
const submitCreate = async () => {
const values = await form.validateFields()
if (!values.user_id || String(values.user_id).trim() === '') {
form.setFields([{ name:'user_id', errors:['用户ID不能为空'] }])
return
}
await QuotaApiService.createUserProjectQuota({
user_id: String(values.user_id).trim(),
project_limit: Number(values.project_limit),
coder_vm_limit: Number(values.coder_vm_limit),
browser_vm_limit: Number(values.browser_vm_limit),
process_limit: Number(values.process_limit),
enabled: values.enabled,
description: values.description?.trim() || undefined,
})
setOpen(false)
form.resetFields()
fetchList()
}
const submitEdit = async () => {
const values = await editForm.validateFields()
if (!currentRow) return
const uid = String(values.user_id ?? '').trim()
await QuotaApiService.updateUserProjectQuota(currentRow.id, {
// 后端可能需要 user_id一并传递取自编辑窗展示值
// @ts-ignore
user_id: uid,
project_limit: Number(values.project_limit),
coder_vm_limit: Number(values.coder_vm_limit),
browser_vm_limit: Number(values.browser_vm_limit),
process_limit: Number(values.process_limit),
enabled: values.enabled,
description: values.description?.trim() || undefined,
})
setEditOpen(false)
setCurrentRow(null)
editForm.resetFields()
fetchList()
}
return (
<section className="panel">
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center'}}>
<h2 style={{margin:0}}></h2>
<Button type="primary" onClick={()=>{ form.resetFields(); setOpen(true) }}></Button>
</div>
<div style={{height:12}} />
<Table
loading={loading}
rowKey={(r)=> String(r.id)}
dataSource={rows}
columns={[
{ title:'ID', dataIndex:'id', width:80 },
{ title:'用户ID', dataIndex:'user_id', width:160 },
{ title:'项目上限', dataIndex:'project_limit', width:120 },
{ title:'Coder VM上限', dataIndex:'coder_vm_limit', width:120 },
{ title:'Browser VM上限', dataIndex:'browser_vm_limit', width:120 },
{ title:'进程上限', dataIndex:'process_limit', width:120 },
{ title:'启用', dataIndex:'enabled', render:(v:boolean)=> v? '是':'否', width:100 },
{ title:'描述', dataIndex:'description' },
{
title:'操作', key:'actions', fixed:'right' as const, width:160,
render: (_: any, row: QuotaRow) => (
<Space>
<Button size="small" onClick={()=>{
setCurrentRow(row)
editForm.setFieldsValue({
user_id: row.user_id,
project_limit: row.project_limit,
coder_vm_limit: row.coder_vm_limit,
browser_vm_limit: row.browser_vm_limit,
process_limit: row.process_limit,
enabled: row.enabled ?? true,
description: row.description || '',
})
setEditOpen(true)
}}></Button>
<Button size="small" danger onClick={async()=>{
await QuotaApiService.deleteUserProjectQuota(row.id)
fetchList()
}}></Button>
</Space>
)
}
]}
pagination={false}
/>
<Modal
title="创建用户项目配额"
open={open}
onOk={submitCreate}
onCancel={()=>{ setOpen(false); form.resetFields() }}
okText="创建"
cancelText="取消"
>
<Form form={form} layout="vertical">
<Form.Item label="用户ID" name="user_id" rules={[{ required: true, message: '请输入用户ID' }]}>
<Input placeholder="例如123" />
</Form.Item>
<Form.Item label="项目上限" name="project_limit" rules={[{ required: true, message: '请输入项目上限' }]}>
<InputNumber min={0} style={{ width:'100%' }} />
</Form.Item>
<Space.Compact block>
<Form.Item label="Coder VM上限" name="coder_vm_limit" rules={[{ required: true, message: '请输入Coder VM上限' }]} style={{ flex:1 }}>
<InputNumber min={0} style={{ width:'100%' }} />
</Form.Item>
<Form.Item label="Browser VM上限" name="browser_vm_limit" rules={[{ required: true, message: '请输入Browser VM上限' }]} style={{ flex:1 }}>
<InputNumber min={0} style={{ width:'100%' }} />
</Form.Item>
</Space.Compact>
<Form.Item label="进程上限" name="process_limit" rules={[{ required: true, message: '请输入进程上限' }]}>
<InputNumber min={0} style={{ width:'100%' }} />
</Form.Item>
<Form.Item label="启用" name="enabled" valuePropName="checked" initialValue={true}>
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
</Form.Item>
<Form.Item label="描述" name="description">
<Input.TextArea rows={3} placeholder="可选" />
</Form.Item>
</Form>
</Modal>
<Modal
title="编辑用户项目配额"
open={editOpen}
onOk={submitEdit}
onCancel={()=>{ setEditOpen(false); setCurrentRow(null); editForm.resetFields() }}
okText="保存"
cancelText="取消"
>
<Form form={editForm} layout="vertical">
<Form.Item label="用户ID" name="user_id">
<Input disabled />
</Form.Item>
<Form.Item label="项目上限" name="project_limit" rules={[{ required: true, message: '请输入项目上限' }]}>
<InputNumber min={0} style={{ width:'100%' }} />
</Form.Item>
<Space.Compact block>
<Form.Item label="Coder VM上限" name="coder_vm_limit" rules={[{ required: true, message: '请输入Coder VM上限' }]} style={{ flex:1 }}>
<InputNumber min={0} style={{ width:'100%' }} />
</Form.Item>
<Form.Item label="Browser VM上限" name="browser_vm_limit" rules={[{ required: true, message: '请输入Browser VM上限' }]} style={{ flex:1 }}>
<InputNumber min={0} style={{ width:'100%' }} />
</Form.Item>
</Space.Compact>
<Form.Item label="进程上限" name="process_limit" rules={[{ required: true, message: '请输入进程上限' }]}>
<InputNumber min={0} style={{ width:'100%' }} />
</Form.Item>
<Form.Item label="启用" name="enabled" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
</Form.Item>
<Form.Item label="描述" name="description">
<Input.TextArea rows={3} placeholder="可选" />
</Form.Item>
</Form>
</Modal>
</section>
)
}

View File

@@ -0,0 +1,418 @@
import React, { useState, useEffect } from 'react';
import {
Card,
Table,
Button,
Input,
Select,
Space,
Form,
message,
Switch,
Typography,
InputNumber,
} from 'antd';
import { SearchOutlined, ReloadOutlined, EditOutlined } from '@ant-design/icons';
import { VendorModelPricingApi } from '../services/api';
const { Option } = Select;
const { Title } = Typography;
interface VendorModelPricingItem {
id: number;
provider: string;
account: string;
model_name: string;
input_price: number;
output_price: number;
cache_read_price: number;
cache_create_price: number;
price_ratio?: number;
enabled: boolean;
created_at: string;
updated_at: string;
}
const VendorModelPricing: React.FC = () => {
const [loading, setLoading] = useState(false);
const [data, setData] = useState<VendorModelPricingItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [providers, setProviders] = useState<string[]>([]);
const [editingCell, setEditingCell] = useState<{ rowId: number; field: string } | null>(null);
const [editingValue, setEditingValue] = useState<number>(0);
const [form] = Form.useForm();
// 获取数据
const fetchData = async (searchParams: any = {}) => {
setLoading(true);
try {
const response = await VendorModelPricingApi.getList({
...searchParams,
page,
size: pageSize,
});
setData(response.data || []);
setTotal(response.total || 0);
} catch (error) {
console.error('获取数据失败:', error);
message.error('获取数据失败');
} finally {
setLoading(false);
}
};
// 获取供应商列表
const fetchProviders = async () => {
try {
const response = await VendorModelPricingApi.getProviders();
setProviders(response.data || []);
} catch (error) {
console.error('获取供应商列表失败:', error);
}
};
useEffect(() => {
fetchData();
fetchProviders();
}, [page, pageSize]);
// 更新价格
const handleUpdatePricing = async (id: number, field: string, value: number) => {
try {
await VendorModelPricingApi.updatePricing(id, { [field]: value });
message.success('价格更新成功');
setEditingCell(null);
fetchData();
} catch (error) {
message.error('价格更新失败');
console.error('更新价格失败:', error);
}
};
// 更新状态
const handleUpdateStatus = async (id: number, enabled: boolean) => {
try {
await VendorModelPricingApi.updatePricing(id, { enabled });
message.success('状态更新成功');
fetchData();
} catch (error) {
message.error('状态更新失败');
console.error('更新状态失败:', error);
}
};
// 搜索
const handleSearch = (values: any) => {
setPage(1);
fetchData(values);
};
// 重置
const handleReset = () => {
form.resetFields();
setPage(1);
fetchData();
};
// 开始编辑
const handleEdit = (rowId: number, field: string, value: number) => {
setEditingCell({ rowId, field });
setEditingValue(value);
};
// 保存编辑
const handleSaveEdit = (rowId: number, field: string) => {
handleUpdatePricing(rowId, field, editingValue);
};
// 取消编辑
const handleCancelEdit = () => {
setEditingCell(null);
};
const columns = [
{
title: '供应商',
dataIndex: 'provider',
key: 'provider',
width: 120,
},
{
title: '模型名称',
dataIndex: 'model_name',
key: 'model_name',
width: 150,
},
{
title: '输入价格($/1K)',
dataIndex: 'input_price',
key: 'input_price',
width: 140,
render: (value: number, record: VendorModelPricingItem) => {
if (editingCell?.rowId === record.id && editingCell?.field === 'input_price') {
return (
<Space.Compact>
<InputNumber
value={editingValue}
onChange={(val) => setEditingValue(val || 0)}
precision={4}
style={{ width: 100 }}
autoFocus
/>
<Button
type="primary"
size="small"
onClick={() => handleSaveEdit(record.id, 'input_price')}
>
</Button>
<Button size="small" onClick={handleCancelEdit}>
</Button>
</Space.Compact>
);
}
return (
<div
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }}
onClick={() => handleEdit(record.id, 'input_price', value)}
>
<span>${value.toFixed(4)}</span>
<EditOutlined style={{ marginLeft: 8, fontSize: 12, color: '#1890ff' }} />
</div>
);
},
},
{
title: '输出价格($/1K)',
dataIndex: 'output_price',
key: 'output_price',
width: 140,
render: (value: number, record: VendorModelPricingItem) => {
if (editingCell?.rowId === record.id && editingCell?.field === 'output_price') {
return (
<Space.Compact>
<InputNumber
value={editingValue}
onChange={(val) => setEditingValue(val || 0)}
precision={4}
style={{ width: 100 }}
autoFocus
/>
<Button
type="primary"
size="small"
onClick={() => handleSaveEdit(record.id, 'output_price')}
>
</Button>
<Button size="small" onClick={handleCancelEdit}>
</Button>
</Space.Compact>
);
}
return (
<div
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }}
onClick={() => handleEdit(record.id, 'output_price', value)}
>
<span>${value.toFixed(4)}</span>
<EditOutlined style={{ marginLeft: 8, fontSize: 12, color: '#1890ff' }} />
</div>
);
},
},
{
title: '缓存读取价格($/1M)',
dataIndex: 'cache_read_price',
key: 'cache_read_price',
width: 160,
render: (value: number, record: VendorModelPricingItem) => {
if (editingCell?.rowId === record.id && editingCell?.field === 'cache_read_price') {
return (
<Space.Compact>
<InputNumber
value={editingValue}
onChange={(val) => setEditingValue(val || 0)}
precision={4}
style={{ width: 100 }}
autoFocus
/>
<Button
type="primary"
size="small"
onClick={() => handleSaveEdit(record.id, 'cache_read_price')}
>
</Button>
<Button size="small" onClick={handleCancelEdit}>
</Button>
</Space.Compact>
);
}
return (
<div
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }}
onClick={() => handleEdit(record.id, 'cache_read_price', value)}
>
<span>${value.toFixed(4)}</span>
<EditOutlined style={{ marginLeft: 8, fontSize: 12, color: '#1890ff' }} />
</div>
);
},
},
{
title: '缓存创建价格($/1M)',
dataIndex: 'cache_create_price',
key: 'cache_create_price',
width: 160,
render: (value: number, record: VendorModelPricingItem) => {
if (editingCell?.rowId === record.id && editingCell?.field === 'cache_create_price') {
return (
<Space.Compact>
<InputNumber
value={editingValue}
onChange={(val) => setEditingValue(val || 0)}
precision={4}
style={{ width: 100 }}
autoFocus
/>
<Button
type="primary"
size="small"
onClick={() => handleSaveEdit(record.id, 'cache_create_price')}
>
</Button>
<Button size="small" onClick={handleCancelEdit}>
</Button>
</Space.Compact>
);
}
return (
<div
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }}
onClick={() => handleEdit(record.id, 'cache_create_price', value)}
>
<span>${value.toFixed(4)}</span>
<EditOutlined style={{ marginLeft: 8, fontSize: 12, color: '#1890ff' }} />
</div>
);
},
},
{
title: '价格倍率',
dataIndex: 'price_ratio',
key: 'price_ratio',
width: 120,
render: (value: number | undefined, record: VendorModelPricingItem) => {
if (editingCell?.rowId === record.id && editingCell?.field === 'price_ratio') {
return (
<Space.Compact>
<InputNumber
value={editingValue}
onChange={(val) => setEditingValue(val || 0)}
precision={2}
min={0}
max={100}
step={0.01}
style={{ width: 120 }}
autoFocus
/>
<Button
type="primary"
size="small"
onClick={() => handleSaveEdit(record.id, 'price_ratio')}
>
</Button>
<Button size="small" onClick={handleCancelEdit}>
</Button>
</Space.Compact>
);
}
const ratio = Number(value ?? 1);
return (
<div
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }}
onClick={() => handleEdit(record.id, 'price_ratio', ratio)}
>
<span>{ratio.toFixed(2)}</span>
<EditOutlined style={{ marginLeft: 8, fontSize: 12, color: '#1890ff' }} />
</div>
);
},
},
{
title: '状态',
dataIndex: 'enabled',
key: 'enabled',
width: 80,
render: (value: boolean, record: VendorModelPricingItem) => (
<Switch
checked={value}
onChange={(checked) => handleUpdateStatus(record.id, checked)}
/>
),
},
];
return (
<Card title={<Title level={4}></Title>}>
<Form form={form} onFinish={handleSearch} layout="inline" style={{ marginBottom: 16 }}>
<Form.Item name="provider">
<Select placeholder="选择供应商" allowClear style={{ width: 150 }}>
{providers.map(provider => (
<Option key={provider} value={provider}>{provider}</Option>
))}
</Select>
</Form.Item>
<Form.Item name="model">
<Input placeholder="模型名称" style={{ width: 180 }} />
</Form.Item>
<Form.Item name="status">
<Select placeholder="状态" allowClear style={{ width: 120 }}>
<Option value="enabled"></Option>
<Option value="disabled"></Option>
</Select>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>
</Button>
<Button onClick={handleReset} icon={<ReloadOutlined />}>
</Button>
</Space>
</Form.Item>
</Form>
<Table
dataSource={data}
columns={columns}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize: pageSize,
total: total,
showSizeChanger: true,
showTotal: (total) => `${total}`,
onChange: (page, pageSize) => {
setPage(page);
setPageSize(pageSize || 10);
},
}}
/>
</Card>
);
};
export default VendorModelPricing;

732
src/pages/VmPricing.tsx Normal file
View File

@@ -0,0 +1,732 @@
import React, { useState, useEffect } from 'react';
import {
Card,
Table,
Button,
Input,
Select,
Space,
Form,
message,
Switch,
Typography,
InputNumber,
Tabs,
Modal,
Popconfirm,
} from 'antd';
import { SearchOutlined, ReloadOutlined, EditOutlined, PlusOutlined, DeleteOutlined, CheckOutlined } from '@ant-design/icons';
import { VmPricingApi } from '../services/api';
const { Option } = Select;
const { Title } = Typography;
const { TabPane } = Tabs;
interface VmSpecItem {
id: number;
spec_type: string;
cpu_cores: number;
memory_gb: number;
description?: string;
cost_price_per_minute: number;
markup_rate: number;
is_active: boolean;
created_at: string;
updated_at: string;
}
interface VmTemplateItem {
id: number;
spec_type: string;
template_id: string;
is_default: boolean;
created_at: string;
updated_at: string;
}
const VmPricing: React.FC = () => {
const [loading, setLoading] = useState(false);
const [specsData, setSpecsData] = useState<VmSpecItem[]>([]);
const [templatesData, setTemplatesData] = useState<VmTemplateItem[]>([]);
const [specsTotal, setSpecsTotal] = useState(0);
const [templatesTotal, setTemplatesTotal] = useState(0);
const [specsPage, setSpecsPage] = useState(1);
const [templatesPage, setTemplatesPage] = useState(1);
const [specsPageSize, setSpecsPageSize] = useState(10);
const [templatesPageSize, setTemplatesPageSize] = useState(10);
const [activeTab, setActiveTab] = useState('specs');
const [editingCell, setEditingCell] = useState<{ rowId: number; field: string } | null>(null);
const [editingValue, setEditingValue] = useState<number>(0);
const [specModalVisible, setSpecModalVisible] = useState(false);
const [templateModalVisible, setTemplateModalVisible] = useState(false);
const [specsForm] = Form.useForm();
const [templatesForm] = Form.useForm();
const [templateForm] = Form.useForm();
const [specForm] = Form.useForm();
const [specTypes, setSpecTypes] = useState<string[]>([]);
// 获取规格类型列表(用于模板表单)
const fetchSpecTypes = async () => {
try {
const response = await VmPricingApi.getSpecs({ size: 1000 });
const types = [...new Set((response.data || []).map((item: VmSpecItem) => item.spec_type))];
setSpecTypes(types);
} catch (error) {
console.error('获取规格类型失败:', error);
}
};
// 获取规格数据
const fetchSpecs = async (searchParams: any = {}) => {
setLoading(true);
try {
const response = await VmPricingApi.getSpecs({
...searchParams,
page: specsPage,
size: specsPageSize,
});
setSpecsData(response.data || []);
setSpecsTotal(response.total || 0);
} catch (error) {
console.error('获取规格数据失败:', error);
message.error('获取规格数据失败');
} finally {
setLoading(false);
}
};
// 获取模板数据
const fetchTemplates = async (searchParams: any = {}) => {
setLoading(true);
try {
const response = await VmPricingApi.getTemplates({
...searchParams,
page: templatesPage,
size: templatesPageSize,
});
setTemplatesData(response.data || []);
setTemplatesTotal(response.total || 0);
} catch (error) {
console.error('获取模板数据失败:', error);
message.error('获取模板数据失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchSpecTypes(); // 获取规格类型列表
if (activeTab === 'specs') {
fetchSpecs();
} else {
fetchTemplates();
}
}, [specsPage, specsPageSize, templatesPage, templatesPageSize, activeTab]);
useEffect(() => {
// 当规格数据更新时,更新规格类型列表
if (specsData.length > 0) {
const types = [...new Set(specsData.map(item => item.spec_type))];
setSpecTypes(types);
}
}, [specsData]);
// 更新规格
const handleUpdateSpec = async (id: number, field: string, value: number | boolean) => {
try {
await VmPricingApi.updateSpec(id, { [field]: value });
message.success('更新成功');
setEditingCell(null);
fetchSpecs();
} catch (error) {
message.error('更新失败');
console.error('更新规格失败:', error);
}
};
// 更新状态
const handleUpdateStatus = async (id: number, isActive: boolean) => {
handleUpdateSpec(id, 'is_active', isActive);
};
// 搜索规格
const handleSpecsSearch = (values: any) => {
setSpecsPage(1);
fetchSpecs(values);
};
// 重置规格搜索
const handleSpecsReset = () => {
specsForm.resetFields();
setSpecsPage(1);
fetchSpecs();
};
// 搜索模板
const handleTemplatesSearch = (values: any) => {
setTemplatesPage(1);
fetchTemplates(values);
};
// 重置模板搜索
const handleTemplatesReset = () => {
templatesForm.resetFields();
setTemplatesPage(1);
fetchTemplates();
};
// 开始编辑
const handleEdit = (rowId: number, field: string, value: number) => {
setEditingCell({ rowId, field });
setEditingValue(value);
};
// 保存编辑
const handleSaveEdit = (rowId: number, field: string) => {
let valueToSave = editingValue;
// 加价率需要转换为小数(前端显示为百分比,后端存储为小数)
if (field === 'markup_rate') {
valueToSave = editingValue / 100;
}
handleUpdateSpec(rowId, field, valueToSave);
};
// 取消编辑
const handleCancelEdit = () => {
setEditingCell(null);
};
// 创建规格
const handleCreateSpec = async (values: any) => {
try {
// 转换加价率:前端输入百分比,后端存储小数
const data = {
...values,
markup_rate: values.markup_rate ? values.markup_rate / 100 : undefined,
};
await VmPricingApi.createSpec(data);
message.success('规格创建成功');
setSpecModalVisible(false);
specForm.resetFields();
fetchSpecs();
fetchSpecTypes(); // 刷新规格类型列表
} catch (error: any) {
message.error(error?.response?.data?.message || '规格创建失败');
console.error('创建规格失败:', error);
}
};
// 创建模板
const handleCreateTemplate = async (values: any) => {
try {
await VmPricingApi.createTemplate(values);
message.success('模板创建成功');
setTemplateModalVisible(false);
templateForm.resetFields();
fetchTemplates();
} catch (error: any) {
message.error(error?.response?.data?.message || '模板创建失败');
console.error('创建模板失败:', error);
}
};
// 删除规格
const handleDeleteSpec = async (id: number) => {
try {
await VmPricingApi.deleteSpec(id);
message.success('规格删除成功');
fetchSpecs();
fetchSpecTypes(); // 刷新规格类型列表
} catch (error) {
message.error('规格删除失败');
console.error('删除规格失败:', error);
}
};
// 删除模板
const handleDeleteTemplate = async (id: number) => {
try {
await VmPricingApi.deleteTemplate(id);
message.success('模板删除成功');
fetchTemplates();
} catch (error) {
message.error('模板删除失败');
console.error('删除模板失败:', error);
}
};
// 设置默认模板
const handleSetDefaultTemplate = async (id: number) => {
try {
await VmPricingApi.setDefaultTemplate(id);
message.success('默认模板设置成功');
fetchTemplates();
} catch (error) {
message.error('设置默认模板失败');
console.error('设置默认模板失败:', error);
}
};
// 规格表格列
const specsColumns = [
{
title: '配置类型',
dataIndex: 'spec_type',
key: 'spec_type',
width: 120,
},
{
title: 'CPU核心',
dataIndex: 'cpu_cores',
key: 'cpu_cores',
width: 100,
},
{
title: '内存(GB)',
dataIndex: 'memory_gb',
key: 'memory_gb',
width: 100,
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
width: 150,
render: (text: string) => text || '-',
},
{
title: '成本价($/分钟)',
dataIndex: 'cost_price_per_minute',
key: 'cost_price_per_minute',
width: 160,
render: (value: number, record: VmSpecItem) => {
if (editingCell?.rowId === record.id && editingCell?.field === 'cost_price_per_minute') {
return (
<Space.Compact>
<InputNumber
value={editingValue}
onChange={(val) => setEditingValue(val || 0)}
precision={8}
min={0}
style={{ width: 120 }}
autoFocus
/>
<Button
type="primary"
size="small"
onClick={() => handleSaveEdit(record.id, 'cost_price_per_minute')}
>
</Button>
<Button size="small" onClick={handleCancelEdit}>
</Button>
</Space.Compact>
);
}
return (
<div
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }}
onClick={() => handleEdit(record.id, 'cost_price_per_minute', value)}
>
<span>${value.toFixed(8)}</span>
<EditOutlined style={{ marginLeft: 8, fontSize: 12, color: '#1890ff' }} />
</div>
);
},
},
{
title: '加价率(%)',
dataIndex: 'markup_rate',
key: 'markup_rate',
width: 140,
render: (value: number, record: VmSpecItem) => {
if (editingCell?.rowId === record.id && editingCell?.field === 'markup_rate') {
return (
<Space.Compact>
<InputNumber
value={editingValue}
onChange={(val) => setEditingValue(val || 0)}
precision={4}
min={0}
max={100}
style={{ width: 100 }}
autoFocus
formatter={(value) => `${value}%`}
parser={(value) => value!.replace('%', '')}
/>
<Button
type="primary"
size="small"
onClick={() => handleSaveEdit(record.id, 'markup_rate')}
>
</Button>
<Button size="small" onClick={handleCancelEdit}>
</Button>
</Space.Compact>
);
}
return (
<div
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }}
onClick={() => handleEdit(record.id, 'markup_rate', value * 100)}
>
<span>{(value * 100).toFixed(2)}%</span>
<EditOutlined style={{ marginLeft: 8, fontSize: 12, color: '#1890ff' }} />
</div>
);
},
},
{
title: '状态',
dataIndex: 'is_active',
key: 'is_active',
width: 80,
render: (value: boolean, record: VmSpecItem) => (
<Switch
checked={value}
onChange={(checked) => handleUpdateStatus(record.id, checked)}
/>
),
},
{
title: '操作',
key: 'action',
width: 100,
render: (_: any, record: VmSpecItem) => (
<Popconfirm
title="确定要删除这个规格吗?"
description="删除后该规格的所有配置将无法使用"
onConfirm={() => handleDeleteSpec(record.id)}
okText="确定"
cancelText="取消"
>
<Button
type="link"
danger
size="small"
icon={<DeleteOutlined />}
>
</Button>
</Popconfirm>
),
},
];
// 模板表格列
const templatesColumns = [
{
title: '配置类型',
dataIndex: 'spec_type',
key: 'spec_type',
width: 120,
},
{
title: '模板ID',
dataIndex: 'template_id',
key: 'template_id',
width: 200,
},
{
title: '默认模板',
dataIndex: 'is_default',
key: 'is_default',
width: 100,
render: (value: boolean, record: VmTemplateItem) => (
value ? (
<span style={{ color: '#52c41a' }}>
<CheckOutlined />
</span>
) : (
<Button
type="link"
size="small"
onClick={() => handleSetDefaultTemplate(record.id)}
>
</Button>
)
),
},
{
title: '操作',
key: 'action',
width: 120,
render: (_: any, record: VmTemplateItem) => (
<Popconfirm
title="确定要删除这个模板吗?"
onConfirm={() => handleDeleteTemplate(record.id)}
okText="确定"
cancelText="取消"
>
<Button
type="link"
danger
size="small"
icon={<DeleteOutlined />}
>
</Button>
</Popconfirm>
),
},
];
return (
<Card title={<Title level={4}></Title>}>
<Tabs activeKey={activeTab} onChange={setActiveTab}>
<TabPane tab="规格价格" key="specs">
<Space style={{ marginBottom: 16 }}>
<Form form={specsForm} onFinish={handleSpecsSearch} layout="inline">
<Form.Item name="spec_type">
<Input placeholder="配置类型" style={{ width: 150 }} />
</Form.Item>
<Form.Item name="status">
<Select placeholder="状态" allowClear style={{ width: 120 }}>
<Option value="active"></Option>
<Option value="inactive"></Option>
</Select>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>
</Button>
<Button onClick={handleSpecsReset} icon={<ReloadOutlined />}>
</Button>
</Space>
</Form.Item>
</Form>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setSpecModalVisible(true)}
>
</Button>
</Space>
<Table
dataSource={specsData}
columns={specsColumns}
rowKey="id"
loading={loading}
pagination={{
current: specsPage,
pageSize: specsPageSize,
total: specsTotal,
showSizeChanger: true,
showTotal: (total) => `${total}`,
onChange: (page, pageSize) => {
setSpecsPage(page);
setSpecsPageSize(pageSize || 10);
},
}}
/>
</TabPane>
<TabPane tab="模板配置" key="templates">
<Space style={{ marginBottom: 16 }}>
<Form form={templatesForm} onFinish={handleTemplatesSearch} layout="inline">
<Form.Item name="spec_type">
<Input placeholder="配置类型" style={{ width: 150 }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>
</Button>
<Button onClick={handleTemplatesReset} icon={<ReloadOutlined />}>
</Button>
</Space>
</Form.Item>
</Form>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setTemplateModalVisible(true)}
>
</Button>
</Space>
<Table
dataSource={templatesData}
columns={templatesColumns}
rowKey="id"
loading={loading}
pagination={{
current: templatesPage,
pageSize: templatesPageSize,
total: templatesTotal,
showSizeChanger: true,
showTotal: (total) => `${total}`,
onChange: (page, pageSize) => {
setTemplatesPage(page);
setTemplatesPageSize(pageSize || 10);
},
}}
/>
</TabPane>
</Tabs>
{/* 添加规格模态框 */}
<Modal
title="添加规格"
open={specModalVisible}
onCancel={() => {
setSpecModalVisible(false);
specForm.resetFields();
}}
footer={null}
>
<Form
form={specForm}
layout="vertical"
onFinish={handleCreateSpec}
>
<Form.Item
name="spec_type"
label="配置类型"
rules={[{ required: true, message: '请输入配置类型' }]}
>
<Input placeholder="如2c4g, 4c8g, 6c12g" />
</Form.Item>
<Form.Item
name="cpu_cores"
label="CPU核心数"
rules={[{ required: true, message: '请输入CPU核心数' }]}
>
<InputNumber min={1} style={{ width: '100%' }} placeholder="请输入CPU核心数" />
</Form.Item>
<Form.Item
name="memory_gb"
label="内存(GB)"
rules={[{ required: true, message: '请输入内存大小' }]}
>
<InputNumber min={1} style={{ width: '100%' }} placeholder="请输入内存大小(GB)" />
</Form.Item>
<Form.Item
name="description"
label="描述"
>
<Input placeholder="配置描述(可选)" />
</Form.Item>
<Form.Item
name="cost_price_per_minute"
label="成本价($/分钟)"
rules={[{ required: true, message: '请输入成本价' }]}
>
<InputNumber
min={0}
precision={8}
style={{ width: '100%' }}
placeholder="请输入成本价(美元/分钟)"
/>
</Form.Item>
<Form.Item
name="markup_rate"
label="加价率(%)"
initialValue={30}
>
<InputNumber
min={0}
max={100}
precision={2}
style={{ width: '100%' }}
placeholder="默认30%"
formatter={(value) => `${value}%`}
parser={(value) => value!.replace('%', '')}
/>
</Form.Item>
<Form.Item
name="is_active"
label="启用"
valuePropName="checked"
initialValue={true}
>
<Switch />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button onClick={() => {
setSpecModalVisible(false);
specForm.resetFields();
}}>
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
{/* 添加模板模态框 */}
<Modal
title="添加模板"
open={templateModalVisible}
onCancel={() => {
setTemplateModalVisible(false);
templateForm.resetFields();
}}
footer={null}
>
<Form
form={templateForm}
layout="vertical"
onFinish={handleCreateTemplate}
>
<Form.Item
name="spec_type"
label="配置类型"
rules={[{ required: true, message: '请选择配置类型' }]}
>
<Select placeholder="请选择配置类型" showSearch>
{specTypes.map(type => (
<Option key={type} value={type}>{type}</Option>
))}
</Select>
</Form.Item>
<Form.Item
name="template_id"
label="模板ID"
rules={[{ required: true, message: '请输入模板ID' }]}
>
<Input placeholder="请输入E2B模板ID" />
</Form.Item>
<Form.Item
name="is_default"
label="设为默认模板"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button onClick={() => {
setTemplateModalVisible(false);
templateForm.resetFields();
}}>
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
</Card>
);
};
export default VmPricing;

View File

@@ -0,0 +1,109 @@
// 邀请码编辑功能测试用例
// 这个文件用于验证编辑邀请码功能是否正常工作
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { InviteCodes } from '../pages/InviteCodes';
import { InviteCodeApiService } from '../services/inviteCodeApi';
// Mock API 服务
jest.mock('../services/inviteCodeApi');
const mockInviteCodeApiService = InviteCodeApiService as jest.Mocked<typeof InviteCodeApiService>;
describe('邀请码编辑功能', () => {
const mockInviteCode = {
id: 1,
code: 'GFY-ABC12345-XYZ1',
is_used: false,
expires_at: '2025-12-31T23:59:59Z',
created_at: '2024-12-01T10:00:00Z',
invite_url: 'https://sso.example.com/register?invite=GFY-ABC12345-XYZ1'
};
beforeEach(() => {
// Mock API 调用
mockInviteCodeApiService.getInviteCodeList.mockResolvedValue({
list: [mockInviteCode],
total: 1
});
mockInviteCodeApiService.getStatistics.mockResolvedValue({
total: 1,
used: 0,
unused: 1,
today_created: 1
});
mockInviteCodeApiService.updateInviteCode.mockResolvedValue(mockInviteCode);
});
test('应该正确显示邀请码值', async () => {
render(<InviteCodes />);
// 等待数据加载
await waitFor(() => {
expect(screen.getByText('GFY-ABC12345-XYZ1')).toBeInTheDocument();
});
// 点击编辑按钮
const editButton = screen.getByText('编辑');
fireEvent.click(editButton);
// 验证邀请码值是否正确显示
await waitFor(() => {
const codeInput = screen.getByDisplayValue('GFY-ABC12345-XYZ1');
expect(codeInput).toBeInTheDocument();
expect(codeInput).toBeDisabled();
});
});
test('应该能够更新过期时间', async () => {
render(<InviteCodes />);
// 等待数据加载
await waitFor(() => {
expect(screen.getByText('GFY-ABC12345-XYZ1')).toBeInTheDocument();
});
// 点击编辑按钮
const editButton = screen.getByText('编辑');
fireEvent.click(editButton);
// 等待模态框打开
await waitFor(() => {
expect(screen.getByText('编辑邀请码')).toBeInTheDocument();
});
// 验证过期时间字段存在
const datePicker = screen.getByPlaceholderText('选择过期时间(可选,设置为空表示永不过期)');
expect(datePicker).toBeInTheDocument();
// 点击更新按钮
const updateButton = screen.getByText('更新');
fireEvent.click(updateButton);
// 验证 API 被调用
await waitFor(() => {
expect(mockInviteCodeApiService.updateInviteCode).toHaveBeenCalledWith(1, {
expires_at: undefined // 因为没有修改过期时间
});
});
});
test('已使用的邀请码编辑按钮应该被禁用', async () => {
const usedInviteCode = { ...mockInviteCode, is_used: true };
mockInviteCodeApiService.getInviteCodeList.mockResolvedValue({
list: [usedInviteCode],
total: 1
});
render(<InviteCodes />);
// 等待数据加载
await waitFor(() => {
expect(screen.getByText('GFY-ABC12345-XYZ1')).toBeInTheDocument();
});
// 验证编辑按钮被禁用
const editButton = screen.getByText('编辑');
expect(editButton.closest('button')).toBeDisabled();
});
});

View File

@@ -0,0 +1,95 @@
import React from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { usePagePermissions } from '../hooks/usePagePermissions';
// 页面组件导入
import Dashboard from '../pages/Dashboard';
import UserManagement from '../pages/UserManagement';
import RoleManagement from '../pages/RoleManagement';
import PermissionManagement from '../pages/PermissionManagement';
import FinanceTransactionLogs from '../pages/FinanceTransactionLogs';
import QuotaManagement from '../pages/QuotaManagement';
import GoalfyMaxUsers from '../pages/GoalfyMaxUsers';
import UserFeedback from '../pages/UserFeedback';
import MessagePush from '../pages/MessagePush';
import UserLevelConfigs from '../pages/UserLevelConfigs';
import Operations from '../pages/Operations';
import MCPProviderPricing from '../pages/MCPProviderPricing';
import AuditLogs from '../pages/AuditLogs';
// 动态路由配置
const DynamicRoutes: React.FC = () => {
const { getAccessiblePages } = usePagePermissions();
const accessiblePages = getAccessiblePages();
return (
<Routes>
{/* 默认重定向到仪表盘 */}
<Route path="/" element={<Navigate to="/dashboard" replace />} />
{/* 仪表盘 - 所有用户都可以访问 */}
<Route path="/dashboard" element={<Dashboard />} />
{/* 总览页面 */}
{accessiblePages.includes('/overview') && (
<Route path="/overview" element={<Dashboard />} />
)}
{/* 运营页面 */}
{accessiblePages.includes('/operations') && (
<Route path="/operations" element={<Operations />} />
)}
{/* 监控页面 */}
{accessiblePages.includes('/monitoring') && (
<Route path="/monitoring" element={<RoleManagement />} />
)}
{/* 财务页面 */}
{accessiblePages.includes('/finance') && (
<Route path="/finance" element={<FinanceTransactionLogs />} />
)}
{/* 系统管理页面 */}
{accessiblePages.includes('/system') && (
<Route path="/system" element={<QuotaManagement />} />
)}
{/* GoalfyMax 用户管理 - 系统管理子菜单,随系统权限显示 */}
{accessiblePages.includes('/system') && (
<Route path="/system/goalfymax-users" element={<GoalfyMaxUsers />} />
)}
{/* 用户等级配置管理 - 系统管理子菜单,随系统权限显示 */}
{accessiblePages.includes('/system') && (
<Route path="/system/user-level-configs" element={<UserLevelConfigs />} />
)}
{/* 审计日志 - 系统管理子菜单 */}
{accessiblePages.includes('/system') && (
<Route path="/system/audit-logs" element={<AuditLogs />} />
)}
{/* 用户反馈管理 - 运营页面子菜单,随运营权限显示 */}
{accessiblePages.includes('/operations') && (
<Route path="/user-feedback" element={<UserFeedback />} />
)}
{/* 消息推送管理 - 运营页面子菜单,随运营权限显示 */}
{accessiblePages.includes('/operations') && (
<Route path="/message-push" element={<MessagePush />} />
)}
{/* MCP价格配置管理 - 运营页面子菜单,随运营权限显示 */}
{accessiblePages.includes('/operations') && (
<Route path="/mcp-provider-pricing" element={<MCPProviderPricing />} />
)}
{/* 404页面 */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
);
};
export default DynamicRoutes;

911
src/services/api.ts Normal file
View File

@@ -0,0 +1,911 @@
// 类型定义
interface QuotaHistoryRequest {
start_date: string;
end_date: string;
user_id?: string;
api_group?: string;
project_id?: string;
period?: string;
group_by?: string[];
}
interface QuotaHistoryItem {
id: number;
user_id: string;
api_group: string;
project_id: string;
day: string;
account: string;
model: string;
quota_used: number;
created_at: string;
}
interface QuotaHistoryResponse {
success: boolean;
data: QuotaHistoryItem[];
message?: string;
}
// API 基础配置
const API_BASE_URL = '';
// 创建 axios 实例用于自动处理 token
import axios from 'axios';
class ApiClient {
private instance: any;
private isRefreshing = false;
private failedQueue: Array<{
resolve: (value: any) => void;
reject: (reason: any) => void;
}> = [];
constructor(baseURL: string = '/api') {
this.instance = axios.create({
baseURL,
timeout: 60000,
headers: {
'Content-Type': 'application/json',
},
});
this.setupInterceptors();
}
private processQueue(error: any, token: string | null = null) {
this.failedQueue.forEach(({ resolve, reject }) => {
if (error) {
reject(error);
} else {
resolve(token);
}
});
this.failedQueue = [];
}
private async handleTokenRefresh(): Promise<{
access_token: string;
refresh_token?: string;
expires_in?: number;
}> {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
throw new Error('No refresh token available');
}
try {
// 使用独立的axios实例来避免循环依赖和拦截器影响
const refreshResponse = await axios.post<SSOCallbackResponse>(
'/api/sso/refresh',
{ refresh_token: refreshToken },
{
headers: {
'Content-Type': 'application/json',
},
}
);
const {
access_token,
refresh_token: newRefreshToken,
expires_in,
} = refreshResponse.data;
if (access_token) {
localStorage.setItem('token', access_token);
if (newRefreshToken) {
localStorage.setItem('refreshToken', newRefreshToken);
}
return {
access_token,
refresh_token: newRefreshToken,
expires_in,
};
}
throw new Error('No access token in refresh response');
} catch (error) {
// 刷新失败时不在这里清除token让上层的relogin事件处理器来处理
throw error;
}
}
private setupInterceptors() {
// 请求拦截器
this.instance.interceptors.request.use(
config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => {
return Promise.reject(error);
}
);
// 响应拦截器
this.instance.interceptors.response.use(
(response: any) => {
return response;
},
error => {
if (error.response?.status === 401) {
const originalRequest = error.config;
// 避免无限递归
if (originalRequest._retry) {
// 重试失败,触发重新登录
const event = new CustomEvent('auth:relogin');
window.dispatchEvent(event);
return Promise.reject(error);
}
if (this.isRefreshing) {
// 如果正在刷新token将请求加入队列
return new Promise((resolve, reject) => {
this.failedQueue.push({ resolve, reject });
})
.then(token => {
originalRequest.headers.Authorization = token;
return this.instance(originalRequest);
})
.catch(err => {
return Promise.reject(err);
});
}
originalRequest._retry = true;
this.isRefreshing = true;
return this.handleTokenRefresh()
.then(refreshResult => {
const { access_token, refresh_token, expires_in } = refreshResult;
this.processQueue(null, `Bearer ${access_token}`);
originalRequest.headers.Authorization = `Bearer ${access_token}`;
// 刷新成功通知useAuth更新状态和重置定时器
const event = new CustomEvent('auth:tokenRefreshed', {
detail: {
access_token,
refresh_token,
expires_in,
},
});
window.dispatchEvent(event);
return this.instance(originalRequest);
})
.catch(refreshError => {
this.processQueue(refreshError, null);
// 刷新失败,重新进行单点登录
const event = new CustomEvent('auth:relogin');
window.dispatchEvent(event);
return Promise.reject(refreshError);
})
.finally(() => {
this.isRefreshing = false;
});
}
return Promise.reject(error);
}
);
}
// GET 请求
async get<T = any>(url: string, config?: any): Promise<T> {
const response = await this.instance.get<T>(url, config);
return response.data;
}
// POST 请求
async post<T = any>(
url: string,
data?: any,
config?: any
): Promise<T> {
const response = await this.instance.post<T>(url, data, config);
return response.data;
}
// PUT 请求
async put<T = any>(
url: string,
data?: any,
config?: any
): Promise<T> {
const response = await this.instance.put<T>(url, data, config);
return response.data;
}
// DELETE 请求
async delete<T = any>(
url: string,
data?: any,
config?: any
): Promise<T> {
const response = await this.instance.delete<T>(url, {
...config,
data,
});
return response.data;
}
}
export const apiClient = new ApiClient();
// GoalfyMax Users API
export const GoalfyMaxUsersApi = {
list(params: { username?: string; email?: string; status?: number; page?: number; size?: number }) {
const qs = new URLSearchParams()
if (params?.username) qs.set('username', params.username)
if (params?.email) qs.set('email', params.email)
if (typeof params?.status === 'number') qs.set('status', String(params.status))
if (params?.page) qs.set('page', String(params.page))
if (params?.size) qs.set('size', String(params.size))
return apiClient.get<any>(`/admin/goalfymax-users?${qs.toString()}`)
},
getById(id: number) {
return apiClient.get<any>(`/admin/goalfymax-users/${id}`)
},
create(payload: { username: string; email: string; password: string; nickname?: string; avatar?: string; user_level_id?: number; version?: number }) {
return apiClient.post<any>(`/admin/goalfymax-users`, payload)
},
update(id: number, payload: { nickname?: string; email?: string; avatar?: string; user_level_id?: number; version?: number; goalfy_hub_permission?: number }) {
return apiClient.put<any>(`/admin/goalfymax-users/${id}`, payload)
},
delete(id: number) {
return apiClient.delete<any>(`/admin/goalfymax-users/${id}`)
},
ban(id: number, reason: string) {
return apiClient.post<any>(`/admin/goalfymax-users/${id}/ban`, { reason })
},
unban(id: number) {
return apiClient.post<any>(`/admin/goalfymax-users/${id}/unban`, {})
},
addBalance(id: number, amount: number) {
return apiClient.post<any>(`/admin/goalfymax-users/${id}/add-balance`, { amount })
},
deductBalance(id: number, amount: number) {
return apiClient.post<any>(`/admin/goalfymax-users/${id}/deduct-balance`, { amount })
},
}
// ============ SSO 类型与工具 ============
export interface SSOLoginResponse {
success: boolean
message: string
auth_url: string
state: string
}
export interface SSOCallbackResponse {
success: boolean
message: string
access_token?: string
id_token?: string
refresh_token?: string
expires_in?: number
user_info?: {
sub: string;
name?: string;
email?: string;
preferred_username?: string;
permissions?: string[];
roles?: string[];
}
uuid?: string
}
export const AuthStorage = {
getToken(): string | null {
return localStorage.getItem('admin_auth_token')
},
setToken(token: string) {
localStorage.setItem('admin_auth_token', token)
},
clear() {
localStorage.removeItem('admin_auth_token')
}
}
export class SSOApiService {
static async initiateLogin(): Promise<SSOLoginResponse> {
const apiClient = new ApiClient()
const response = await apiClient.instance.post('/sso/login', {})
return response.data?.data as SSOLoginResponse
}
static async callback(code: string, state: string): Promise<SSOCallbackResponse> {
const apiClient = new ApiClient()
const response = await apiClient.instance.post('/sso/callback', { code, state })
return response.data?.data as SSOCallbackResponse
}
static async userInfo(): Promise<any> {
const apiClient = new ApiClient()
const response = await apiClient.instance.get('/sso/userinfo')
return response.data
}
static async logout(): Promise<any> {
const apiClient = new ApiClient()
const response = await apiClient.instance.post('/sso/logout', {})
return response.data?.data || response.data
}
static async refreshToken(refreshToken: string): Promise<SSOCallbackResponse> {
const apiClient = new ApiClient()
const response = await apiClient.instance.post('/sso/refresh', { refresh_token: refreshToken })
return response.data?.data as SSOCallbackResponse
}
}
// API 服务类
export class QuotaApiService {
/**
* 获取配额历史数据
*/
static async getQuotaHistory(request: QuotaHistoryRequest): Promise<QuotaHistoryResponse> {
try {
return await apiClient.post<QuotaHistoryResponse>('/quotas/history', request);
} catch (error) {
console.error('获取配额历史数据失败:', error);
throw error;
}
}
/**
* 健康检查
*/
static async healthCheck(): Promise<{ status: string }> {
try {
return await apiClient.get<{ status: string }>('/health');
} catch (error) {
console.error('健康检查失败:', error);
throw error;
}
}
/**
* 获取配额规则列表
*/
static async getQuotaRules(): Promise<any[]> {
try {
const body = await apiClient.get<any>('/quotas/rules');
// 后端统一返回 { code, message, data }
if (typeof body?.data === 'string') {
// 兼容 data 透传为字符串的情况
try { return JSON.parse(body.data); } catch { return []; }
}
return body?.data ?? [];
} catch (e) {
console.error('获取配额规则失败:', e);
throw e;
}
}
/**
* 创建配额规则
*/
static async createQuotaRule(payload: any): Promise<any> {
return await apiClient.post<any>('/quotas/rules', payload);
}
/**
* 更新配额规则
*/
static async updateQuotaRule(id: string, payload: any): Promise<any> {
return await apiClient.put<any>(`/quotas/rules/${id}`, payload);
}
/** 删除配额规则 */
static async deleteQuotaRule(id: string): Promise<any> {
return await apiClient.delete<any>(`/quotas/rules/${id}`);
}
// ===== 用户项目配额 CRUD =====
static async listUserProjectQuota(params: { user_id?: string; enabled?: string; page?: number; size?: number }) {
const qs = new URLSearchParams()
if (params?.user_id) qs.set('user_id', params.user_id)
if (params?.enabled) qs.set('enabled', params.enabled)
if (params?.page) qs.set('page', String(params.page))
if (params?.size) qs.set('size', String(params.size))
return await apiClient.get<any>(`/quotas/user-project?${qs.toString()}`);
}
static async createUserProjectQuota(payload: { user_id: string; project_limit: number; vm_limit: number; process_limit: number; enabled?: boolean; description?: string }) {
const body = { ...payload, user_id: String(payload.user_id ?? '').trim() }
return await apiClient.post<any>('/quotas/user-project', body);
}
static async updateUserProjectQuota(id: number, payload: { project_limit: number; vm_limit: number; process_limit: number; enabled?: boolean; description?: string }) {
return await apiClient.put<any>(`/quotas/user-project/${id}`, payload);
}
static async deleteUserProjectQuota(id: number) {
return await apiClient.delete<any>(`/quotas/user-project/${id}`);
}
}
// 财务相关 API
export class FinanceApiService {
static async listSandboxRecords(params: { page?: number; size?: number; user_id?: string; project?: string; start?: string; end?: string } = {}) {
const qs = new URLSearchParams();
if (params.page) qs.set('page', String(params.page));
if (params.size) qs.set('size', String(params.size));
if (params.user_id) qs.set('user_id', params.user_id);
if (params.project) qs.set('project', params.project);
if (params.start) qs.set('start', params.start);
if (params.end) qs.set('end', params.end);
return await apiClient.get<any>(`/finance/sandbox-records?${qs.toString()}`);
}
static async listTokenUsages(params: { page?: number; size?: number; user_id?: string; project?: string; start?: string; end?: string } = {}) {
const qs = new URLSearchParams();
if (params.page) qs.set('page', String(params.page));
if (params.size) qs.set('size', String(params.size));
if (params.user_id) qs.set('user_id', params.user_id);
if (params.project) qs.set('project', params.project);
if (params.start) qs.set('start', params.start);
if (params.end) qs.set('end', params.end);
return await apiClient.get<any>(`/finance/token-usages?${qs.toString()}`);
}
static async listMcpUsages(params: { page?: number; size?: number; user_id?: string; project?: string; start?: string; end?: string } = {}) {
const qs = new URLSearchParams();
if (params.page) qs.set('page', String(params.page));
if (params.size) qs.set('size', String(params.size));
if (params.user_id) qs.set('user_id', params.user_id);
if (params.project) qs.set('project', params.project);
if (params.start) qs.set('start', params.start);
if (params.end) qs.set('end', params.end);
return await apiClient.get<any>(`/finance/mcp-usages?${qs.toString()}`);
}
static async listTransactionLogs(params: { page?: number; size?: number; user_id?: string; order_id?: string; type?: string; status?: string; start?: string; end?: string } = {}) {
const qs = new URLSearchParams();
if (params.page) qs.set('page', String(params.page));
if (params.size) qs.set('size', String(params.size));
if (params.user_id) qs.set('user_id', params.user_id);
if (params.order_id) qs.set('order_id', params.order_id);
if (params.type) qs.set('type', params.type);
if (params.status) qs.set('status', params.status);
if (params.start) qs.set('start', params.start);
if (params.end) qs.set('end', params.end);
return await apiClient.get<any>(`/finance/transaction-logs?${qs.toString()}`);
}
static async listPaymentRecords(params: { page?: number; size?: number; user_id?: string; order_id?: string; paypal_order_id?: string; status?: string; refund_status?: string; payer_email?: string; start?: string; end?: string } = {}) {
const qs = new URLSearchParams();
if (params.page) qs.set('page', String(params.page));
if (params.size) qs.set('size', String(params.size));
if (params.user_id) qs.set('user_id', params.user_id);
if (params.order_id) qs.set('order_id', params.order_id);
if (params.paypal_order_id) qs.set('paypal_order_id', params.paypal_order_id);
if (params.status) qs.set('status', params.status);
if (params.refund_status) qs.set('refund_status', params.refund_status);
if (params.payer_email) qs.set('payer_email', params.payer_email);
if (params.start) qs.set('start', params.start);
if (params.end) qs.set('end', params.end);
return await apiClient.get<any>(`/finance/payment-records?${qs.toString()}`);
}
static async refundPaymentRecord(payload: { order_id?: string; paypal_capture_id?: string; amount?: number }) {
return await apiClient.post<any>('/finance/payment-records/refund', payload);
}
static async listMcpAccountRechargeRecords(params: {
page?: number;
size?: number;
provider?: string;
account?: string;
start?: string;
end?: string;
} = {}) {
const qs = new URLSearchParams();
if (params.page) qs.set('page', String(params.page));
if (params.size) qs.set('size', String(params.size));
if (params.provider) qs.set('provider', params.provider);
if (params.account) qs.set('account', params.account);
if (params.start) qs.set('start', params.start);
if (params.end) qs.set('end', params.end);
return await apiClient.get<any>(`/finance/mcp-account-recharge-records?${qs.toString()}`);
}
static async createMcpAccountRechargeRecord(payload: {
provider_id: string;
amount: number;
recharge_date: string;
remark?: string;
}) {
return await apiClient.post<any>('/finance/mcp-account-recharge-records', payload);
}
static async updateMcpAccountRechargeRecord(id: string, payload: {
amount?: number;
recharge_date?: string;
remark?: string;
}) {
return await apiClient.put<any>(`/finance/mcp-account-recharge-records/${id}`, payload);
}
static async deleteMcpAccountRechargeRecord(id: string) {
return await apiClient.delete<any>(`/finance/mcp-account-recharge-records/${id}`);
}
static async getMcpProviderAccounts(params?: {
status?: string;
is_used?: boolean;
}) {
const qs = new URLSearchParams();
if (params?.status) qs.set('status', params.status);
if (params?.is_used !== undefined) qs.set('is_used', String(params.is_used));
return await apiClient.get<any>(`/finance/mcp-provider-accounts?${qs.toString()}`);
}
static async getMcpAccountBalances() {
return await apiClient.get<any>('/finance/mcp-account-balances');
}
static async getMcpAccountBalanceHistory(providerId: string, params?: {
start?: string;
end?: string;
}) {
const qs = new URLSearchParams();
if (params?.start) qs.set('start', params.start);
if (params?.end) qs.set('end', params.end);
return await apiClient.get<any>(`/finance/mcp-account-balances/${providerId}/history?${qs.toString()}`);
}
static async createMcpAccountBalance(payload: {
provider_id: string;
balance: number;
remark?: string;
}) {
return await apiClient.post<any>('/finance/mcp-account-balances', payload);
}
static async adjustMcpAccountBalance(providerId: string, payload: {
balance: number;
remark?: string;
}) {
return await apiClient.put<any>(`/finance/mcp-account-balances/${providerId}`, payload);
}
// 模型账号管理
static async listModelAccountRechargeRecords(params: {
page?: number;
size?: number;
provider?: string;
model_name?: string;
start?: string;
end?: string;
} = {}) {
const qs = new URLSearchParams();
if (params.page) qs.set('page', String(params.page));
if (params.size) qs.set('size', String(params.size));
if (params.provider) qs.set('provider', params.provider);
if (params.model_name) qs.set('model_name', params.model_name);
if (params.start) qs.set('start', params.start);
if (params.end) qs.set('end', params.end);
return await apiClient.get<any>(`/finance/model-account-recharge-records?${qs.toString()}`);
}
static async createModelAccountRechargeRecord(payload: {
account: string;
amount: number;
recharge_date: string;
remark?: string;
}) {
return await apiClient.post<any>('/finance/model-account-recharge-records', payload);
}
static async updateModelAccountRechargeRecord(id: string, payload: {
amount?: number;
recharge_date?: string;
remark?: string;
}) {
return await apiClient.put<any>(`/finance/model-account-recharge-records/${id}`, payload);
}
static async deleteModelAccountRechargeRecord(id: string) {
return await apiClient.delete<any>(`/finance/model-account-recharge-records/${id}`);
}
static async getModelConfigAccounts(params?: {
enabled?: boolean;
}) {
const qs = new URLSearchParams();
if (params?.enabled !== undefined) qs.set('enabled', String(params.enabled));
return await apiClient.get<any>(`/finance/model-config-accounts?${qs.toString()}`);
}
static async getModelAccountBalances() {
return await apiClient.get<any>('/finance/model-account-balances');
}
static async getModelAccountBalanceHistory(account: string, params?: {
start?: string;
end?: string;
}) {
const qs = new URLSearchParams();
if (params?.start) qs.set('start', params.start);
if (params?.end) qs.set('end', params.end);
return await apiClient.get<any>(`/finance/model-account-balances/${account}/history?${qs.toString()}`);
}
static async createModelAccountBalance(payload: {
account: string;
balance: number;
remark?: string;
}) {
return await apiClient.post<any>('/finance/model-account-balances', payload);
}
static async adjustModelAccountBalance(account: string, payload: {
balance: number;
remark?: string;
}) {
return await apiClient.put<any>(`/finance/model-account-balances/${account}`, payload);
}
}
// 用户反馈 API 服务类
export class UserFeedbackApiService {
/**
* 获取用户反馈列表
*/
static async getFeedbackList(params: {
page?: number;
page_size?: number;
status?: number;
user_id?: number;
keyword?: string;
start_time?: string;
end_time?: string;
}): Promise<any> {
const qs = new URLSearchParams();
if (params.page) qs.set('page', String(params.page));
if (params.page_size) qs.set('page_size', String(params.page_size));
if (params.status !== undefined) qs.set('status', String(params.status));
if (params.user_id) qs.set('user_id', String(params.user_id));
if (params.keyword) qs.set('keyword', params.keyword);
if (params.start_time) qs.set('start_time', params.start_time);
if (params.end_time) qs.set('end_time', params.end_time);
return await apiClient.get<any>(`/admin/user-feedback?${qs.toString()}`);
}
/**
* 获取用户反馈详情
*/
static async getFeedbackDetail(id: number): Promise<any> {
return await apiClient.get<any>(`/admin/user-feedback/${id}`);
}
/**
* 标记反馈为已处理
*/
static async markFeedbackHandled(id: number, note?: string): Promise<any> {
return await apiClient.post<any>(`/admin/user-feedback/${id}/mark-handled`, {
note: note || ''
});
}
/**
* 删除用户反馈
*/
static async deleteFeedback(id: number): Promise<any> {
return await apiClient.delete<any>(`/admin/user-feedback/${id}`);
}
/**
* 获取反馈统计信息
*/
static async getFeedbackStatistics(): Promise<any> {
return await apiClient.get<any>('/admin/user-feedback/statistics');
}
}
/**
* 消息推送API服务
*/
export class MessagePushApiService {
/**
* 发送消息到GoalfyMax用户
*/
static async sendMessage(params: {
title: string;
content: string;
user_ids: number[];
}): Promise<any> {
return await apiClient.post<any>('/admin/message-push/send', params);
}
/**
* 获取推送记录列表
*/
static async getPushLogs(params: {
page?: number;
page_size?: number;
status?: number;
sender_id?: number;
start_time?: string;
end_time?: string;
}): Promise<any> {
const qs = new URLSearchParams();
if (params.page) qs.set('page', String(params.page));
if (params.page_size) qs.set('page_size', String(params.page_size));
if (params.status !== undefined) qs.set('status', String(params.status));
if (params.sender_id) qs.set('sender_id', String(params.sender_id));
if (params.start_time) qs.set('start_time', params.start_time);
if (params.end_time) qs.set('end_time', params.end_time);
return await apiClient.get<any>(`/admin/message-push/logs?${qs.toString()}`);
}
/**
* 获取推送记录详情
*/
static async getPushLogDetail(id: number): Promise<any> {
return await apiClient.get<any>(`/admin/message-push/logs/${id}`);
}
/**
* 搜索GoalfyMax用户
*/
static async searchUsers(keyword: string, limit?: number): Promise<any> {
const qs = new URLSearchParams();
qs.set('keyword', keyword);
if (limit) qs.set('limit', String(limit));
return await apiClient.get<any>(`/admin/message-push/users/search?${qs.toString()}`);
}
}
// 供应商模型价格配置API
export const VendorModelPricingApi = {
/**
* 获取供应商模型价格配置列表
*/
async getList(params: {
provider?: string;
model?: string;
status?: string;
page?: number;
size?: number;
} = {}): Promise<any> {
const qs = new URLSearchParams();
if (params.provider) qs.set('provider', params.provider);
if (params.model) qs.set('model', params.model);
if (params.status) qs.set('status', params.status);
if (params.page) qs.set('page', String(params.page));
if (params.size) qs.set('size', String(params.size));
return await apiClient.get<any>(`/admin/vendor-model-pricing?${qs.toString()}`);
},
/**
* 更新模型价格
*/
async updatePricing(id: number, data: {
input_price?: number;
output_price?: number;
cache_read_price?: number;
cache_create_price?: number;
price_ratio?: number;
enabled?: boolean;
}): Promise<any> {
return await apiClient.put<any>(`/admin/vendor-model-pricing/${id}`, data);
},
/**
* 获取供应商列表
*/
async getProviders(): Promise<any> {
return await apiClient.get<any>('/admin/vendor-model-pricing/providers');
}
};
// 虚拟机价格配置API
export const VmPricingApi = {
/**
* 获取虚拟机规格列表
*/
async getSpecs(params: {
spec_type?: string;
status?: string;
page?: number;
size?: number;
} = {}): Promise<any> {
const qs = new URLSearchParams();
if (params.spec_type) qs.set('spec_type', params.spec_type);
if (params.status) qs.set('status', params.status);
if (params.page) qs.set('page', String(params.page));
if (params.size) qs.set('size', String(params.size));
return await apiClient.get<any>(`/admin/vm-pricing/specs?${qs.toString()}`);
},
/**
* 创建规格
*/
async createSpec(data: {
spec_type: string;
cpu_cores: number;
memory_gb: number;
description?: string;
cost_price_per_minute: number;
markup_rate?: number;
is_active?: boolean;
}): Promise<any> {
return await apiClient.post<any>('/admin/vm-pricing/specs', data);
},
/**
* 更新规格价格
*/
async updateSpec(id: number, data: {
cost_price_per_minute?: number;
markup_rate?: number;
is_active?: boolean;
}): Promise<any> {
return await apiClient.put<any>(`/admin/vm-pricing/specs/${id}`, data);
},
/**
* 删除规格
*/
async deleteSpec(id: number): Promise<any> {
return await apiClient.delete<any>(`/admin/vm-pricing/specs/${id}`);
},
/**
* 获取模板列表
*/
async getTemplates(params: {
spec_type?: string;
page?: number;
size?: number;
} = {}): Promise<any> {
const qs = new URLSearchParams();
if (params.spec_type) qs.set('spec_type', params.spec_type);
if (params.page) qs.set('page', String(params.page));
if (params.size) qs.set('size', String(params.size));
return await apiClient.get<any>(`/admin/vm-pricing/templates?${qs.toString()}`);
},
/**
* 创建模板
*/
async createTemplate(data: {
spec_type: string;
template_id: string;
is_default?: boolean;
}): Promise<any> {
return await apiClient.post<any>('/admin/vm-pricing/templates', data);
},
/**
* 删除模板
*/
async deleteTemplate(id: number): Promise<any> {
return await apiClient.delete<any>(`/admin/vm-pricing/templates/${id}`);
},
/**
* 设置默认模板
*/
async setDefaultTemplate(id: number): Promise<any> {
return await apiClient.put<any>(`/admin/vm-pricing/templates/${id}/default`, {});
},
};

View File

@@ -0,0 +1,21 @@
import { apiClient } from './api';
import type { AuditLog, AuditLogListRequest, AuditLogListResponse } from '../types/auditLog';
export class AuditLogApiService {
static async getAuditLogs(params: AuditLogListRequest = {}): Promise<AuditLogListResponse> {
const response = await apiClient.get<any>('/admin/audit-logs', { params });
const data = response?.data ?? {};
return {
list: (data.list ?? []) as AuditLog[],
total: Number(data.total ?? 0),
page: Number(data.page ?? params.page ?? 1),
size: Number(data.size ?? params.size ?? 20),
};
}
static async getAuditLogDetail(id: number): Promise<AuditLog | null> {
const response = await apiClient.get<any>(`/admin/audit-logs/${id}`);
return (response?.data ?? null) as AuditLog | null;
}
}

View File

@@ -0,0 +1,59 @@
import { apiClient } from './api';
import type {
InviteCode,
InviteCodeListRequest,
InviteCodeCreateRequest,
InviteCodeUpdateRequest,
InviteCodeStatistics,
InviteCodeListResponse,
ClientOption,
} from '../types/inviteCode';
export class InviteCodeApiService {
// 获取邀请码列表
static async getInviteCodeList(params?: InviteCodeListRequest): Promise<InviteCodeListResponse> {
const response = await apiClient.get('/admin/invite-codes', { params });
return response.data;
}
// 创建邀请码(支持设置过期时间和邮箱列表)
// 返回可能是单个邀请码对象或邀请码数组
static async createInviteCode(data?: InviteCodeCreateRequest): Promise<InviteCode | InviteCode[]> {
const response = await apiClient.post('/admin/invite-codes', data);
// 处理响应格式data.data 可能是对象或数组
return response.data?.data || response.data;
}
// 更新邀请码(支持更新过期时间)
static async updateInviteCode(id: number, data: InviteCodeUpdateRequest): Promise<InviteCode> {
const response = await apiClient.put(`/admin/invite-codes/${id}`, data);
return response.data;
}
// 删除邀请码
static async deleteInviteCode(id: number): Promise<void> {
await apiClient.delete(`/admin/invite-codes/${id}`);
}
// 获取统计信息
static async getStatistics(): Promise<InviteCodeStatistics> {
const response = await apiClient.get('/admin/invite-codes/statistics');
return response.data;
}
// 标记邀请码为已使用
static async markAsUsed(code: string): Promise<void> {
await apiClient.post('/admin/invite-codes/mark-used', { code });
}
// 验证邀请码是否有效
static async validateInviteCode(code: string): Promise<void> {
await apiClient.post('/admin/invite-codes/validate', { code });
}
// 获取客户端选项列表
static async getClientOptions(): Promise<ClientOption[]> {
const response = await apiClient.get('/admin/invite-codes/client-options');
return response.data;
}
}

View File

@@ -0,0 +1,69 @@
import { apiClient } from './api';
import axios from 'axios';
import type {
InviteCodeApplication,
InviteCodeApplicationListRequest,
InviteCodeApplicationListResponse,
InviteCodeApplicationCreateRequest,
InviteCodeApplicationApproveRequest,
InviteCodeApplicationRejectRequest,
InviteCodeApplicationBatchApproveRequest,
InviteCodeApplicationBatchRejectRequest,
InviteCodeApplicationStatistics,
} from '../types/inviteCodeApplication';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8087';
export const InviteCodeApplicationApiService = {
// 获取申请列表(需要认证)
async getApplicationList(params?: InviteCodeApplicationListRequest): Promise<InviteCodeApplicationListResponse> {
const qs = new URLSearchParams();
if (params?.email) qs.set('email', params.email);
if (params?.status) qs.set('status', params.status);
if (params?.start_time) qs.set('start_time', params.start_time);
if (params?.end_time) qs.set('end_time', params.end_time);
if (params?.page) qs.set('page', String(params.page));
if (params?.size) qs.set('size', String(params.size));
const response = await apiClient.get<any>(`/admin/invite-applications?${qs.toString()}`);
return response.data;
},
// 获取统计信息(需要认证)
async getStatistics(): Promise<InviteCodeApplicationStatistics> {
const response = await apiClient.get<any>('/admin/invite-applications/statistics');
return response.data;
},
// 获取待处理数量(需要认证)
async getPendingCount(): Promise<number> {
const response = await apiClient.get<any>('/admin/invite-applications/pending-count');
return response.data.count;
},
// 审批通过申请(需要认证)
async approveApplication(request: InviteCodeApplicationApproveRequest): Promise<void> {
await apiClient.post('/admin/invite-applications/approve', request);
},
// 审批拒绝申请(需要认证)
async rejectApplication(request: InviteCodeApplicationRejectRequest): Promise<void> {
await apiClient.post('/admin/invite-applications/reject', request);
},
// 批量审批通过(需要认证)
async batchApproveApplications(request: InviteCodeApplicationBatchApproveRequest): Promise<void> {
await apiClient.post('/admin/invite-applications/batch-approve', request);
},
// 批量审批拒绝(需要认证)
async batchRejectApplications(request: InviteCodeApplicationBatchRejectRequest): Promise<void> {
await apiClient.post('/admin/invite-applications/batch-reject', request);
},
// 提交申请(公开接口,不需要认证)
async submitApplication(request: InviteCodeApplicationCreateRequest): Promise<InviteCodeApplication> {
const response = await axios.post(`${API_BASE_URL}/api/public/invite-code/apply`, request);
return response.data.data;
},
};

View File

@@ -0,0 +1,63 @@
import { apiClient } from './api';
import type {
MCPProvider,
MCPProviderListRequest,
MCPProviderListResponse,
MCPProviderCreateRequest,
MCPProviderUpdateRequest,
MCPProviderStatusRequest,
MCPProviderIsUsedRequest,
} from '../types/mcpProvider';
// 获取MCP供应商列表
export const getMCPProviderList = async (
params: MCPProviderListRequest
): Promise<MCPProviderListResponse> => {
const response = await apiClient.get('/admin/mcp-providers', { params });
return response;
};
// 获取MCP供应商详情
export const getMCPProviderById = async (id: string): Promise<MCPProvider> => {
const response = await apiClient.get(`/admin/mcp-providers/${id}`);
return response.data;
};
// 创建MCP供应商
export const createMCPProvider = async (
data: MCPProviderCreateRequest
): Promise<MCPProvider> => {
const response = await apiClient.post('/admin/mcp-providers', data);
return response.data;
};
// 更新MCP供应商
export const updateMCPProvider = async (
id: string,
data: MCPProviderUpdateRequest
): Promise<MCPProvider> => {
const response = await apiClient.put(`/admin/mcp-providers/${id}`, data);
return response.data;
};
// 删除MCP供应商
export const deleteMCPProvider = async (id: string): Promise<void> => {
await apiClient.delete(`/admin/mcp-providers/${id}`);
};
// 更新MCP供应商状态
export const updateMCPProviderStatus = async (
id: string,
data: MCPProviderStatusRequest
): Promise<void> => {
await apiClient.patch(`/admin/mcp-providers/${id}/status`, data);
};
// 更新MCP供应商使用状态
export const updateMCPProviderIsUsed = async (
id: string,
data: MCPProviderIsUsedRequest
): Promise<void> => {
await apiClient.patch(`/admin/mcp-providers/${id}/is-used`, data);
};

118
src/services/roleApi.ts Normal file
View File

@@ -0,0 +1,118 @@
import { apiClient } from './api';
// 角色相关类型定义
export interface Role {
id: number;
name: string;
description: string;
status: number;
createdAt: string;
updatedAt: string;
}
export interface RoleCreateRequest {
name: string;
description?: string;
}
export interface RoleUpdateRequest {
name: string;
description?: string;
status?: number;
}
export interface RoleListRequest {
page?: number;
size?: number;
name?: string;
status?: number;
}
export interface RoleListResponse {
roles: Role[];
total: number;
page: number;
size: number;
}
export interface RolePermissionAssignRequest {
roleId: number;
permissionIds: number[];
}
export interface RolePermissionsResponse {
role: Role;
permissions: any[];
}
// 角色管理API服务
export class RoleApiService {
/**
* 获取角色列表
*/
static async getRoles(params?: RoleListRequest): Promise<RoleListResponse> {
const queryParams = new URLSearchParams();
if (params?.page) queryParams.set('page', params.page.toString());
if (params?.size) queryParams.set('size', params.size.toString());
if (params?.name) queryParams.set('name', params.name);
if (params?.status !== undefined) queryParams.set('status', params.status.toString());
return await apiClient.get<RoleListResponse>(`/admin/roles?${queryParams.toString()}`);
}
/**
* 获取角色详情
*/
static async getRoleById(id: number): Promise<Role> {
return await apiClient.get<Role>(`/admin/roles/${id}`);
}
/**
* 创建角色
*/
static async createRole(roleData: RoleCreateRequest): Promise<Role> {
return await apiClient.post<Role>('/admin/roles', roleData);
}
/**
* 更新角色
*/
static async updateRole(id: number, roleData: RoleUpdateRequest): Promise<Role> {
return await apiClient.put<Role>(`/admin/roles/${id}`, roleData);
}
/**
* 删除角色
*/
static async deleteRole(id: number): Promise<void> {
await apiClient.delete(`/admin/roles/${id}`);
}
/**
* 更新角色状态
*/
static async updateRoleStatus(id: number, status: number): Promise<void> {
await apiClient.put(`/admin/roles/${id}/status`, { status });
}
/**
* 更新角色权限
*/
static async updateRolePermissions(id: number, permissionIds: number[]): Promise<void> {
await apiClient.put(`/admin/roles/${id}/permissions`, { permissionIds });
}
/**
* 获取角色权限
*/
static async getRolePermissions(id: number): Promise<any[]> {
return await apiClient.get<any[]>(`/admin/roles/${id}/permissions`);
}
/**
* 获取角色权限响应(包含角色和权限)
*/
static async getRolePermissionsResponse(id: number): Promise<RolePermissionsResponse> {
return await apiClient.get<RolePermissionsResponse>(`/admin/rbac/roles/${id}/permissions-response`);
}
}

View File

@@ -0,0 +1,65 @@
import { apiClient } from './api';
import type {
SystemConfig,
SystemConfigListRequest,
SystemConfigListResponse,
SystemConfigCreateRequest,
SystemConfigUpdateRequest,
SystemConfigStatusRequest,
} from '../types/systemConfig';
// 获取系统配置列表
export const getSystemConfigList = async (
params: SystemConfigListRequest
): Promise<SystemConfigListResponse> => {
const response = await apiClient.get('/admin/system-configs', { params });
return response; // 返回完整响应对象,包含 code, message, data, total, page, size
};
// 获取所有系统配置(不分页)
export const getAllSystemConfigs = async (): Promise<SystemConfig[]> => {
const response = await apiClient.get('/admin/system-configs/all');
return response.data;
};
// 获取系统配置详情
export const getSystemConfigById = async (id: number): Promise<SystemConfig> => {
const response = await apiClient.get(`/admin/system-configs/${id}`);
return response.data;
};
// 根据Key获取系统配置
export const getSystemConfigByKey = async (key: string): Promise<SystemConfig> => {
const response = await apiClient.get(`/admin/system-configs/key/${key}`);
return response.data;
};
// 创建系统配置
export const createSystemConfig = async (
data: SystemConfigCreateRequest
): Promise<SystemConfig> => {
const response = await apiClient.post('/admin/system-configs', data);
return response.data;
};
// 更新系统配置
export const updateSystemConfig = async (
id: number,
data: SystemConfigUpdateRequest
): Promise<SystemConfig> => {
const response = await apiClient.put(`/admin/system-configs/${id}`, data);
return response.data;
};
// 删除系统配置
export const deleteSystemConfig = async (id: number): Promise<void> => {
await apiClient.delete(`/admin/system-configs/${id}`);
};
// 更新系统配置状态
export const updateSystemConfigStatus = async (
id: number,
data: SystemConfigStatusRequest
): Promise<void> => {
await apiClient.put(`/admin/system-configs/${id}/status`, data);
};

188
src/services/userApi.ts Normal file
View File

@@ -0,0 +1,188 @@
import { apiClient } from './api';
// 用户相关类型定义
export interface User {
id: number;
username: string;
email: string;
nickname: string;
roleId: number;
status: number;
createdAt: string;
lastLoginAt?: string;
loginCount: number;
ssoUserId?: string;
ssoProvider?: string;
// 角色信息
role?: {
id: number;
name: string;
level: number;
description: string;
};
}
export interface UserCreateRequest {
username: string;
email: string;
nickname?: string;
roleId: number;
ssoProvider: string;
password: string;
}
export interface UserUpdateRequest {
nickname?: string;
email?: string;
avatar?: string;
status?: number;
roleId?: number;
ssoProvider?: string;
}
export interface UserListRequest {
page?: number;
size?: number;
username?: string;
email?: string;
status?: number;
role?: string;
}
export interface UserListResponse {
users: User[];
total: number;
page: number;
size: number;
}
export interface UserRoleAssignRequest {
userId: number;
roleIds: number[];
}
export interface UserPermissionsResponse {
user: User;
roles: any[];
permissions: any[];
menus: any[];
}
// 通用响应处理函数
interface ApiResponse<T> {
code: number;
message: string;
data: T;
}
// 用户管理API服务
export class UserApiService {
/**
* 获取用户列表
*/
static async getUsers(params?: UserListRequest): Promise<UserListResponse> {
const queryParams = new URLSearchParams();
if (params?.page) queryParams.set('page', params.page.toString());
if (params?.size) queryParams.set('size', params.size.toString());
if (params?.username) queryParams.set('username', params.username);
if (params?.email) queryParams.set('email', params.email);
if (params?.status !== undefined) queryParams.set('status', params.status.toString());
if (params?.role) queryParams.set('role', params.role);
const response = await apiClient.get<ApiResponse<UserListResponse>>(`/admin/users?${queryParams.toString()}`);
return response.data;
}
/**
* 获取用户详情
*/
static async getUserById(id: number): Promise<User> {
const response = await apiClient.get<ApiResponse<User>>(`/admin/users/${id}`);
return response.data;
}
/**
* 创建用户
*/
static async createUser(userData: UserCreateRequest): Promise<User> {
const response = await apiClient.post<ApiResponse<User>>('/admin/users', userData);
return response.data;
}
/**
* 更新用户
*/
static async updateUser(id: number, userData: UserUpdateRequest): Promise<User> {
const response = await apiClient.put<ApiResponse<User>>(`/admin/users/${id}`, userData);
return response.data;
}
/**
* 删除用户
*/
static async deleteUser(id: number): Promise<void> {
await apiClient.delete(`/admin/users/${id}`);
}
/**
* 更新用户状态
*/
static async updateUserStatus(id: number, status: number): Promise<void> {
await apiClient.put(`/admin/users/${id}/status`, { status });
}
/**
* 更新用户角色
*/
static async updateUserRoles(id: number, roleIds: number[]): Promise<void> {
await apiClient.put(`/admin/users/${id}/roles`, { roleIds });
}
/**
* 重置用户密码
*/
static async resetUserPassword(id: number, newPassword: string): Promise<void> {
await apiClient.post(`/admin/users/${id}/reset-password`, { newPassword });
}
/**
* 获取用户角色
*/
static async getUserRoles(id: number): Promise<any[]> {
return await apiClient.get<any[]>(`/admin/users/${id}/roles`);
}
/**
* 获取用户权限
*/
static async getUserPermissions(id: number): Promise<any[]> {
return await apiClient.get<any[]>(`/admin/users/${id}/permissions`);
}
/**
* 获取用户权限响应(包含用户、角色、权限、菜单)
*/
static async getUserPermissionsResponse(id: number): Promise<UserPermissionsResponse> {
return await apiClient.get<UserPermissionsResponse>(`/admin/rbac/users/${id}/permissions-response`);
}
/**
* 检查用户系统角色
*/
static async checkUserRole(userId: number): Promise<{ is_system_admin: boolean; current_role: string }> {
const response = await apiClient.get<ApiResponse<{ is_system_admin: boolean; current_role: string }>>(
`/admin/users/check-role/${userId}`
);
return response.data;
}
/**
* 变更用户系统角色
*/
static async changeUserSystemRole(userId: number, systemRole: 'sys_admin' | 'custom'): Promise<void> {
await apiClient.post('/admin/users/change-system-role', {
user_id: userId,
system_role: systemRole,
});
}
}

View File

@@ -0,0 +1,59 @@
import { apiClient } from './api';
import type {
UserLevelConfig,
UserLevelConfigListRequest,
UserLevelConfigListResponse,
UserLevelConfigCreateRequest,
UserLevelConfigUpdateRequest,
UserLevelConfigStatusRequest,
} from '../types/userLevelConfig';
// 获取用户等级配置列表
export const getUserLevelConfigList = async (
params: UserLevelConfigListRequest
): Promise<UserLevelConfigListResponse> => {
const response = await apiClient.get('/admin/user-level-configs', { params });
return response.data;
};
// 获取所有用户等级配置(不分页)
export const getAllUserLevelConfigs = async (): Promise<UserLevelConfig[]> => {
const response = await apiClient.get('/admin/user-level-configs/all');
return response.data;
};
// 获取用户等级配置详情
export const getUserLevelConfigById = async (id: number): Promise<UserLevelConfig> => {
const response = await apiClient.get(`/admin/user-level-configs/${id}`);
return response.data;
};
// 创建用户等级配置
export const createUserLevelConfig = async (
data: UserLevelConfigCreateRequest
): Promise<UserLevelConfig> => {
const response = await apiClient.post('/admin/user-level-configs', data);
return response.data;
};
// 更新用户等级配置
export const updateUserLevelConfig = async (
id: number,
data: UserLevelConfigUpdateRequest
): Promise<UserLevelConfig> => {
const response = await apiClient.put(`/admin/user-level-configs/${id}`, data);
return response.data;
};
// 删除用户等级配置
export const deleteUserLevelConfig = async (id: number): Promise<void> => {
await apiClient.delete(`/admin/user-level-configs/${id}`);
};
// 更新用户等级配置状态
export const updateUserLevelConfigStatus = async (
id: number,
data: UserLevelConfigStatusRequest
): Promise<void> => {
await apiClient.put(`/admin/user-level-configs/${id}/status`, data);
};

34
src/types/auditLog.ts Normal file
View File

@@ -0,0 +1,34 @@
export interface AuditLog {
id: number;
operation_type: string;
operation_time: string;
operator_id: number;
operator_email: string;
target_type?: string | null;
target_id?: number | null;
target_email?: string | null;
operation_details?: Record<string, any> | null;
ip_address?: string | null;
user_agent?: string | null;
status?: string | null;
error_message?: string | null;
}
export interface AuditLogListRequest {
operation_type?: string;
operator_email?: string;
target_email?: string;
start_time?: string;
end_time?: string;
page?: number;
size?: number;
sort_by?: string;
sort_order?: 'asc' | 'desc';
}
export interface AuditLogListResponse {
list: AuditLog[];
total: number;
page: number;
size: number;
}

56
src/types/inviteCode.ts Normal file
View File

@@ -0,0 +1,56 @@
// 简化版邀请码类型定义
export interface InviteCode {
id: number;
code: string;
is_used: boolean;
client_id?: string;
client_label?: string;
email?: string;
user_level_id?: number;
user_level_name?: string;
expires_at?: string | null;
created_at: string;
invite_url?: string;
}
export interface InviteCodeListRequest {
code?: string;
is_used?: boolean;
start_time?: string;
end_time?: string;
page?: number;
size?: number;
}
export interface InviteCodeCreateRequest {
emails?: string[];
user_level_id?: number;
expires_at?: string;
client_id?: string; // 保留向后兼容
}
export interface InviteCodeUpdateRequest {
client_id?: string;
email?: string;
user_level_id?: number;
expires_at?: string;
}
export interface InviteCodeStatistics {
total: number;
used: number;
unused: number;
today_created: number;
}
export interface InviteCodeListResponse {
list: InviteCode[];
total: number;
}
// 客户端选项
export interface ClientOption {
key: string;
value: string;
label: string;
}

View File

@@ -0,0 +1,64 @@
export interface InviteCodeApplication {
id: number;
email: string;
reason?: string;
status: 'pending' | 'approved' | 'rejected';
invite_code_id?: number;
invite_code?: {
id: number;
code: string;
expires_at?: string;
};
reject_reason?: string;
approved_at?: string;
approved_by?: string;
email_sent_at?: string;
created_at: string;
updated_at: string;
}
export interface InviteCodeApplicationListRequest {
email?: string;
status?: 'pending' | 'approved' | 'rejected';
start_time?: string;
end_time?: string;
page?: number;
size?: number;
}
export interface InviteCodeApplicationListResponse {
list: InviteCodeApplication[];
total: number;
}
export interface InviteCodeApplicationCreateRequest {
email: string;
reason?: string;
}
export interface InviteCodeApplicationApproveRequest {
application_id: number;
valid_days?: number; // 有效期天数默认7天
}
export interface InviteCodeApplicationRejectRequest {
application_id: number;
reject_reason?: string;
}
export interface InviteCodeApplicationBatchApproveRequest {
application_ids: number[];
valid_days?: number;
}
export interface InviteCodeApplicationBatchRejectRequest {
application_ids: number[];
reject_reason?: string;
}
export interface InviteCodeApplicationStatistics {
total_pending: number; // 待处理数量
total_approved: number; // 已通过数量
total_rejected: number; // 已拒绝数量
today_applied: number; // 今日申请数量
}

67
src/types/mcpProvider.ts Normal file
View File

@@ -0,0 +1,67 @@
export interface MCPProvider {
id: string;
provider: string;
base_url: string;
auth?: string;
account?: string;
price_type: string;
price: number;
floating_ratio: number;
is_used: boolean;
status: string;
description?: string;
created_at: string;
updated_at: string;
}
export interface MCPProviderListRequest {
page?: number;
page_size?: number;
provider?: string;
status?: string;
is_used?: boolean;
}
export interface MCPProviderListResponse {
data: MCPProvider[];
total: number;
page: number;
size: number;
}
export interface MCPProviderCreateRequest {
provider: string;
base_url: string;
auth?: string;
account?: string;
price_type: string;
price: number;
floating_ratio?: number;
is_used?: boolean;
status?: string;
description?: string;
}
export interface MCPProviderUpdateRequest {
provider?: string;
base_url?: string;
auth?: string;
account?: string;
price_type?: string;
price?: number;
floating_ratio?: number;
is_used?: boolean;
status?: string;
description?: string;
}
export interface MCPProviderStatusRequest {
status: 'active' | 'inactive';
}
export interface MCPProviderIsUsedRequest {
is_used: boolean;
}

66
src/types/quota.ts Normal file
View File

@@ -0,0 +1,66 @@
// 配额历史查询请求
export interface QuotaHistoryRequest {
start_date: string; // 开始日期 (YYYY-MM-DD)
end_date: string; // 结束日期 (YYYY-MM-DD)
user_id?: string; // 用户ID (可选)
api_group?: string; // API组 (可选)
project_id?: string; // 项目ID (可选)
period?: string; // 周期: daily, monthly
group_by?: string[]; // 分组维度
}
// 配额历史数据项
export interface QuotaHistoryItem {
id: number;
user_id: string;
api_group: string;
project_id: string;
day: string;
account: string;
model: string;
quota_used: number;
created_at: string;
}
// 配额历史查询响应
export interface QuotaHistoryResponse {
success: boolean;
data: QuotaHistoryItem[];
message?: string;
}
// 统计概览数据
export interface QuotaStats {
totalQuota: number;
totalUsers: number;
totalProjects: number;
totalModels: number;
dailyUsage: Array<{
date: string;
quota: number;
}>;
topUsers: Array<{
user_id: string;
quota_used: number;
}>;
topModels: Array<{
model: string;
quota_used: number;
}>;
}
// 图表数据
export interface ChartData {
name: string;
value: number;
color?: string;
}
// 时间范围选项
export interface TimeRange {
label: string;
value: string;
days: number;
}

50
src/types/systemConfig.ts Normal file
View File

@@ -0,0 +1,50 @@
// 系统配置类型定义
export interface SystemConfig {
id: number;
key: string;
name: string;
value: string;
type: string;
desc: string;
status: number; // 1-启用 0-禁用
createdAt: string;
updatedAt: string;
deletedAt?: string | null;
}
export interface SystemConfigListRequest {
key?: string;
name?: string;
status?: number;
page?: number;
size?: number;
}
export interface SystemConfigListResponse {
code: number;
message: string;
data: SystemConfig[];
total: number;
page: number;
size: number;
}
export interface SystemConfigCreateRequest {
key: string;
name: string;
value: string;
type?: string;
desc?: string;
}
export interface SystemConfigUpdateRequest {
name: string;
value: string;
type?: string;
desc?: string;
}
export interface SystemConfigStatusRequest {
status: number;
}

View File

@@ -0,0 +1,55 @@
// 用户等级配置类型定义
export interface UserLevelConfig {
id: number;
level_name: string;
level_code: string;
project_limit: number;
coder_vm_limit: number; // Coder VM上限
browser_vm_limit: number; // Browser VM上限
process_limit: number; // 进程上限
description: string;
sort_order: number;
status: number; // 1-启用 0-禁用
created_at: string;
updated_at: string;
}
export interface UserLevelConfigListRequest {
level_name?: string;
status?: number;
page?: number;
size?: number;
}
export interface UserLevelConfigListResponse {
data: UserLevelConfig[];
total: number;
page: number;
size: number;
}
export interface UserLevelConfigCreateRequest {
level_name: string;
level_code: string;
project_limit: number;
coder_vm_limit: number;
browser_vm_limit: number;
process_limit: number;
description?: string;
sort_order?: number;
}
export interface UserLevelConfigUpdateRequest {
level_name: string;
project_limit: number;
coder_vm_limit: number;
browser_vm_limit: number;
process_limit: number;
description?: string;
sort_order?: number;
}
export interface UserLevelConfigStatusRequest {
status: number;
}

View File

@@ -0,0 +1,25 @@
export interface UserProjectQuotaItem {
id: number
user_id: string
project_limit: number
coder_vm_limit: number
browser_vm_limit: number
process_limit: number
enabled: boolean
description?: string
created_at?: string
updated_at?: string
}
export interface PageResponse<T> {
code: number
message: string
data: {
data?: T[]
items?: T[]
total: number
page: number
size: number
}
}

View File

@@ -0,0 +1,22 @@
// 存储迁移工具将旧的存储key迁移到新的统一key
export const migrateStorage = () => {
// 迁移 admin_auth_token -> token
const oldToken = localStorage.getItem('admin_auth_token');
if (oldToken) {
// 移除Bearer前缀只存储裸token
const cleanToken = oldToken.replace('Bearer ', '');
localStorage.setItem('token', cleanToken);
localStorage.removeItem('admin_auth_token');
}
// 迁移 admin_refresh_token -> refreshToken
const oldRefreshToken = localStorage.getItem('admin_refresh_token');
if (oldRefreshToken) {
localStorage.setItem('refreshToken', oldRefreshToken);
localStorage.removeItem('admin_refresh_token');
}
// 清理旧的存储
localStorage.removeItem('admin_auth_token');
localStorage.removeItem('admin_refresh_token');
};

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

@@ -0,0 +1,14 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string;
readonly VITE_APP_TITLE: string;
readonly VITE_APP_VERSION: string;
readonly VITE_ENABLE_DEVTOOLS: string;
readonly VITE_USE_MOCK: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

29
tsconfig.app.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"allowSyntheticDefaultImports": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

26
vite.config.ts Normal file
View File

@@ -0,0 +1,26 @@
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig(({ mode }) => {
// 加载环境变量
const env = loadEnv(mode, process.cwd())
return {
plugins: [react()],
server: {
port: 3003,
host: '0.0.0.0',
open: true,
headers: {
'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:;"
},
proxy: {
'/api': {
target: env.VITE_API_BASE_URL || 'http://localhost:8087',
changeOrigin: true,
},
},
},
}
})