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
+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;