fix: connect API to tracker.db via SQLite repository
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 邀请成员弹窗
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Modal, Form, Input, Select, message } from 'antd';
|
||||
import { useInviteMember } from '@/hooks/useWorkspace';
|
||||
|
||||
interface InviteMemberModalProps {
|
||||
workspaceId: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const InviteMemberModal: React.FC<InviteMemberModalProps> = ({
|
||||
workspaceId,
|
||||
open,
|
||||
onClose,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const inviteMutation = useInviteMember(workspaceId);
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
await inviteMutation.mutateAsync(values);
|
||||
message.success('邀请已发送!');
|
||||
form.resetFields();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
// 验证失败或请求失败
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
form.resetFields();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="邀请成员"
|
||||
open={open}
|
||||
onOk={handleOk}
|
||||
onCancel={handleCancel}
|
||||
confirmLoading={inviteMutation.isPending}
|
||||
okText="发送邀请"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="邮箱地址"
|
||||
rules={[
|
||||
{ required: true, message: '请输入邮箱地址' },
|
||||
{ type: 'email', message: '请输入有效的邮箱地址' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="member@example.com" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="role"
|
||||
label="角色"
|
||||
initialValue="member"
|
||||
rules={[{ required: true, message: '请选择角色' }]}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'admin', label: '管理员 - 可以管理成员和项目' },
|
||||
{ value: 'member', label: '成员 - 可以创建和管理项目' },
|
||||
{ value: 'viewer', label: '访客 - 只读权限' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default InviteMemberModal;
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 成员列表组件
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Table, Button, Tag, Space, Popconfirm, message, Select } from 'antd';
|
||||
import { DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
||||
import { useWorkspaceMembers } from '@/hooks/useWorkspace';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { removeMember, updateMemberRole } from '@/api/workspace';
|
||||
|
||||
interface MemberListProps {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
const MemberList: React.FC<MemberListProps> = ({ workspaceId }) => {
|
||||
const { data: members, isLoading } = useWorkspaceMembers(workspaceId);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (memberId: string) => removeMember(workspaceId, memberId),
|
||||
onSuccess: () => {
|
||||
message.success('成员已移除');
|
||||
queryClient.invalidateQueries({ queryKey: ['workspaceMembers', workspaceId] });
|
||||
},
|
||||
});
|
||||
|
||||
const updateRoleMutation = useMutation({
|
||||
mutationFn: ({ memberId, role }: { memberId: string; role: string }) =>
|
||||
updateMemberRole(workspaceId, memberId, role as any),
|
||||
onSuccess: () => {
|
||||
message.success('角色已更新');
|
||||
queryClient.invalidateQueries({ queryKey: ['workspaceMembers', workspaceId] });
|
||||
},
|
||||
});
|
||||
|
||||
const getRoleTag = (role: string) => {
|
||||
const roleConfig: Record<string, { color: string; text: string }> = {
|
||||
owner: { color: 'gold', text: '拥有者' },
|
||||
admin: { color: 'blue', text: '管理员' },
|
||||
member: { color: 'green', text: '成员' },
|
||||
viewer: { color: 'default', text: '访客' },
|
||||
};
|
||||
const config = roleConfig[role] || roleConfig.member;
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '用户名',
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
},
|
||||
{
|
||||
title: '邮箱',
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
key: 'role',
|
||||
render: (role: string, record: any) => {
|
||||
if (role === 'owner') {
|
||||
return getRoleTag(role);
|
||||
}
|
||||
return (
|
||||
<Select
|
||||
value={role}
|
||||
style={{ width: 120 }}
|
||||
onChange={(newRole) =>
|
||||
updateRoleMutation.mutate({ memberId: record.id, role: newRole })
|
||||
}
|
||||
options={[
|
||||
{ value: 'admin', label: '管理员' },
|
||||
{ value: 'member', label: '成员' },
|
||||
{ value: 'viewer', label: '访客' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '加入时间',
|
||||
dataIndex: 'joined_at',
|
||||
key: 'joined_at',
|
||||
render: (date: string) => new Date(date).toLocaleDateString(),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: any, record: any) => {
|
||||
if (record.role === 'owner') {
|
||||
return <span style={{ color: '#999' }}>-</span>;
|
||||
}
|
||||
return (
|
||||
<Space>
|
||||
<Popconfirm
|
||||
title="确定移除该成员?"
|
||||
onConfirm={() => removeMutation.mutate(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={removeMutation.isPending}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={members}
|
||||
loading={isLoading}
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 10 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemberList;
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 权限矩阵展示组件
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Card, Table } from 'antd';
|
||||
import { CheckOutlined, CloseOutlined } from '@ant-design/icons';
|
||||
|
||||
const PermissionMatrix: React.FC = () => {
|
||||
const permissions = [
|
||||
{ action: '查看工作空间', owner: true, admin: true, member: true, viewer: true },
|
||||
{ action: '创建项目', owner: true, admin: true, member: true, viewer: false },
|
||||
{ action: '编辑项目', owner: true, admin: true, member: true, viewer: false },
|
||||
{ action: '删除项目', owner: true, admin: true, member: false, viewer: false },
|
||||
{ action: '邀请成员', owner: true, admin: true, member: false, viewer: false },
|
||||
{ action: '移除成员', owner: true, admin: true, member: false, viewer: false },
|
||||
{ action: '修改成员角色', owner: true, admin: true, member: false, viewer: false },
|
||||
{ action: '升级订阅', owner: true, admin: false, member: false, viewer: false },
|
||||
{ action: '删除工作空间', owner: true, admin: false, member: false, viewer: false },
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
fixed: 'left' as const,
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '拥有者',
|
||||
dataIndex: 'owner',
|
||||
key: 'owner',
|
||||
align: 'center' as const,
|
||||
render: (value: boolean) =>
|
||||
value ? (
|
||||
<CheckOutlined style={{ color: '#52c41a', fontSize: '16px' }} />
|
||||
) : (
|
||||
<CloseOutlined style={{ color: '#ff4d4f', fontSize: '16px' }} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '管理员',
|
||||
dataIndex: 'admin',
|
||||
key: 'admin',
|
||||
align: 'center' as const,
|
||||
render: (value: boolean) =>
|
||||
value ? (
|
||||
<CheckOutlined style={{ color: '#52c41a', fontSize: '16px' }} />
|
||||
) : (
|
||||
<CloseOutlined style={{ color: '#ff4d4f', fontSize: '16px' }} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '成员',
|
||||
dataIndex: 'member',
|
||||
key: 'member',
|
||||
align: 'center' as const,
|
||||
render: (value: boolean) =>
|
||||
value ? (
|
||||
<CheckOutlined style={{ color: '#52c41a', fontSize: '16px' }} />
|
||||
) : (
|
||||
<CloseOutlined style={{ color: '#ff4d4f', fontSize: '16px' }} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '访客',
|
||||
dataIndex: 'viewer',
|
||||
key: 'viewer',
|
||||
align: 'center' as const,
|
||||
render: (value: boolean) =>
|
||||
value ? (
|
||||
<CheckOutlined style={{ color: '#52c41a', fontSize: '16px' }} />
|
||||
) : (
|
||||
<CloseOutlined style={{ color: '#ff4d4f', fontSize: '16px' }} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card title="权限说明">
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={permissions}
|
||||
pagination={false}
|
||||
rowKey="action"
|
||||
size="small"
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PermissionMatrix;
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 配额使用展示组件
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Card, Progress, Row, Col, Tag, Space } from 'antd';
|
||||
import { ProjectOutlined, CloudOutlined, WarningOutlined } from '@ant-design/icons';
|
||||
import type { QuotaStatus } from '@/api/subscription';
|
||||
|
||||
interface QuotaDisplayProps {
|
||||
quota: QuotaStatus;
|
||||
}
|
||||
|
||||
const QuotaDisplay: React.FC<QuotaDisplayProps> = ({ quota }) => {
|
||||
const getStatusTag = (status: string) => {
|
||||
const config: Record<string, { color: string; text: string; icon: React.ReactNode }> = {
|
||||
normal: { color: 'success', text: '正常', icon: null },
|
||||
warning: { color: 'warning', text: '接近上限', icon: <WarningOutlined /> },
|
||||
critical: { color: 'error', text: '即将超限', icon: <WarningOutlined /> },
|
||||
exceeded: { color: 'error', text: '已超限', icon: <WarningOutlined /> },
|
||||
};
|
||||
const item = config[status] || config.normal;
|
||||
return (
|
||||
<Tag color={item.color} icon={item.icon}>
|
||||
{item.text}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
const getProgressStatus = (status: string) => {
|
||||
const statusMap: Record<string, 'success' | 'exception' | 'normal'> = {
|
||||
normal: 'success',
|
||||
warning: 'normal',
|
||||
critical: 'exception',
|
||||
exceeded: 'exception',
|
||||
};
|
||||
return statusMap[status] || 'normal';
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title="配额使用情况">
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<div>
|
||||
<Row justify="space-between" style={{ marginBottom: 8 }}>
|
||||
<Col>
|
||||
<Space>
|
||||
<ProjectOutlined />
|
||||
<span>项目数量</span>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>{getStatusTag(quota.projects.status)}</Col>
|
||||
</Row>
|
||||
<div style={{ marginBottom: 4, color: '#666' }}>
|
||||
{quota.projects.used} / {quota.projects.limit} 个项目
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.round((quota.projects.used / quota.projects.limit) * 100)}
|
||||
status={getProgressStatus(quota.projects.status)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Row justify="space-between" style={{ marginBottom: 8 }}>
|
||||
<Col>
|
||||
<Space>
|
||||
<CloudOutlined />
|
||||
<span>存储空间</span>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>{getStatusTag(quota.storage.status)}</Col>
|
||||
</Row>
|
||||
<div style={{ marginBottom: 4, color: '#666' }}>
|
||||
{quota.storage.used_gb.toFixed(2)} / {quota.storage.limit_gb} GB
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.round((quota.storage.used_gb / quota.storage.limit_gb) * 100)}
|
||||
status={getProgressStatus(quota.storage.status)}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuotaDisplay;
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 顶部导航栏
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Layout, Button, Dropdown, Avatar, Space } from 'antd';
|
||||
import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
UserOutlined,
|
||||
LogoutOutlined,
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useUIStore } from '@/store/uiStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useLogout } from '@/hooks/useAuth';
|
||||
import type { MenuProps } from 'antd';
|
||||
|
||||
const { Header: AntHeader } = Layout;
|
||||
|
||||
const Header: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const sidebarCollapsed = useUIStore((state) => state.sidebarCollapsed);
|
||||
const toggleSidebar = useUIStore((state) => state.toggleSidebar);
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const logoutMutation = useLogout();
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'profile',
|
||||
icon: <UserOutlined />,
|
||||
label: '个人设置',
|
||||
onClick: () => navigate('/profile'),
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
icon: <SettingOutlined />,
|
||||
label: '账号设置',
|
||||
onClick: () => navigate('/profile/settings'),
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: '退出登录',
|
||||
onClick: () => logoutMutation.mutate(),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<AntHeader
|
||||
style={{
|
||||
padding: '0 24px',
|
||||
background: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={sidebarCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={toggleSidebar}
|
||||
style={{ fontSize: '16px', width: 64, height: 64 }}
|
||||
/>
|
||||
|
||||
<Dropdown menu={{ items: menuItems }} placement="bottomRight">
|
||||
<Space style={{ cursor: 'pointer' }}>
|
||||
<Avatar icon={<UserOutlined />} />
|
||||
<span>{user?.display_name || user?.username}</span>
|
||||
</Space>
|
||||
</Dropdown>
|
||||
</AntHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,10 @@
|
||||
.main-layout {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-layout-content {
|
||||
margin: 24px 16px;
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
min-height: 280px;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 主布局组件
|
||||
* 包含 Header、Sidebar、Content 区域
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Layout } from 'antd';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Header from './Header';
|
||||
import Sidebar from './Sidebar';
|
||||
import { useUIStore } from '@/store/uiStore';
|
||||
import './MainLayout.css';
|
||||
|
||||
const { Content } = Layout;
|
||||
|
||||
const MainLayout: React.FC = () => {
|
||||
const sidebarCollapsed = useUIStore((state) => state.sidebarCollapsed);
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sidebar collapsed={sidebarCollapsed} />
|
||||
<Layout>
|
||||
<Header />
|
||||
<Content style={{ margin: '24px 16px', padding: 24, background: '#fff' }}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainLayout;
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 侧边栏导航
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Layout, Menu } from 'antd';
|
||||
import {
|
||||
HomeOutlined,
|
||||
AppstoreOutlined,
|
||||
TeamOutlined,
|
||||
CrownOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import type { MenuProps } from 'antd';
|
||||
|
||||
const { Sider } = Layout;
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: '/workspaces',
|
||||
icon: <HomeOutlined />,
|
||||
label: '工作空间',
|
||||
onClick: () => navigate('/workspaces'),
|
||||
},
|
||||
{
|
||||
key: '/projects',
|
||||
icon: <AppstoreOutlined />,
|
||||
label: '项目',
|
||||
onClick: () => navigate('/projects'),
|
||||
},
|
||||
{
|
||||
key: '/members',
|
||||
icon: <TeamOutlined />,
|
||||
label: '成员管理',
|
||||
onClick: () => navigate('/members'),
|
||||
},
|
||||
{
|
||||
key: '/subscription',
|
||||
icon: <CrownOutlined />,
|
||||
label: '订阅管理',
|
||||
onClick: () => navigate('/subscription'),
|
||||
},
|
||||
{
|
||||
key: '/profile',
|
||||
icon: <UserOutlined />,
|
||||
label: '个人中心',
|
||||
onClick: () => navigate('/profile'),
|
||||
},
|
||||
];
|
||||
|
||||
// 根据当前路径选中菜单项
|
||||
const selectedKey = '/' + location.pathname.split('/')[1];
|
||||
|
||||
return (
|
||||
<Sider
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
trigger={null}
|
||||
width={200}
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
height: '100vh',
|
||||
position: 'sticky',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: 64,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{collapsed ? '🦐' : '小虾 SaaS'}
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey]}
|
||||
items={menuItems}
|
||||
/>
|
||||
</Sider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
Reference in New Issue
Block a user