fix: connect API to tracker.db via SQLite repository
Deploy / Deploy Staging (push) Failing after 9s
Deploy / Deploy Production (push) Has been skipped
Tests / test (push) Failing after 14s
Tests / lint (push) Failing after 15s

This commit is contained in:
Xiaoxia AI
2026-06-17 13:21:38 +08:00
parent 3bf90f8614
commit 2cda8dec39
67 changed files with 5737 additions and 123 deletions
+80
View File
@@ -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;
};
+71
View File
@@ -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;
+67
View File
@@ -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;
};
+95
View File
@@ -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;
+80
View File
@@ -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;
+100
View File
@@ -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;
+79
View File
@@ -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);
},
});
};
+64
View File
@@ -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
View File
@@ -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>
);
+68
View File
@@ -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;
+108
View File
@@ -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;
+18
View File
@@ -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;
}
+95
View File
@@ -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;
+18
View File
@@ -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;
}
+134
View File
@@ -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;
+18
View File
@@ -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;
}
+127
View File
@@ -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;
+55
View File
@@ -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;
+106
View File
@@ -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;
+80
View File
@@ -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;
+98
View File
@@ -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;
+70
View File
@@ -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,
}),
}
)
);
+32
View File
@@ -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 });
},
}));
+43
View File
@@ -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,
}));
},
}));
+125
View File
@@ -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();
});
});
+64
View File
@@ -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');
});
});
+14
View File
@@ -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();
});