feat():learning后台管理前端页面初始化
This commit is contained in:
557
API_PATTERNS.md
Normal file
557
API_PATTERNS.md
Normal 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; // 返回完整响应对象
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模式 3:CRUD 操作
|
||||||
|
|
||||||
|
```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 和拦截器)
|
||||||
|
↓
|
||||||
|
Axios(HTTP 请求)
|
||||||
|
↓
|
||||||
|
后端 API
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关键要点
|
||||||
|
|
||||||
|
1. **分层设计**:每一层职责清晰
|
||||||
|
2. **类型安全**:完整的 TypeScript 类型支持
|
||||||
|
3. **错误处理**:统一的错误处理机制
|
||||||
|
4. **权限集成**:无缝的权限管理
|
||||||
|
5. **状态管理**:简化的本地状态管理
|
||||||
|
6. **自动化**:自动 Token 管理和刷新
|
||||||
|
|
||||||
306
DOCUMENTATION_INDEX.md
Normal file
306
DOCUMENTATION_INDEX.md
Normal 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
33
Dockerfile
Normal 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
303
EXPLORATION_SUMMARY.md
Normal 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
113
EXPLORATION_SUMMARY.txt
Normal 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
1527
FRONTEND_GUIDE.md
Normal file
File diff suppressed because it is too large
Load Diff
480
PROJECT_ARCHITECTURE.md
Normal file
480
PROJECT_ARCHITECTURE.md
Normal 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
392
QUICK_REFERENCE.md
Normal 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. 检查是否有错误导致代码未执行到刷新逻辑
|
||||||
|
|
||||||
|
### 问题 4:Token 过期导致 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
566
QUICK_START.md
Normal 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 请求会自动附带 Token,401 错误会自动刷新 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
178
README.md
Normal 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
78
README_ENV.md
Normal 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
82
build-and-push.sh
Executable 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
15
env.example
Normal 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
23
eslint.config.js
Normal 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
13
index.html
Normal 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
49
k8s/configmap.yaml
Normal 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
58
k8s/deployment.yaml
Normal 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
38
k8s/httproute.yaml
Normal 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
16
k8s/service.yaml
Normal 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
42
nginx.conf
Normal 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
4769
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal 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
1
public/vite.svg
Normal 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
214
src/App.css
Normal 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
276
src/App.tsx
Normal 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
1
src/assets/react.svg
Normal 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
36
src/atoms/auth.ts
Normal 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);
|
||||||
90
src/components/AuthGuard.tsx
Normal file
90
src/components/AuthGuard.tsx
Normal 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;
|
||||||
108
src/components/DynamicMenu.css
Normal file
108
src/components/DynamicMenu.css
Normal 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;
|
||||||
|
}
|
||||||
250
src/components/DynamicMenu.tsx
Normal file
250
src/components/DynamicMenu.tsx
Normal 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
391
src/components/Layout.tsx
Normal 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;
|
||||||
24
src/components/PagePermissionGuard.tsx
Normal file
24
src/components/PagePermissionGuard.tsx
Normal 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;
|
||||||
57
src/components/PermissionGuard.tsx
Normal file
57
src/components/PermissionGuard.tsx
Normal 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;
|
||||||
142
src/components/QuotaCharts.tsx
Normal file
142
src/components/QuotaCharts.tsx
Normal 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;
|
||||||
|
|
||||||
|
|
||||||
162
src/components/QuotaFilters.tsx
Normal file
162
src/components/QuotaFilters.tsx
Normal 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;
|
||||||
|
|
||||||
|
|
||||||
118
src/components/QuotaHistoryTable.tsx
Normal file
118
src/components/QuotaHistoryTable.tsx
Normal 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;
|
||||||
|
|
||||||
|
|
||||||
82
src/components/QuotaStats.tsx
Normal file
82
src/components/QuotaStats.tsx
Normal 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;
|
||||||
|
|
||||||
|
|
||||||
87
src/components/UserProjectQuotaPage.tsx
Normal file
87
src/components/UserProjectQuotaPage.tsx
Normal 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
415
src/hooks/useAuth.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
74
src/hooks/usePagePermissions.ts
Normal file
74
src/hooks/usePagePermissions.ts
Normal 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
|
||||||
|
};
|
||||||
|
};
|
||||||
46
src/hooks/usePermissions.ts
Normal file
46
src/hooks/usePermissions.ts
Normal 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
1
src/index.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/* 基础样式重置 - 不干扰 App.css 的设计 */
|
||||||
28
src/main.tsx
Normal file
28
src/main.tsx
Normal 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
315
src/pages/AuditLogs.tsx
Normal 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
42
src/pages/Dashboard.tsx
Normal 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
47
src/pages/Finance.tsx
Normal 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;
|
||||||
544
src/pages/FinanceMcpAccountRechargeRecords.tsx
Normal file
544
src/pages/FinanceMcpAccountRechargeRecords.tsx
Normal 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;
|
||||||
|
|
||||||
85
src/pages/FinanceMcpUsage.tsx
Normal file
85
src/pages/FinanceMcpUsage.tsx
Normal 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;
|
||||||
|
|
||||||
|
|
||||||
567
src/pages/FinanceModelAccountRechargeRecords.tsx
Normal file
567
src/pages/FinanceModelAccountRechargeRecords.tsx
Normal 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;
|
||||||
|
|
||||||
165
src/pages/FinancePaymentRecords.tsx
Normal file
165
src/pages/FinancePaymentRecords.tsx
Normal 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;
|
||||||
|
|
||||||
79
src/pages/FinanceSandboxCosts.tsx
Normal file
79
src/pages/FinanceSandboxCosts.tsx
Normal 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;
|
||||||
|
|
||||||
|
|
||||||
92
src/pages/FinanceTokenUsage.tsx
Normal file
92
src/pages/FinanceTokenUsage.tsx
Normal 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;
|
||||||
|
|
||||||
|
|
||||||
111
src/pages/FinanceTransactionLogs.tsx
Normal file
111
src/pages/FinanceTransactionLogs.tsx
Normal 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;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
532
src/pages/GoalfyMaxUsers.tsx
Normal file
532
src/pages/GoalfyMaxUsers.tsx
Normal 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
954
src/pages/InviteCodes.tsx
Normal 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="请输入邮箱地址,例如: user1@example.com user2@example.com 或: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;
|
||||||
517
src/pages/MCPProviderPricing.tsx
Normal file
517
src/pages/MCPProviderPricing.tsx
Normal 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
566
src/pages/MessagePush.tsx
Normal 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
83
src/pages/Monitoring.tsx
Normal 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;
|
||||||
42
src/pages/NoPermission.tsx
Normal file
42
src/pages/NoPermission.tsx
Normal 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
51
src/pages/Operations.tsx
Normal 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
42
src/pages/Overview.tsx
Normal 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;
|
||||||
634
src/pages/RoleManagement.tsx
Normal file
634
src/pages/RoleManagement.tsx
Normal 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
283
src/pages/SystemConfigs.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/pages/SystemHealth.tsx
Normal file
27
src/pages/SystemHealth.tsx
Normal 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;
|
||||||
27
src/pages/TokenAnalytics.tsx
Normal file
27
src/pages/TokenAnalytics.tsx
Normal 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
190
src/pages/TokenHistory.tsx
Normal 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
489
src/pages/UserFeedback.tsx
Normal 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;
|
||||||
328
src/pages/UserLevelConfigs.tsx
Normal file
328
src/pages/UserLevelConfigs.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
585
src/pages/UserManagement.tsx
Normal file
585
src/pages/UserManagement.tsx
Normal 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;
|
||||||
202
src/pages/UserProjectQuota.tsx
Normal file
202
src/pages/UserProjectQuota.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
418
src/pages/VendorModelPricing.tsx
Normal file
418
src/pages/VendorModelPricing.tsx
Normal 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
732
src/pages/VmPricing.tsx
Normal 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;
|
||||||
|
|
||||||
109
src/pages/__tests__/InviteCodes.test.tsx
Normal file
109
src/pages/__tests__/InviteCodes.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
95
src/routes/DynamicRoutes.tsx
Normal file
95
src/routes/DynamicRoutes.tsx
Normal 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
911
src/services/api.ts
Normal 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`, {});
|
||||||
|
},
|
||||||
|
};
|
||||||
21
src/services/auditLogApi.ts
Normal file
21
src/services/auditLogApi.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/services/inviteCodeApi.ts
Normal file
59
src/services/inviteCodeApi.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/services/inviteCodeApplicationApi.ts
Normal file
69
src/services/inviteCodeApplicationApi.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
63
src/services/mcpProviderApi.ts
Normal file
63
src/services/mcpProviderApi.ts
Normal 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
118
src/services/roleApi.ts
Normal 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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/services/systemConfigApi.ts
Normal file
65
src/services/systemConfigApi.ts
Normal 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
188
src/services/userApi.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/services/userLevelConfigApi.ts
Normal file
59
src/services/userLevelConfigApi.ts
Normal 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
34
src/types/auditLog.ts
Normal 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
56
src/types/inviteCode.ts
Normal 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;
|
||||||
|
}
|
||||||
64
src/types/inviteCodeApplication.ts
Normal file
64
src/types/inviteCodeApplication.ts
Normal 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
67
src/types/mcpProvider.ts
Normal 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
66
src/types/quota.ts
Normal 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
50
src/types/systemConfig.ts
Normal 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;
|
||||||
|
}
|
||||||
55
src/types/userLevelConfig.ts
Normal file
55
src/types/userLevelConfig.ts
Normal 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;
|
||||||
|
}
|
||||||
25
src/types/userProjectQuota.ts
Normal file
25
src/types/userProjectQuota.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
22
src/utils/storageMigration.ts
Normal file
22
src/utils/storageMigration.ts
Normal 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
14
src/vite-env.d.ts
vendored
Normal 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
29
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
tsconfig.node.json
Normal file
26
tsconfig.node.json
Normal 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
26
vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user