fix: connect API to tracker.db via SQLite repository
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 认证相关 API
|
||||
*/
|
||||
import apiClient from './client';
|
||||
|
||||
// 类型定义
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
username: string;
|
||||
display_name?: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
is_email_verified: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// 登录
|
||||
export const login = async (data: LoginRequest): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post('/auth/login', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 注册
|
||||
export const register = async (data: RegisterRequest): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post('/auth/register', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 登出
|
||||
export const logout = async (): Promise<void> => {
|
||||
await apiClient.post('/auth/logout');
|
||||
};
|
||||
|
||||
// 获取当前用户
|
||||
export const getCurrentUser = async (): Promise<User> => {
|
||||
const response = await apiClient.get('/auth/me');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 请求密码重置
|
||||
export const requestPasswordReset = async (email: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post('/auth/forgot-password', { email });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 重置密码
|
||||
export const resetPassword = async (
|
||||
token: string,
|
||||
newPassword: string
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post('/auth/reset-password', {
|
||||
token,
|
||||
new_password: newPassword,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 验证邮箱
|
||||
export const verifyEmail = async (token: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post('/auth/verify-email', { token });
|
||||
return response.data;
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* API 客户端配置
|
||||
* 封装 Axios 实例,配置拦截器和 Token 管理
|
||||
*/
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
|
||||
// 创建 Axios 实例
|
||||
const apiClient = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// 请求拦截器:添加 Token
|
||||
apiClient.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 响应拦截器:处理错误和 Token 刷新
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// Token 过期,尝试刷新
|
||||
if (error.response?.status === 401 && originalRequest) {
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const { data } = await axios.post('/api/v1/auth/refresh', {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
// 保存新 Token
|
||||
localStorage.setItem('access_token', data.access_token);
|
||||
|
||||
// 重试原请求
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${data.access_token}`;
|
||||
}
|
||||
return apiClient(originalRequest);
|
||||
} catch (refreshError) {
|
||||
// 刷新失败,清除 Token 并跳转登录
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
} else {
|
||||
// 没有 refresh token,直接跳转登录
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 订阅相关 API
|
||||
*/
|
||||
import apiClient from './client';
|
||||
|
||||
// 类型定义
|
||||
export interface SubscriptionPlan {
|
||||
name: 'free' | 'pro' | 'enterprise';
|
||||
display_name: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
max_projects: number;
|
||||
max_storage_gb: number;
|
||||
features: string[];
|
||||
}
|
||||
|
||||
export interface QuotaStatus {
|
||||
projects: {
|
||||
used: number;
|
||||
limit: number;
|
||||
status: 'normal' | 'warning' | 'critical' | 'exceeded';
|
||||
};
|
||||
storage: {
|
||||
used_gb: number;
|
||||
limit_gb: number;
|
||||
status: 'normal' | 'warning' | 'critical' | 'exceeded';
|
||||
};
|
||||
}
|
||||
|
||||
// 获取所有订阅计划
|
||||
export const getPlans = async (): Promise<SubscriptionPlan[]> => {
|
||||
const response = await apiClient.get('/subscriptions/plans');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 获取当前订阅
|
||||
export const getCurrentSubscription = async (
|
||||
workspaceId: string
|
||||
): Promise<SubscriptionPlan> => {
|
||||
const response = await apiClient.get(`/workspaces/${workspaceId}/subscription`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 升级订阅
|
||||
export const upgradeSubscription = async (
|
||||
workspaceId: string,
|
||||
plan: 'pro' | 'enterprise'
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post(`/workspaces/${workspaceId}/subscription/upgrade`, {
|
||||
plan,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 取消订阅
|
||||
export const cancelSubscription = async (
|
||||
workspaceId: string
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post(`/workspaces/${workspaceId}/subscription/cancel`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 获取配额状态
|
||||
export const getQuotaStatus = async (workspaceId: string): Promise<QuotaStatus> => {
|
||||
const response = await apiClient.get(`/workspaces/${workspaceId}/quota`);
|
||||
return response.data;
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 工作空间相关 API
|
||||
*/
|
||||
import apiClient from './client';
|
||||
|
||||
// 类型定义
|
||||
export interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
owner_user_id: string;
|
||||
subscription_plan: 'free' | 'pro' | 'enterprise';
|
||||
subscription_status: 'active' | 'inactive' | 'expired';
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateWorkspaceRequest {
|
||||
name: string;
|
||||
subscription_plan?: 'free' | 'pro' | 'enterprise';
|
||||
}
|
||||
|
||||
export interface WorkspaceMember {
|
||||
id: string;
|
||||
user_id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
role: 'owner' | 'admin' | 'member' | 'viewer';
|
||||
joined_at: string;
|
||||
}
|
||||
|
||||
export interface InviteMemberRequest {
|
||||
email: string;
|
||||
role: 'admin' | 'member' | 'viewer';
|
||||
}
|
||||
|
||||
// 获取工作空间列表
|
||||
export const getWorkspaces = async (): Promise<Workspace[]> => {
|
||||
const response = await apiClient.get('/workspaces');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 获取工作空间详情
|
||||
export const getWorkspace = async (id: string): Promise<Workspace> => {
|
||||
const response = await apiClient.get(`/workspaces/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 创建工作空间
|
||||
export const createWorkspace = async (
|
||||
data: CreateWorkspaceRequest
|
||||
): Promise<Workspace> => {
|
||||
const response = await apiClient.post('/workspaces', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 获取成员列表
|
||||
export const getMembers = async (workspaceId: string): Promise<WorkspaceMember[]> => {
|
||||
const response = await apiClient.get(`/workspaces/${workspaceId}/members`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 邀请成员
|
||||
export const inviteMember = async (
|
||||
workspaceId: string,
|
||||
data: InviteMemberRequest
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post(`/workspaces/${workspaceId}/members/invite`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 移除成员
|
||||
export const removeMember = async (
|
||||
workspaceId: string,
|
||||
memberId: string
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.delete(`/workspaces/${workspaceId}/members/${memberId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 更新成员角色
|
||||
export const updateMemberRole = async (
|
||||
workspaceId: string,
|
||||
memberId: string,
|
||||
role: 'admin' | 'member' | 'viewer'
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.patch(`/workspaces/${workspaceId}/members/${memberId}`, {
|
||||
role,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 离开工作空间
|
||||
export const leaveWorkspace = async (workspaceId: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post(`/workspaces/${workspaceId}/leave`);
|
||||
return response.data;
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 邀请成员弹窗
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Modal, Form, Input, Select, message } from 'antd';
|
||||
import { useInviteMember } from '@/hooks/useWorkspace';
|
||||
|
||||
interface InviteMemberModalProps {
|
||||
workspaceId: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const InviteMemberModal: React.FC<InviteMemberModalProps> = ({
|
||||
workspaceId,
|
||||
open,
|
||||
onClose,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const inviteMutation = useInviteMember(workspaceId);
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
await inviteMutation.mutateAsync(values);
|
||||
message.success('邀请已发送!');
|
||||
form.resetFields();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
// 验证失败或请求失败
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
form.resetFields();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="邀请成员"
|
||||
open={open}
|
||||
onOk={handleOk}
|
||||
onCancel={handleCancel}
|
||||
confirmLoading={inviteMutation.isPending}
|
||||
okText="发送邀请"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="邮箱地址"
|
||||
rules={[
|
||||
{ required: true, message: '请输入邮箱地址' },
|
||||
{ type: 'email', message: '请输入有效的邮箱地址' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="member@example.com" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="role"
|
||||
label="角色"
|
||||
initialValue="member"
|
||||
rules={[{ required: true, message: '请选择角色' }]}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'admin', label: '管理员 - 可以管理成员和项目' },
|
||||
{ value: 'member', label: '成员 - 可以创建和管理项目' },
|
||||
{ value: 'viewer', label: '访客 - 只读权限' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default InviteMemberModal;
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 成员列表组件
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Table, Button, Tag, Space, Popconfirm, message, Select } from 'antd';
|
||||
import { DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
||||
import { useWorkspaceMembers } from '@/hooks/useWorkspace';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { removeMember, updateMemberRole } from '@/api/workspace';
|
||||
|
||||
interface MemberListProps {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
const MemberList: React.FC<MemberListProps> = ({ workspaceId }) => {
|
||||
const { data: members, isLoading } = useWorkspaceMembers(workspaceId);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (memberId: string) => removeMember(workspaceId, memberId),
|
||||
onSuccess: () => {
|
||||
message.success('成员已移除');
|
||||
queryClient.invalidateQueries({ queryKey: ['workspaceMembers', workspaceId] });
|
||||
},
|
||||
});
|
||||
|
||||
const updateRoleMutation = useMutation({
|
||||
mutationFn: ({ memberId, role }: { memberId: string; role: string }) =>
|
||||
updateMemberRole(workspaceId, memberId, role as any),
|
||||
onSuccess: () => {
|
||||
message.success('角色已更新');
|
||||
queryClient.invalidateQueries({ queryKey: ['workspaceMembers', workspaceId] });
|
||||
},
|
||||
});
|
||||
|
||||
const getRoleTag = (role: string) => {
|
||||
const roleConfig: Record<string, { color: string; text: string }> = {
|
||||
owner: { color: 'gold', text: '拥有者' },
|
||||
admin: { color: 'blue', text: '管理员' },
|
||||
member: { color: 'green', text: '成员' },
|
||||
viewer: { color: 'default', text: '访客' },
|
||||
};
|
||||
const config = roleConfig[role] || roleConfig.member;
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '用户名',
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
},
|
||||
{
|
||||
title: '邮箱',
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
key: 'role',
|
||||
render: (role: string, record: any) => {
|
||||
if (role === 'owner') {
|
||||
return getRoleTag(role);
|
||||
}
|
||||
return (
|
||||
<Select
|
||||
value={role}
|
||||
style={{ width: 120 }}
|
||||
onChange={(newRole) =>
|
||||
updateRoleMutation.mutate({ memberId: record.id, role: newRole })
|
||||
}
|
||||
options={[
|
||||
{ value: 'admin', label: '管理员' },
|
||||
{ value: 'member', label: '成员' },
|
||||
{ value: 'viewer', label: '访客' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '加入时间',
|
||||
dataIndex: 'joined_at',
|
||||
key: 'joined_at',
|
||||
render: (date: string) => new Date(date).toLocaleDateString(),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: any, record: any) => {
|
||||
if (record.role === 'owner') {
|
||||
return <span style={{ color: '#999' }}>-</span>;
|
||||
}
|
||||
return (
|
||||
<Space>
|
||||
<Popconfirm
|
||||
title="确定移除该成员?"
|
||||
onConfirm={() => removeMutation.mutate(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={removeMutation.isPending}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={members}
|
||||
loading={isLoading}
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 10 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemberList;
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 权限矩阵展示组件
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Card, Table } from 'antd';
|
||||
import { CheckOutlined, CloseOutlined } from '@ant-design/icons';
|
||||
|
||||
const PermissionMatrix: React.FC = () => {
|
||||
const permissions = [
|
||||
{ action: '查看工作空间', owner: true, admin: true, member: true, viewer: true },
|
||||
{ action: '创建项目', owner: true, admin: true, member: true, viewer: false },
|
||||
{ action: '编辑项目', owner: true, admin: true, member: true, viewer: false },
|
||||
{ action: '删除项目', owner: true, admin: true, member: false, viewer: false },
|
||||
{ action: '邀请成员', owner: true, admin: true, member: false, viewer: false },
|
||||
{ action: '移除成员', owner: true, admin: true, member: false, viewer: false },
|
||||
{ action: '修改成员角色', owner: true, admin: true, member: false, viewer: false },
|
||||
{ action: '升级订阅', owner: true, admin: false, member: false, viewer: false },
|
||||
{ action: '删除工作空间', owner: true, admin: false, member: false, viewer: false },
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
fixed: 'left' as const,
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '拥有者',
|
||||
dataIndex: 'owner',
|
||||
key: 'owner',
|
||||
align: 'center' as const,
|
||||
render: (value: boolean) =>
|
||||
value ? (
|
||||
<CheckOutlined style={{ color: '#52c41a', fontSize: '16px' }} />
|
||||
) : (
|
||||
<CloseOutlined style={{ color: '#ff4d4f', fontSize: '16px' }} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '管理员',
|
||||
dataIndex: 'admin',
|
||||
key: 'admin',
|
||||
align: 'center' as const,
|
||||
render: (value: boolean) =>
|
||||
value ? (
|
||||
<CheckOutlined style={{ color: '#52c41a', fontSize: '16px' }} />
|
||||
) : (
|
||||
<CloseOutlined style={{ color: '#ff4d4f', fontSize: '16px' }} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '成员',
|
||||
dataIndex: 'member',
|
||||
key: 'member',
|
||||
align: 'center' as const,
|
||||
render: (value: boolean) =>
|
||||
value ? (
|
||||
<CheckOutlined style={{ color: '#52c41a', fontSize: '16px' }} />
|
||||
) : (
|
||||
<CloseOutlined style={{ color: '#ff4d4f', fontSize: '16px' }} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '访客',
|
||||
dataIndex: 'viewer',
|
||||
key: 'viewer',
|
||||
align: 'center' as const,
|
||||
render: (value: boolean) =>
|
||||
value ? (
|
||||
<CheckOutlined style={{ color: '#52c41a', fontSize: '16px' }} />
|
||||
) : (
|
||||
<CloseOutlined style={{ color: '#ff4d4f', fontSize: '16px' }} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card title="权限说明">
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={permissions}
|
||||
pagination={false}
|
||||
rowKey="action"
|
||||
size="small"
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PermissionMatrix;
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 配额使用展示组件
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Card, Progress, Row, Col, Tag, Space } from 'antd';
|
||||
import { ProjectOutlined, CloudOutlined, WarningOutlined } from '@ant-design/icons';
|
||||
import type { QuotaStatus } from '@/api/subscription';
|
||||
|
||||
interface QuotaDisplayProps {
|
||||
quota: QuotaStatus;
|
||||
}
|
||||
|
||||
const QuotaDisplay: React.FC<QuotaDisplayProps> = ({ quota }) => {
|
||||
const getStatusTag = (status: string) => {
|
||||
const config: Record<string, { color: string; text: string; icon: React.ReactNode }> = {
|
||||
normal: { color: 'success', text: '正常', icon: null },
|
||||
warning: { color: 'warning', text: '接近上限', icon: <WarningOutlined /> },
|
||||
critical: { color: 'error', text: '即将超限', icon: <WarningOutlined /> },
|
||||
exceeded: { color: 'error', text: '已超限', icon: <WarningOutlined /> },
|
||||
};
|
||||
const item = config[status] || config.normal;
|
||||
return (
|
||||
<Tag color={item.color} icon={item.icon}>
|
||||
{item.text}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
const getProgressStatus = (status: string) => {
|
||||
const statusMap: Record<string, 'success' | 'exception' | 'normal'> = {
|
||||
normal: 'success',
|
||||
warning: 'normal',
|
||||
critical: 'exception',
|
||||
exceeded: 'exception',
|
||||
};
|
||||
return statusMap[status] || 'normal';
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title="配额使用情况">
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<div>
|
||||
<Row justify="space-between" style={{ marginBottom: 8 }}>
|
||||
<Col>
|
||||
<Space>
|
||||
<ProjectOutlined />
|
||||
<span>项目数量</span>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>{getStatusTag(quota.projects.status)}</Col>
|
||||
</Row>
|
||||
<div style={{ marginBottom: 4, color: '#666' }}>
|
||||
{quota.projects.used} / {quota.projects.limit} 个项目
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.round((quota.projects.used / quota.projects.limit) * 100)}
|
||||
status={getProgressStatus(quota.projects.status)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Row justify="space-between" style={{ marginBottom: 8 }}>
|
||||
<Col>
|
||||
<Space>
|
||||
<CloudOutlined />
|
||||
<span>存储空间</span>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>{getStatusTag(quota.storage.status)}</Col>
|
||||
</Row>
|
||||
<div style={{ marginBottom: 4, color: '#666' }}>
|
||||
{quota.storage.used_gb.toFixed(2)} / {quota.storage.limit_gb} GB
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.round((quota.storage.used_gb / quota.storage.limit_gb) * 100)}
|
||||
status={getProgressStatus(quota.storage.status)}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuotaDisplay;
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 顶部导航栏
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Layout, Button, Dropdown, Avatar, Space } from 'antd';
|
||||
import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
UserOutlined,
|
||||
LogoutOutlined,
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useUIStore } from '@/store/uiStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useLogout } from '@/hooks/useAuth';
|
||||
import type { MenuProps } from 'antd';
|
||||
|
||||
const { Header: AntHeader } = Layout;
|
||||
|
||||
const Header: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const sidebarCollapsed = useUIStore((state) => state.sidebarCollapsed);
|
||||
const toggleSidebar = useUIStore((state) => state.toggleSidebar);
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const logoutMutation = useLogout();
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'profile',
|
||||
icon: <UserOutlined />,
|
||||
label: '个人设置',
|
||||
onClick: () => navigate('/profile'),
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
icon: <SettingOutlined />,
|
||||
label: '账号设置',
|
||||
onClick: () => navigate('/profile/settings'),
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: '退出登录',
|
||||
onClick: () => logoutMutation.mutate(),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<AntHeader
|
||||
style={{
|
||||
padding: '0 24px',
|
||||
background: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={sidebarCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={toggleSidebar}
|
||||
style={{ fontSize: '16px', width: 64, height: 64 }}
|
||||
/>
|
||||
|
||||
<Dropdown menu={{ items: menuItems }} placement="bottomRight">
|
||||
<Space style={{ cursor: 'pointer' }}>
|
||||
<Avatar icon={<UserOutlined />} />
|
||||
<span>{user?.display_name || user?.username}</span>
|
||||
</Space>
|
||||
</Dropdown>
|
||||
</AntHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,10 @@
|
||||
.main-layout {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-layout-content {
|
||||
margin: 24px 16px;
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
min-height: 280px;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 主布局组件
|
||||
* 包含 Header、Sidebar、Content 区域
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Layout } from 'antd';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Header from './Header';
|
||||
import Sidebar from './Sidebar';
|
||||
import { useUIStore } from '@/store/uiStore';
|
||||
import './MainLayout.css';
|
||||
|
||||
const { Content } = Layout;
|
||||
|
||||
const MainLayout: React.FC = () => {
|
||||
const sidebarCollapsed = useUIStore((state) => state.sidebarCollapsed);
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sidebar collapsed={sidebarCollapsed} />
|
||||
<Layout>
|
||||
<Header />
|
||||
<Content style={{ margin: '24px 16px', padding: 24, background: '#fff' }}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainLayout;
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 侧边栏导航
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Layout, Menu } from 'antd';
|
||||
import {
|
||||
HomeOutlined,
|
||||
AppstoreOutlined,
|
||||
TeamOutlined,
|
||||
CrownOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import type { MenuProps } from 'antd';
|
||||
|
||||
const { Sider } = Layout;
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: '/workspaces',
|
||||
icon: <HomeOutlined />,
|
||||
label: '工作空间',
|
||||
onClick: () => navigate('/workspaces'),
|
||||
},
|
||||
{
|
||||
key: '/projects',
|
||||
icon: <AppstoreOutlined />,
|
||||
label: '项目',
|
||||
onClick: () => navigate('/projects'),
|
||||
},
|
||||
{
|
||||
key: '/members',
|
||||
icon: <TeamOutlined />,
|
||||
label: '成员管理',
|
||||
onClick: () => navigate('/members'),
|
||||
},
|
||||
{
|
||||
key: '/subscription',
|
||||
icon: <CrownOutlined />,
|
||||
label: '订阅管理',
|
||||
onClick: () => navigate('/subscription'),
|
||||
},
|
||||
{
|
||||
key: '/profile',
|
||||
icon: <UserOutlined />,
|
||||
label: '个人中心',
|
||||
onClick: () => navigate('/profile'),
|
||||
},
|
||||
];
|
||||
|
||||
// 根据当前路径选中菜单项
|
||||
const selectedKey = '/' + location.pathname.split('/')[1];
|
||||
|
||||
return (
|
||||
<Sider
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
trigger={null}
|
||||
width={200}
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
height: '100vh',
|
||||
position: 'sticky',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: 64,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{collapsed ? '🦐' : '小虾 SaaS'}
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey]}
|
||||
items={menuItems}
|
||||
/>
|
||||
</Sider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 认证相关 Hooks
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import * as authApi from '@/api/auth';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
// 登录 Hook
|
||||
export const useLogin = () => {
|
||||
const navigate = useNavigate();
|
||||
const setAuth = useAuthStore((state) => state.setAuth);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: authApi.login,
|
||||
onSuccess: (data) => {
|
||||
// 先保存 token
|
||||
localStorage.setItem('access_token', data.access_token);
|
||||
localStorage.setItem('refresh_token', data.refresh_token);
|
||||
|
||||
// 获取用户信息
|
||||
authApi.getCurrentUser().then((user) => {
|
||||
setAuth(user, data.access_token, data.refresh_token);
|
||||
navigate('/workspaces');
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 注册 Hook
|
||||
export const useRegister = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: authApi.register,
|
||||
onSuccess: () => {
|
||||
navigate('/login', {
|
||||
state: { message: '注册成功!请查收验证邮件。' },
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 登出 Hook
|
||||
export const useLogout = () => {
|
||||
const navigate = useNavigate();
|
||||
const clearAuth = useAuthStore((state) => state.clearAuth);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: authApi.logout,
|
||||
onSuccess: () => {
|
||||
clearAuth();
|
||||
queryClient.clear();
|
||||
navigate('/login');
|
||||
},
|
||||
onError: () => {
|
||||
// 即使登出失败也清除本地状态
|
||||
clearAuth();
|
||||
queryClient.clear();
|
||||
navigate('/login');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 获取当前用户 Hook
|
||||
export const useCurrentUser = () => {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
const setUser = useAuthStore((state) => state.setUser);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['currentUser'],
|
||||
queryFn: authApi.getCurrentUser,
|
||||
enabled: isAuthenticated,
|
||||
onSuccess: (user) => {
|
||||
setUser(user);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 工作空间相关 Hooks
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import * as workspaceApi from '@/api/workspace';
|
||||
import { useWorkspaceStore } from '@/store/workspaceStore';
|
||||
|
||||
// 获取工作空间列表
|
||||
export const useWorkspaces = () => {
|
||||
const setWorkspaces = useWorkspaceStore((state) => state.setWorkspaces);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['workspaces'],
|
||||
queryFn: workspaceApi.getWorkspaces,
|
||||
onSuccess: (data) => {
|
||||
setWorkspaces(data);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 获取工作空间详情
|
||||
export const useWorkspace = (id: string) => {
|
||||
return useQuery({
|
||||
queryKey: ['workspace', id],
|
||||
queryFn: () => workspaceApi.getWorkspace(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
// 创建工作空间
|
||||
export const useCreateWorkspace = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const addWorkspace = useWorkspaceStore((state) => state.addWorkspace);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: workspaceApi.createWorkspace,
|
||||
onSuccess: (data) => {
|
||||
addWorkspace(data);
|
||||
queryClient.invalidateQueries({ queryKey: ['workspaces'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 获取成员列表
|
||||
export const useWorkspaceMembers = (workspaceId: string) => {
|
||||
return useQuery({
|
||||
queryKey: ['workspaceMembers', workspaceId],
|
||||
queryFn: () => workspaceApi.getMembers(workspaceId),
|
||||
enabled: !!workspaceId,
|
||||
});
|
||||
};
|
||||
|
||||
// 邀请成员
|
||||
export const useInviteMember = (workspaceId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: workspaceApi.InviteMemberRequest) =>
|
||||
workspaceApi.inviteMember(workspaceId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['workspaceMembers', workspaceId] });
|
||||
},
|
||||
});
|
||||
};
|
||||
+39
-7
@@ -1,10 +1,42 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
/**
|
||||
* 更新主入口,引入全局样式
|
||||
*/
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import router from './router';
|
||||
import './index.css';
|
||||
import './styles/global.css';
|
||||
|
||||
// 创建 React Query 客户端
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 5 * 60 * 1000, // 5 分钟
|
||||
gcTime: 10 * 60 * 1000, // 10 分钟
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Ant Design 主题配置
|
||||
const theme = {
|
||||
token: {
|
||||
colorPrimary: '#1890ff',
|
||||
borderRadius: 4,
|
||||
},
|
||||
};
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConfigProvider locale={zhCN} theme={theme}>
|
||||
<RouterProvider router={router} />
|
||||
</ConfigProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Admin Dashboard 仪表盘
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Card, Row, Col, Statistic, Table } from 'antd';
|
||||
import {
|
||||
UserOutlined,
|
||||
CrownOutlined,
|
||||
DollarOutlined,
|
||||
RiseOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
// 模拟数据
|
||||
const stats = [
|
||||
{ title: '总用户数', value: 1234, icon: <UserOutlined />, color: '#1890ff' },
|
||||
{ title: '付费用户', value: 156, icon: <CrownOutlined />, color: '#52c41a' },
|
||||
{ title: '月收入', value: 15444, prefix: '¥', icon: <DollarOutlined />, color: '#faad14' },
|
||||
{ title: '活跃用户', value: 892, icon: <RiseOutlined />, color: '#722ed1' },
|
||||
];
|
||||
|
||||
const recentUsers = [
|
||||
{ id: '1', username: 'user1', email: 'user1@example.com', created_at: '2024-06-17' },
|
||||
{ id: '2', username: 'user2', email: 'user2@example.com', created_at: '2024-06-16' },
|
||||
{ id: '3', username: 'user3', email: 'user3@example.com', created_at: '2024-06-15' },
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{ title: '用户名', dataIndex: 'username', key: 'username' },
|
||||
{ title: '邮箱', dataIndex: 'email', key: 'email' },
|
||||
{ title: '注册时间', dataIndex: 'created_at', key: 'created_at' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<h1>Dashboard</h1>
|
||||
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
{stats.map((stat, index) => (
|
||||
<Col span={6} key={index}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={stat.title}
|
||||
value={stat.value}
|
||||
prefix={stat.prefix}
|
||||
valueStyle={{ color: stat.color }}
|
||||
suffix={stat.icon}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Card title="最近注册用户">
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={recentUsers}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
|
||||
export const Component = Dashboard;
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Admin 用户管理页面
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Table, Button, Space, Tag, Input, Card } from 'antd';
|
||||
import { SearchOutlined, LockOutlined, UnlockOutlined } from '@ant-design/icons';
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
const UserManagement: React.FC = () => {
|
||||
// 模拟数据
|
||||
const users = [
|
||||
{
|
||||
id: '1',
|
||||
username: 'user1',
|
||||
email: 'user1@example.com',
|
||||
is_email_verified: true,
|
||||
status: 'active',
|
||||
created_at: '2024-01-15',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
username: 'user2',
|
||||
email: 'user2@example.com',
|
||||
is_email_verified: false,
|
||||
status: 'active',
|
||||
created_at: '2024-02-20',
|
||||
},
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '用户名',
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
},
|
||||
{
|
||||
title: '邮箱',
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
},
|
||||
{
|
||||
title: '邮箱验证',
|
||||
dataIndex: 'is_email_verified',
|
||||
key: 'is_email_verified',
|
||||
render: (verified: boolean) => (
|
||||
<Tag color={verified ? 'success' : 'default'}>
|
||||
{verified ? '已验证' : '未验证'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => (
|
||||
<Tag color={status === 'active' ? 'success' : 'error'}>
|
||||
{status === 'active' ? '正常' : '已封禁'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '注册时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: any, record: any) => (
|
||||
<Space>
|
||||
<Button type="link" size="small">
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger={record.status === 'active'}
|
||||
icon={record.status === 'active' ? <LockOutlined /> : <UnlockOutlined />}
|
||||
>
|
||||
{record.status === 'active' ? '封禁' : '解封'}
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<h1>用户管理</h1>
|
||||
<Card>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Search
|
||||
placeholder="搜索用户名或邮箱"
|
||||
allowClear
|
||||
enterButton={<SearchOutlined />}
|
||||
style={{ width: 300 }}
|
||||
/>
|
||||
</div>
|
||||
<Table columns={columns} dataSource={users} rowKey="id" />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserManagement;
|
||||
|
||||
export const Component = UserManagement;
|
||||
@@ -0,0 +1,18 @@
|
||||
.forgot-password-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.forgot-password-card {
|
||||
width: 400px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.forgot-password-card .ant-card-head-title {
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 忘记密码页面
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { Form, Input, Button, Card, message, Result } from 'antd';
|
||||
import { MailOutlined } from '@ant-design/icons';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { requestPasswordReset } from '@/api/auth';
|
||||
import './ForgotPassword.css';
|
||||
|
||||
const ForgotPassword: React.FC = () => {
|
||||
const [form] = Form.useForm();
|
||||
const [emailSent, setEmailSent] = useState(false);
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: (email: string) => requestPasswordReset(email),
|
||||
onSuccess: () => {
|
||||
setEmailSent(true);
|
||||
message.success('重置邮件已发送!');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
message.error(error.response?.data?.message || '发送失败,请重试');
|
||||
},
|
||||
});
|
||||
|
||||
const onFinish = (values: { email: string }) => {
|
||||
resetMutation.mutate(values.email);
|
||||
};
|
||||
|
||||
if (emailSent) {
|
||||
return (
|
||||
<div className="forgot-password-container">
|
||||
<Card className="forgot-password-card">
|
||||
<Result
|
||||
status="success"
|
||||
title="重置邮件已发送"
|
||||
subTitle="请检查您的邮箱,点击邮件中的链接重置密码。"
|
||||
extra={[
|
||||
<Link to="/login" key="login">
|
||||
<Button type="primary">返回登录</Button>
|
||||
</Link>,
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="forgot-password-container">
|
||||
<Card className="forgot-password-card" title="重置密码">
|
||||
<p style={{ marginBottom: '24px', color: '#666' }}>
|
||||
请输入您的邮箱地址,我们将发送重置密码的链接到您的邮箱。
|
||||
</p>
|
||||
|
||||
<Form form={form} name="forgot-password" onFinish={onFinish} size="large">
|
||||
<Form.Item
|
||||
name="email"
|
||||
rules={[
|
||||
{ required: true, message: '请输入邮箱' },
|
||||
{ type: 'email', message: '请输入有效的邮箱地址' },
|
||||
]}
|
||||
>
|
||||
<Input prefix={<MailOutlined />} placeholder="邮箱" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={resetMutation.isPending}
|
||||
block
|
||||
>
|
||||
发送重置邮件
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Link to="/login">返回登录</Link>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForgotPassword;
|
||||
@@ -0,0 +1,18 @@
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 400px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.login-card .ant-card-head-title {
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 登录页面
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Form, Input, Button, Card, message, Checkbox } from 'antd';
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useLogin } from '@/hooks/useAuth';
|
||||
import './Login.css';
|
||||
|
||||
interface LoginFormValues {
|
||||
email: string;
|
||||
password: string;
|
||||
remember: boolean;
|
||||
}
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const loginMutation = useLogin();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const onFinish = async (values: LoginFormValues) => {
|
||||
try {
|
||||
await loginMutation.mutateAsync({
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
});
|
||||
message.success('登录成功!');
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || '登录失败,请检查邮箱和密码');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<Card className="login-card" title="登录小虾 SaaS">
|
||||
<Form
|
||||
form={form}
|
||||
name="login"
|
||||
onFinish={onFinish}
|
||||
autoComplete="off"
|
||||
size="large"
|
||||
>
|
||||
<Form.Item
|
||||
name="email"
|
||||
rules={[
|
||||
{ required: true, message: '请输入邮箱' },
|
||||
{ type: 'email', message: '请输入有效的邮箱地址' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="邮箱"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="密码"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Form.Item name="remember" valuePropName="checked" noStyle>
|
||||
<Checkbox>记住我</Checkbox>
|
||||
</Form.Item>
|
||||
<Link to="/forgot-password" style={{ float: 'right' }}>
|
||||
忘记密码?
|
||||
</Link>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loginMutation.isPending}
|
||||
block
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
还没有账号? <Link to="/register">立即注册</Link>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
@@ -0,0 +1,18 @@
|
||||
.register-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.register-card {
|
||||
width: 400px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.register-card .ant-card-head-title {
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 注册页面
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Form, Input, Button, Card, message } from 'antd';
|
||||
import { UserOutlined, LockOutlined, MailOutlined } from '@ant-design/icons';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useRegister } from '@/hooks/useAuth';
|
||||
import './Register.css';
|
||||
|
||||
interface RegisterFormValues {
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
const Register: React.FC = () => {
|
||||
const registerMutation = useRegister();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const onFinish = async (values: RegisterFormValues) => {
|
||||
try {
|
||||
await registerMutation.mutateAsync({
|
||||
email: values.email,
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
});
|
||||
message.success('注册成功!请查收验证邮件。');
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || '注册失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="register-container">
|
||||
<Card className="register-card" title="注册小虾 SaaS">
|
||||
<Form
|
||||
form={form}
|
||||
name="register"
|
||||
onFinish={onFinish}
|
||||
autoComplete="off"
|
||||
size="large"
|
||||
>
|
||||
<Form.Item
|
||||
name="email"
|
||||
rules={[
|
||||
{ required: true, message: '请输入邮箱' },
|
||||
{ type: 'email', message: '请输入有效的邮箱地址' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={<MailOutlined />}
|
||||
placeholder="邮箱"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[
|
||||
{ required: true, message: '请输入用户名' },
|
||||
{ min: 3, message: '用户名至少 3 个字符' },
|
||||
{ max: 20, message: '用户名最多 20 个字符' },
|
||||
{
|
||||
pattern: /^[a-zA-Z0-9_]+$/,
|
||||
message: '用户名只能包含字母、数字和下划线',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="用户名"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[
|
||||
{ required: true, message: '请输入密码' },
|
||||
{ min: 8, message: '密码至少 8 个字符' },
|
||||
{
|
||||
pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
|
||||
message: '密码必须包含大小写字母和数字',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="密码"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="confirmPassword"
|
||||
dependencies={['password']}
|
||||
rules={[
|
||||
{ required: true, message: '请确认密码' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('password') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('两次输入的密码不一致'));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="确认密码"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={registerMutation.isPending}
|
||||
block
|
||||
>
|
||||
注册
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
已有账号? <Link to="/login">立即登录</Link>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Register;
|
||||
@@ -0,0 +1,18 @@
|
||||
.reset-password-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.reset-password-card {
|
||||
width: 400px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.reset-password-card .ant-card-head-title {
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 重置密码页面
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Form, Input, Button, Card, message, Result } from 'antd';
|
||||
import { LockOutlined } from '@ant-design/icons';
|
||||
import { Link, useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { resetPassword } from '@/api/auth';
|
||||
import './ResetPassword.css';
|
||||
|
||||
const ResetPassword: React.FC = () => {
|
||||
const [form] = Form.useForm();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const token = searchParams.get('token');
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: (password: string) => resetPassword(token!, password),
|
||||
onSuccess: () => {
|
||||
message.success('密码重置成功!');
|
||||
setTimeout(() => navigate('/login'), 2000);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
message.error(error.response?.data?.message || '重置失败,请重试');
|
||||
},
|
||||
});
|
||||
|
||||
const onFinish = (values: { password: string }) => {
|
||||
resetMutation.mutate(values.password);
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="reset-password-container">
|
||||
<Card className="reset-password-card">
|
||||
<Result
|
||||
status="error"
|
||||
title="无效的重置链接"
|
||||
subTitle="该链接无效或已过期,请重新申请密码重置。"
|
||||
extra={[
|
||||
<Link to="/forgot-password" key="forgot">
|
||||
<Button type="primary">重新申请</Button>
|
||||
</Link>,
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (resetMutation.isSuccess) {
|
||||
return (
|
||||
<div className="reset-password-container">
|
||||
<Card className="reset-password-card">
|
||||
<Result
|
||||
status="success"
|
||||
title="密码重置成功"
|
||||
subTitle="您的密码已成功重置,即将跳转到登录页面..."
|
||||
extra={[
|
||||
<Link to="/login" key="login">
|
||||
<Button type="primary">立即登录</Button>
|
||||
</Link>,
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="reset-password-container">
|
||||
<Card className="reset-password-card" title="设置新密码">
|
||||
<Form form={form} name="reset-password" onFinish={onFinish} size="large">
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{ min: 8, message: '密码至少 8 个字符' },
|
||||
{
|
||||
pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
|
||||
message: '密码必须包含大小写字母和数字',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="新密码" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="confirmPassword"
|
||||
dependencies={['password']}
|
||||
rules={[
|
||||
{ required: true, message: '请确认新密码' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('password') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('两次输入的密码不一致'));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="确认新密码" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={resetMutation.isPending}
|
||||
block
|
||||
>
|
||||
重置密码
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Link to="/login">返回登录</Link>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetPassword;
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 账号安全设置页面
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Card, Form, Input, Button, message, Divider } from 'antd';
|
||||
import { LockOutlined, MailOutlined } from '@ant-design/icons';
|
||||
|
||||
const AccountSecurity: React.FC = () => {
|
||||
const [passwordForm] = Form.useForm();
|
||||
const [emailForm] = Form.useForm();
|
||||
|
||||
const onPasswordSubmit = (values: any) => {
|
||||
console.log('修改密码:', values);
|
||||
message.success('密码修改成功');
|
||||
passwordForm.resetFields();
|
||||
};
|
||||
|
||||
const onEmailSubmit = (values: any) => {
|
||||
console.log('修改邮箱:', values);
|
||||
message.success('验证邮件已发送到新邮箱');
|
||||
emailForm.resetFields();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<h1>账号安全</h1>
|
||||
|
||||
<Card title="修改密码" style={{ marginBottom: 24 }}>
|
||||
<Form form={passwordForm} layout="vertical" onFinish={onPasswordSubmit}>
|
||||
<Form.Item
|
||||
name="currentPassword"
|
||||
label="当前密码"
|
||||
rules={[{ required: true, message: '请输入当前密码' }]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="newPassword"
|
||||
label="新密码"
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{ min: 8, message: '密码至少 8 个字符' },
|
||||
]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="confirmPassword"
|
||||
label="确认新密码"
|
||||
dependencies={['newPassword']}
|
||||
rules={[
|
||||
{ required: true, message: '请确认新密码' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('newPassword') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('两次输入的密码不一致'));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
修改密码
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card title="修改邮箱">
|
||||
<Form form={emailForm} layout="vertical" onFinish={onEmailSubmit}>
|
||||
<Form.Item
|
||||
name="newEmail"
|
||||
label="新邮箱地址"
|
||||
rules={[
|
||||
{ required: true, message: '请输入新邮箱' },
|
||||
{ type: 'email', message: '请输入有效的邮箱地址' },
|
||||
]}
|
||||
>
|
||||
<Input prefix={<MailOutlined />} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="确认密码"
|
||||
rules={[{ required: true, message: '请输入密码以确认修改' }]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
修改邮箱
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountSecurity;
|
||||
|
||||
export const Component = AccountSecurity;
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 通知设置页面
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Card, Form, Switch, Button, message, Divider } from 'antd';
|
||||
|
||||
const NotificationSettings: React.FC = () => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const onFinish = (values: any) => {
|
||||
console.log('保存通知设置:', values);
|
||||
message.success('通知设置已保存');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<h1>通知设置</h1>
|
||||
<Card>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={onFinish}
|
||||
initialValues={{
|
||||
emailNotifications: true,
|
||||
inviteNotifications: true,
|
||||
projectNotifications: false,
|
||||
marketingEmails: false,
|
||||
}}
|
||||
>
|
||||
<h3>邮件通知</h3>
|
||||
<Form.Item
|
||||
name="emailNotifications"
|
||||
label="启用邮件通知"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Divider />
|
||||
|
||||
<h3>通知类型</h3>
|
||||
<Form.Item
|
||||
name="inviteNotifications"
|
||||
label="工作空间邀请通知"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="projectNotifications"
|
||||
label="项目更新通知"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="subscriptionNotifications"
|
||||
label="订阅和账单通知"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Divider />
|
||||
|
||||
<h3>营销</h3>
|
||||
<Form.Item
|
||||
name="marketingEmails"
|
||||
label="接收产品更新和优惠信息"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
保存设置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationSettings;
|
||||
|
||||
export const Component = NotificationSettings;
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Session 管理页面
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Card, Table, Button, Tag, Space, Popconfirm, message } from 'antd';
|
||||
import { LaptopOutlined, MobileOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
device: string;
|
||||
deviceType: 'desktop' | 'mobile';
|
||||
location: string;
|
||||
lastActive: string;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
const SessionManagement: React.FC = () => {
|
||||
// 模拟数据
|
||||
const sessions: Session[] = [
|
||||
{
|
||||
id: '1',
|
||||
device: 'Chrome on Windows',
|
||||
deviceType: 'desktop',
|
||||
location: '北京, 中国',
|
||||
lastActive: '当前会话',
|
||||
isCurrent: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
device: 'Safari on iPhone',
|
||||
deviceType: 'mobile',
|
||||
location: '上海, 中国',
|
||||
lastActive: '2 小时前',
|
||||
isCurrent: false,
|
||||
},
|
||||
];
|
||||
|
||||
const handleLogout = (sessionId: string) => {
|
||||
console.log('登出会话:', sessionId);
|
||||
message.success('会话已结束');
|
||||
};
|
||||
|
||||
const handleLogoutAll = () => {
|
||||
console.log('登出所有其他设备');
|
||||
message.success('所有其他设备已登出');
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '设备',
|
||||
dataIndex: 'device',
|
||||
key: 'device',
|
||||
render: (device: string, record: Session) => (
|
||||
<Space>
|
||||
{record.deviceType === 'desktop' ? <LaptopOutlined /> : <MobileOutlined />}
|
||||
<span>{device}</span>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '位置',
|
||||
dataIndex: 'location',
|
||||
key: 'location',
|
||||
},
|
||||
{
|
||||
title: '最后活动',
|
||||
dataIndex: 'lastActive',
|
||||
key: 'lastActive',
|
||||
render: (text: string, record: Session) => (
|
||||
<>
|
||||
{text}
|
||||
{record.isCurrent && <Tag color="success" style={{ marginLeft: 8 }}>当前</Tag>}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: any, record: Session) => {
|
||||
if (record.isCurrent) {
|
||||
return <span style={{ color: '#999' }}>-</span>;
|
||||
}
|
||||
return (
|
||||
<Popconfirm
|
||||
title="确定要结束此会话吗?"
|
||||
onConfirm={() => handleLogout(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="link" danger icon={<DeleteOutlined />}>
|
||||
结束会话
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<h1>Session 管理</h1>
|
||||
<Card>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Popconfirm
|
||||
title="确定要登出所有其他设备吗?"
|
||||
onConfirm={handleLogoutAll}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button danger>登出所有其他设备</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
<Table columns={columns} dataSource={sessions} rowKey="id" pagination={false} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SessionManagement;
|
||||
|
||||
export const Component = SessionManagement;
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 个人设置页面
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Card, Form, Input, Button, message } from 'antd';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
const Settings: React.FC = () => {
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const onFinish = (values: any) => {
|
||||
console.log('保存设置:', values);
|
||||
message.success('设置已保存');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<Card title="个人信息">
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={onFinish}
|
||||
initialValues={{
|
||||
username: user?.username,
|
||||
email: user?.email,
|
||||
display_name: user?.display_name,
|
||||
}}
|
||||
>
|
||||
<Form.Item label="用户名" name="username">
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="邮箱" name="email">
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="显示名称" name="display_name">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
保存
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
|
||||
export const Component = Settings;
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 账单管理页面
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Card, Table, Button, Tag, Space } from 'antd';
|
||||
import { DownloadOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
|
||||
interface Invoice {
|
||||
id: string;
|
||||
date: string;
|
||||
amount: number;
|
||||
status: 'paid' | 'pending' | 'overdue';
|
||||
plan: string;
|
||||
}
|
||||
|
||||
const Billing: React.FC = () => {
|
||||
// 模拟数据
|
||||
const invoices: Invoice[] = [
|
||||
{
|
||||
id: 'INV-2024-001',
|
||||
date: '2024-06-01',
|
||||
amount: 99,
|
||||
status: 'paid',
|
||||
plan: 'Pro',
|
||||
},
|
||||
{
|
||||
id: 'INV-2024-002',
|
||||
date: '2024-05-01',
|
||||
amount: 99,
|
||||
status: 'paid',
|
||||
plan: 'Pro',
|
||||
},
|
||||
];
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const config: Record<string, { color: string; text: string }> = {
|
||||
paid: { color: 'success', text: '已支付' },
|
||||
pending: { color: 'processing', text: '待支付' },
|
||||
overdue: { color: 'error', text: '逾期' },
|
||||
};
|
||||
const item = config[status] || config.pending;
|
||||
return <Tag color={item.color}>{item.text}</Tag>;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '账单编号',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
},
|
||||
{
|
||||
title: '日期',
|
||||
dataIndex: 'date',
|
||||
key: 'date',
|
||||
},
|
||||
{
|
||||
title: '套餐',
|
||||
dataIndex: 'plan',
|
||||
key: 'plan',
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount',
|
||||
key: 'amount',
|
||||
render: (amount: number) => `¥${amount}`,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => getStatusTag(status),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: any, record: Invoice) => (
|
||||
<Space>
|
||||
<Button type="link" icon={<DownloadOutlined />}>
|
||||
下载
|
||||
</Button>
|
||||
<Button type="link" icon={<FileTextOutlined />}>
|
||||
发票
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<h1>账单管理</h1>
|
||||
<Card>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={invoices}
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 10 }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Billing;
|
||||
|
||||
export const Component = Billing;
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 订阅计划页面
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Card, Row, Col, Button, List } from 'antd';
|
||||
import { CheckOutlined } from '@ant-design/icons';
|
||||
|
||||
const Plans: React.FC = () => {
|
||||
const plans = [
|
||||
{
|
||||
name: 'Free',
|
||||
price: '¥0',
|
||||
period: '/月',
|
||||
features: ['3 个项目', '10GB 存储', '3 个成员', '邮件支持'],
|
||||
},
|
||||
{
|
||||
name: 'Pro',
|
||||
price: '¥99',
|
||||
period: '/月',
|
||||
features: ['无限项目', '100GB 存储', '20 个成员', '优先支持'],
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
name: 'Enterprise',
|
||||
price: '¥999',
|
||||
period: '/月',
|
||||
features: ['无限项目', '1TB 存储', '无限成员', '专属支持', '定制开发'],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<h1 style={{ textAlign: 'center', marginBottom: '48px' }}>选择适合你的套餐</h1>
|
||||
<Row gutter={24} justify="center">
|
||||
{plans.map((plan) => (
|
||||
<Col key={plan.name} xs={24} sm={12} md={8}>
|
||||
<Card
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
border: plan.recommended ? '2px solid #1890ff' : undefined,
|
||||
}}
|
||||
>
|
||||
{plan.recommended && (
|
||||
<div style={{ color: '#1890ff', marginBottom: '16px' }}>推荐</div>
|
||||
)}
|
||||
<h2>{plan.name}</h2>
|
||||
<div style={{ fontSize: '32px', fontWeight: 'bold', margin: '24px 0' }}>
|
||||
{plan.price}
|
||||
<span style={{ fontSize: '16px', fontWeight: 'normal' }}>
|
||||
{plan.period}
|
||||
</span>
|
||||
</div>
|
||||
<List
|
||||
dataSource={plan.features}
|
||||
renderItem={(item) => (
|
||||
<List.Item style={{ border: 'none', padding: '8px 0' }}>
|
||||
<CheckOutlined style={{ color: '#52c41a', marginRight: '8px' }} />
|
||||
{item}
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type={plan.recommended ? 'primary' : 'default'}
|
||||
size="large"
|
||||
block
|
||||
style={{ marginTop: '24px' }}
|
||||
>
|
||||
{plan.name === 'Free' ? '当前计划' : '升级'}
|
||||
</Button>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Plans;
|
||||
|
||||
export const Component = Plans;
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 订阅升级流程页面
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Card, Steps, Button, Result, Descriptions, message } from 'antd';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { upgradeSubscription } from '@/api/subscription';
|
||||
|
||||
const UpgradeSubscription: React.FC = () => {
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [selectedPlan, setSelectedPlan] = useState<'pro' | 'enterprise'>('pro');
|
||||
|
||||
const upgradeMutation = useMutation({
|
||||
mutationFn: () => upgradeSubscription(workspaceId!, selectedPlan),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['workspace', workspaceId] });
|
||||
setCurrent(2);
|
||||
message.success('订阅升级成功!');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
message.error(error.response?.data?.message || '升级失败');
|
||||
},
|
||||
});
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: '选择套餐',
|
||||
content: (
|
||||
<div>
|
||||
<Card
|
||||
style={{ marginBottom: 16, cursor: 'pointer' }}
|
||||
onClick={() => setSelectedPlan('pro')}
|
||||
className={selectedPlan === 'pro' ? 'selected-plan' : ''}
|
||||
>
|
||||
<h3>Pro 套餐</h3>
|
||||
<p>¥99/月</p>
|
||||
<ul>
|
||||
<li>无限项目</li>
|
||||
<li>100GB 存储</li>
|
||||
<li>20 个成员</li>
|
||||
</ul>
|
||||
</Card>
|
||||
<Card
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setSelectedPlan('enterprise')}
|
||||
className={selectedPlan === 'enterprise' ? 'selected-plan' : ''}
|
||||
>
|
||||
<h3>Enterprise 套餐</h3>
|
||||
<p>¥999/月</p>
|
||||
<ul>
|
||||
<li>无限项目</li>
|
||||
<li>1TB 存储</li>
|
||||
<li>无限成员</li>
|
||||
<li>专属支持</li>
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '确认订单',
|
||||
content: (
|
||||
<Card>
|
||||
<Descriptions column={1}>
|
||||
<Descriptions.Item label="套餐">
|
||||
{selectedPlan === 'pro' ? 'Pro' : 'Enterprise'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="价格">
|
||||
{selectedPlan === 'pro' ? '¥99' : '¥999'} / 月
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="支付方式">
|
||||
在线支付(待接入)
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '完成',
|
||||
content: (
|
||||
<Result
|
||||
status="success"
|
||||
title="升级成功!"
|
||||
subTitle="您的订阅已升级,新配额已生效。"
|
||||
extra={[
|
||||
<Button
|
||||
type="primary"
|
||||
key="workspace"
|
||||
onClick={() => navigate(`/workspaces/${workspaceId}`)}
|
||||
>
|
||||
返回工作空间
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const next = () => {
|
||||
if (current === 0) {
|
||||
setCurrent(current + 1);
|
||||
} else if (current === 1) {
|
||||
upgradeMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
const prev = () => {
|
||||
setCurrent(current - 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<h1>升级订阅</h1>
|
||||
<Steps current={current} items={steps} style={{ marginBottom: 24 }} />
|
||||
<div>{steps[current].content}</div>
|
||||
<div style={{ marginTop: 24 }}>
|
||||
{current < steps.length - 1 && (
|
||||
<>
|
||||
{current > 0 && (
|
||||
<Button style={{ marginRight: 8 }} onClick={prev}>
|
||||
上一步
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={next}
|
||||
loading={upgradeMutation.isPending}
|
||||
>
|
||||
{current === 1 ? '确认升级' : '下一步'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpgradeSubscription;
|
||||
|
||||
export const Component = UpgradeSubscription;
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 工作空间详情页面
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Tabs, Button, Descriptions, Progress, Card, Row, Col } from 'antd';
|
||||
import { PlusOutlined, TeamOutlined, ProjectOutlined } from '@ant-design/icons';
|
||||
import { useWorkspace } from '@/hooks/useWorkspace';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getQuotaStatus } from '@/api/subscription';
|
||||
import MemberList from '@/components/business/MemberList';
|
||||
import InviteMemberModal from '@/components/business/InviteMemberModal';
|
||||
import PermissionMatrix from '@/components/business/PermissionMatrix';
|
||||
|
||||
const WorkspaceDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data: workspace, isLoading } = useWorkspace(id!);
|
||||
const { data: quota } = useQuery({
|
||||
queryKey: ['quota', id],
|
||||
queryFn: () => getQuotaStatus(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
const [inviteModalOpen, setInviteModalOpen] = useState(false);
|
||||
|
||||
if (isLoading) return <div>加载中...</div>;
|
||||
if (!workspace) return <div>工作空间不存在</div>;
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
normal: '#52c41a',
|
||||
warning: '#faad14',
|
||||
critical: '#ff4d4f',
|
||||
exceeded: '#ff4d4f',
|
||||
};
|
||||
return colors[status] || colors.normal;
|
||||
};
|
||||
|
||||
const items = [
|
||||
{
|
||||
key: 'overview',
|
||||
label: '概览',
|
||||
children: (
|
||||
<div>
|
||||
<Card title="工作空间信息" style={{ marginBottom: 16 }}>
|
||||
<Descriptions column={2}>
|
||||
<Descriptions.Item label="名称">{workspace.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="订阅计划">
|
||||
{workspace.subscription_plan}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
{workspace.subscription_status}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">
|
||||
{new Date(workspace.created_at).toLocaleDateString()}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
{quota && (
|
||||
<Card title="配额使用情况">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<ProjectOutlined /> 项目数量:{quota.projects.used} /{' '}
|
||||
{quota.projects.limit}
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.round(
|
||||
(quota.projects.used / quota.projects.limit) * 100
|
||||
)}
|
||||
strokeColor={getStatusColor(quota.projects.status)}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
存储空间:{quota.storage.used_gb.toFixed(2)} GB /{' '}
|
||||
{quota.storage.limit_gb} GB
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.round(
|
||||
(quota.storage.used_gb / quota.storage.limit_gb) * 100
|
||||
)}
|
||||
strokeColor={getStatusColor(quota.storage.status)}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'members',
|
||||
label: (
|
||||
<>
|
||||
<TeamOutlined /> 成员管理
|
||||
</>
|
||||
),
|
||||
children: (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setInviteModalOpen(true)}
|
||||
>
|
||||
邀请成员
|
||||
</Button>
|
||||
</div>
|
||||
<MemberList workspaceId={id!} />
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<PermissionMatrix />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
label: '设置',
|
||||
children: <div>工作空间设置(待开发)</div>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{workspace.name}</h1>
|
||||
<Tabs items={items} />
|
||||
<InviteMemberModal
|
||||
workspaceId={id!}
|
||||
open={inviteModalOpen}
|
||||
onClose={() => setInviteModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceDetail;
|
||||
|
||||
export const Component = WorkspaceDetail;
|
||||
@@ -0,0 +1,37 @@
|
||||
.workspace-list {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.workspace-list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.workspace-card {
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.workspace-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.workspace-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 0;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 16px;
|
||||
color: #999;
|
||||
margin: 24px 0;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 工作空间列表页面
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Card, Button, Row, Col, Tag, Space } from 'antd';
|
||||
import { PlusOutlined, TeamOutlined, CrownOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useWorkspaces } from '@/hooks/useWorkspace';
|
||||
import './WorkspaceList.css';
|
||||
|
||||
const WorkspaceList: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { data: workspaces, isLoading } = useWorkspaces();
|
||||
|
||||
const getPlanTag = (plan: string) => {
|
||||
const planConfig = {
|
||||
free: { color: 'default', text: 'Free' },
|
||||
pro: { color: 'blue', text: 'Pro' },
|
||||
enterprise: { color: 'gold', text: 'Enterprise' },
|
||||
};
|
||||
const config = planConfig[plan as keyof typeof planConfig] || planConfig.free;
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="workspace-list">
|
||||
<div className="workspace-list-header">
|
||||
<h1>我的工作空间</h1>
|
||||
<Button type="primary" icon={<PlusOutlined />} size="large">
|
||||
创建工作空间
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
{workspaces?.map((workspace) => (
|
||||
<Col key={workspace.id} xs={24} sm={12} md={8} lg={6}>
|
||||
<Card
|
||||
hoverable
|
||||
onClick={() => navigate(`/workspaces/${workspace.id}`)}
|
||||
className="workspace-card"
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<div className="workspace-name">{workspace.name}</div>
|
||||
<div>{getPlanTag(workspace.subscription_plan)}</div>
|
||||
<div style={{ color: '#666', fontSize: '14px' }}>
|
||||
<TeamOutlined /> 成员数量
|
||||
</div>
|
||||
<div style={{ color: '#999', fontSize: '12px' }}>
|
||||
创建于 {new Date(workspace.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{!isLoading && workspaces?.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<CrownOutlined style={{ fontSize: '64px', color: '#ccc' }} />
|
||||
<p>还没有工作空间</p>
|
||||
<Button type="primary" icon={<PlusOutlined />}>
|
||||
创建第一个工作空间
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceList;
|
||||
|
||||
export const Component = WorkspaceList;
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 更新路由,添加 Admin 和 Billing 页面
|
||||
*/
|
||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
||||
import MainLayout from '@/components/layout/MainLayout';
|
||||
import Login from '@/pages/auth/Login';
|
||||
import Register from '@/pages/auth/Register';
|
||||
import ForgotPassword from '@/pages/auth/ForgotPassword';
|
||||
import ResetPassword from '@/pages/auth/ResetPassword';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
// 受保护的路由组件
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
// 路由配置
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/login',
|
||||
element: <Login />,
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
element: <Register />,
|
||||
},
|
||||
{
|
||||
path: '/forgot-password',
|
||||
element: <ForgotPassword />,
|
||||
},
|
||||
{
|
||||
path: '/reset-password',
|
||||
element: <ResetPassword />,
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<MainLayout />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <Navigate to="/workspaces" replace />,
|
||||
},
|
||||
{
|
||||
path: 'workspaces',
|
||||
lazy: () => import('@/pages/workspace/WorkspaceList'),
|
||||
},
|
||||
{
|
||||
path: 'workspaces/:id',
|
||||
lazy: () => import('@/pages/workspace/WorkspaceDetail'),
|
||||
},
|
||||
{
|
||||
path: 'subscription',
|
||||
lazy: () => import('@/pages/subscription/Plans'),
|
||||
},
|
||||
{
|
||||
path: 'subscription/upgrade/:workspaceId',
|
||||
lazy: () => import('@/pages/subscription/UpgradeSubscription'),
|
||||
},
|
||||
{
|
||||
path: 'subscription/billing',
|
||||
lazy: () => import('@/pages/subscription/Billing'),
|
||||
},
|
||||
{
|
||||
path: 'admin',
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
lazy: () => import('@/pages/admin/Dashboard'),
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
lazy: () => import('@/pages/admin/UserManagement'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
lazy: () => import('@/pages/profile/Settings'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
element: <Navigate to="/" replace />,
|
||||
},
|
||||
]);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 认证状态管理
|
||||
* 使用 Zustand 管理全局认证状态
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
is_email_verified: boolean;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
isAuthenticated: boolean;
|
||||
|
||||
// Actions
|
||||
setAuth: (user: User, accessToken: string, refreshToken: string) => void;
|
||||
clearAuth: () => void;
|
||||
setUser: (user: User) => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
|
||||
setAuth: (user, accessToken, refreshToken) => {
|
||||
localStorage.setItem('access_token', accessToken);
|
||||
localStorage.setItem('refresh_token', refreshToken);
|
||||
set({
|
||||
user,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
},
|
||||
|
||||
clearAuth: () => {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
set({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
},
|
||||
|
||||
setUser: (user) => {
|
||||
set({ user });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
partialize: (state) => ({
|
||||
user: state.user,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* UI 状态管理
|
||||
* 管理全局 UI 状态(侧边栏、加载状态等)
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface UIState {
|
||||
sidebarCollapsed: boolean;
|
||||
loading: boolean;
|
||||
|
||||
// Actions
|
||||
toggleSidebar: () => void;
|
||||
setSidebarCollapsed: (collapsed: boolean) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>((set) => ({
|
||||
sidebarCollapsed: false,
|
||||
loading: false,
|
||||
|
||||
toggleSidebar: () => {
|
||||
set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed }));
|
||||
},
|
||||
|
||||
setSidebarCollapsed: (collapsed) => {
|
||||
set({ sidebarCollapsed: collapsed });
|
||||
},
|
||||
|
||||
setLoading: (loading) => {
|
||||
set({ loading });
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 工作空间状态管理
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import { Workspace } from '@/api/workspace';
|
||||
|
||||
interface WorkspaceState {
|
||||
currentWorkspace: Workspace | null;
|
||||
workspaces: Workspace[];
|
||||
|
||||
// Actions
|
||||
setCurrentWorkspace: (workspace: Workspace | null) => void;
|
||||
setWorkspaces: (workspaces: Workspace[]) => void;
|
||||
addWorkspace: (workspace: Workspace) => void;
|
||||
removeWorkspace: (workspaceId: string) => void;
|
||||
}
|
||||
|
||||
export const useWorkspaceStore = create<WorkspaceState>((set) => ({
|
||||
currentWorkspace: null,
|
||||
workspaces: [],
|
||||
|
||||
setCurrentWorkspace: (workspace) => {
|
||||
set({ currentWorkspace: workspace });
|
||||
},
|
||||
|
||||
setWorkspaces: (workspaces) => {
|
||||
set({ workspaces });
|
||||
},
|
||||
|
||||
addWorkspace: (workspace) => {
|
||||
set((state) => ({
|
||||
workspaces: [...state.workspaces, workspace],
|
||||
}));
|
||||
},
|
||||
|
||||
removeWorkspace: (workspaceId) => {
|
||||
set((state) => ({
|
||||
workspaces: state.workspaces.filter((w) => w.id !== workspaceId),
|
||||
currentWorkspace:
|
||||
state.currentWorkspace?.id === workspaceId ? null : state.currentWorkspace,
|
||||
}));
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 全局样式变量
|
||||
*/
|
||||
:root {
|
||||
/* 主色调 */
|
||||
--primary-color: #1890ff;
|
||||
--primary-hover: #40a9ff;
|
||||
--primary-active: #096dd9;
|
||||
|
||||
/* 状态色 */
|
||||
--success-color: #52c41a;
|
||||
--warning-color: #faad14;
|
||||
--error-color: #ff4d4f;
|
||||
--info-color: #1890ff;
|
||||
|
||||
/* 文字颜色 */
|
||||
--text-primary: #262626;
|
||||
--text-secondary: #595959;
|
||||
--text-disabled: #bfbfbf;
|
||||
|
||||
/* 背景色 */
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #fafafa;
|
||||
--bg-tertiary: #f5f5f5;
|
||||
|
||||
/* 边框和分割线 */
|
||||
--border-color: #d9d9d9;
|
||||
--divider-color: #f0f0f0;
|
||||
|
||||
/* 间距 */
|
||||
--space-xs: 4px;
|
||||
--space-sm: 8px;
|
||||
--space-md: 16px;
|
||||
--space-lg: 24px;
|
||||
--space-xl: 32px;
|
||||
|
||||
/* 圆角 */
|
||||
--radius-sm: 2px;
|
||||
--radius-md: 4px;
|
||||
--radius-lg: 8px;
|
||||
|
||||
/* 阴影 */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.12);
|
||||
--shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
/* 全局样式重置 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
||||
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* 通用工具类 */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mt-sm {
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.mt-md {
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
|
||||
.mt-lg {
|
||||
margin-top: var(--space-lg);
|
||||
}
|
||||
|
||||
.mb-sm {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.mb-md {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.mb-lg {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
/* 卡片hover效果 */
|
||||
.card-hover {
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* 选中的套餐卡片 */
|
||||
.selected-plan {
|
||||
border: 2px solid var(--primary-color) !important;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Login 组件单元测试
|
||||
*/
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import Login from '@/pages/auth/Login';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
const renderLogin = () => {
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<Login />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Login Component', () => {
|
||||
it('should render login form', () => {
|
||||
renderLogin();
|
||||
|
||||
expect(screen.getByPlaceholderText('邮箱')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('密码')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '登录' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show validation errors for empty fields', async () => {
|
||||
renderLogin();
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: '登录' });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('请输入邮箱')).toBeInTheDocument();
|
||||
expect(screen.getByText('请输入密码')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should navigate to register page', () => {
|
||||
renderLogin();
|
||||
|
||||
const registerLink = screen.getByText('立即注册');
|
||||
expect(registerLink).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* WorkspaceList 组件单元测试
|
||||
*/
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import WorkspaceList from '@/pages/workspace/WorkspaceList';
|
||||
import * as workspaceApi from '@/api/workspace';
|
||||
|
||||
vi.mock('@/api/workspace');
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
const renderWorkspaceList = () => {
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<WorkspaceList />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('WorkspaceList', () => {
|
||||
it('should render workspace list', async () => {
|
||||
const mockWorkspaces = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Test Workspace',
|
||||
subscription_plan: 'free',
|
||||
subscription_status: 'active',
|
||||
created_at: '2024-01-01',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(workspaceApi.getWorkspaces).mockResolvedValue({
|
||||
items: mockWorkspaces,
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
});
|
||||
|
||||
renderWorkspaceList();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Workspace')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show create workspace button', () => {
|
||||
renderWorkspaceList();
|
||||
|
||||
expect(screen.getByText('创建工作空间')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* useAuth Hook 单元测试
|
||||
*/
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import * as authApi from '@/api/auth';
|
||||
|
||||
// Mock API
|
||||
vi.mock('@/api/auth');
|
||||
|
||||
describe('useAuth', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('should login successfully', async () => {
|
||||
const mockUser = {
|
||||
id: '1',
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
};
|
||||
|
||||
const mockToken = 'mock-token';
|
||||
|
||||
vi.mocked(authApi.login).mockResolvedValue({
|
||||
user: mockUser,
|
||||
access_token: mockToken,
|
||||
refresh_token: 'refresh-token',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAuth());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.login('test@example.com', 'password123');
|
||||
});
|
||||
|
||||
expect(authApi.login).toHaveBeenCalledWith('test@example.com', 'password123');
|
||||
expect(localStorage.getItem('token')).toBe(mockToken);
|
||||
});
|
||||
|
||||
it('should logout successfully', async () => {
|
||||
localStorage.setItem('token', 'mock-token');
|
||||
|
||||
const { result } = renderHook(() => useAuth());
|
||||
|
||||
act(() => {
|
||||
result.current.logout();
|
||||
});
|
||||
|
||||
expect(localStorage.getItem('token')).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle login error', async () => {
|
||||
vi.mocked(authApi.login).mockRejectedValue(new Error('Invalid credentials'));
|
||||
|
||||
const { result } = renderHook(() => useAuth());
|
||||
|
||||
await expect(
|
||||
result.current.login('test@example.com', 'wrong-password')
|
||||
).rejects.toThrow('Invalid credentials');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 测试环境设置
|
||||
*/
|
||||
import { expect, afterEach } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import matchers from '@testing-library/jest-dom/matchers';
|
||||
|
||||
// 扩展 Vitest 的 expect
|
||||
expect.extend(matchers);
|
||||
|
||||
// 每次测试后清理
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
Reference in New Issue
Block a user