feat(admin-client): add tenant management page
- Add tenants feature module with Clean Architecture structure - Create tenantsApi for super admin endpoints - Add React Query hooks for tenant CRUD operations - Implement TenantsPage with statistics, list, and modals - Add tenant route and sidebar menu item - Support create/edit tenant, suspend/activate, admin management Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e1e9ba1a77
commit
481c13b67d
|
|
@ -9,6 +9,7 @@ import { AnalyticsPage, ReportsPage, AuditPage } from './features/analytics';
|
|||
import { UsersPage } from './features/users';
|
||||
import { ConversationsPage } from './features/conversations';
|
||||
import { SettingsPage } from './features/settings';
|
||||
import { TenantsPage } from './features/tenants';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
|
|
@ -33,6 +34,7 @@ function App() {
|
|||
<Route path="audit" element={<AuditPage />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="conversations" element={<ConversationsPage />} />
|
||||
<Route path="tenants" element={<TenantsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export * from './useTenants';
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
tenantsApi,
|
||||
TenantQueryParams,
|
||||
TenantDto,
|
||||
PaginatedTenants,
|
||||
GlobalStats,
|
||||
CreateTenantDto,
|
||||
UpdateTenantDto,
|
||||
TenantAdminDto,
|
||||
CreateTenantAdminDto,
|
||||
} from '../infrastructure/tenants.api';
|
||||
|
||||
// Query keys
|
||||
export const TENANT_KEYS = {
|
||||
all: ['tenants'] as const,
|
||||
stats: ['tenants', 'stats'] as const,
|
||||
list: (params: TenantQueryParams) => ['tenants', 'list', params] as const,
|
||||
detail: (id: string) => ['tenants', 'detail', id] as const,
|
||||
admins: (tenantId: string) => ['tenants', 'admins', tenantId] as const,
|
||||
};
|
||||
|
||||
// Global statistics query
|
||||
export function useTenantStats() {
|
||||
return useQuery<GlobalStats>({
|
||||
queryKey: TENANT_KEYS.stats,
|
||||
queryFn: () => tenantsApi.getGlobalStats(),
|
||||
refetchInterval: 60000, // Refresh every minute
|
||||
});
|
||||
}
|
||||
|
||||
// Tenant list query
|
||||
export function useTenants(params: TenantQueryParams) {
|
||||
return useQuery<PaginatedTenants>({
|
||||
queryKey: TENANT_KEYS.list(params),
|
||||
queryFn: () => tenantsApi.listTenants(params),
|
||||
});
|
||||
}
|
||||
|
||||
// Tenant detail query
|
||||
export function useTenantDetail(id: string, enabled = true) {
|
||||
return useQuery<TenantDto>({
|
||||
queryKey: TENANT_KEYS.detail(id),
|
||||
queryFn: () => tenantsApi.getTenantDetail(id),
|
||||
enabled: enabled && !!id,
|
||||
});
|
||||
}
|
||||
|
||||
// Tenant admins query
|
||||
export function useTenantAdmins(tenantId: string, enabled = true) {
|
||||
return useQuery<TenantAdminDto[]>({
|
||||
queryKey: TENANT_KEYS.admins(tenantId),
|
||||
queryFn: () => tenantsApi.getTenantAdmins(tenantId),
|
||||
enabled: enabled && !!tenantId,
|
||||
});
|
||||
}
|
||||
|
||||
// Create tenant mutation
|
||||
export function useCreateTenant() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (dto: CreateTenantDto) => tenantsApi.createTenant(dto),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: TENANT_KEYS.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Update tenant mutation
|
||||
export function useUpdateTenant() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, dto }: { id: string; dto: UpdateTenantDto }) =>
|
||||
tenantsApi.updateTenant(id, dto),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: TENANT_KEYS.all });
|
||||
queryClient.invalidateQueries({ queryKey: TENANT_KEYS.detail(id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Suspend tenant mutation
|
||||
export function useSuspendTenant() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, reason }: { id: string; reason?: string }) =>
|
||||
tenantsApi.suspendTenant(id, reason),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: TENANT_KEYS.all });
|
||||
queryClient.invalidateQueries({ queryKey: TENANT_KEYS.detail(id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Activate tenant mutation
|
||||
export function useActivateTenant() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => tenantsApi.activateTenant(id),
|
||||
onSuccess: (_, id) => {
|
||||
queryClient.invalidateQueries({ queryKey: TENANT_KEYS.all });
|
||||
queryClient.invalidateQueries({ queryKey: TENANT_KEYS.detail(id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Archive tenant mutation
|
||||
export function useArchiveTenant() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => tenantsApi.archiveTenant(id),
|
||||
onSuccess: (_, id) => {
|
||||
queryClient.invalidateQueries({ queryKey: TENANT_KEYS.all });
|
||||
queryClient.invalidateQueries({ queryKey: TENANT_KEYS.detail(id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Create tenant admin mutation
|
||||
export function useCreateTenantAdmin() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ tenantId, dto }: { tenantId: string; dto: CreateTenantAdminDto }) =>
|
||||
tenantsApi.createTenantAdmin(tenantId, dto),
|
||||
onSuccess: (_, { tenantId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: TENANT_KEYS.admins(tenantId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Re-export types
|
||||
export type {
|
||||
TenantDto,
|
||||
TenantStatus,
|
||||
TenantPlan,
|
||||
TenantConfigData,
|
||||
TenantAdminDto,
|
||||
PaginatedTenants,
|
||||
GlobalStats,
|
||||
TenantQueryParams,
|
||||
CreateTenantDto,
|
||||
UpdateTenantDto,
|
||||
CreateTenantAdminDto,
|
||||
} from '../infrastructure/tenants.api';
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from './application';
|
||||
export { TenantsPage } from './presentation/pages/TenantsPage';
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './tenants.api';
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
import api from '../../../shared/utils/api';
|
||||
|
||||
// ==================== DTOs ====================
|
||||
|
||||
export type TenantStatus = 'ACTIVE' | 'SUSPENDED' | 'ARCHIVED';
|
||||
export type TenantPlan = 'FREE' | 'STANDARD' | 'ENTERPRISE';
|
||||
|
||||
export interface TenantConfigData {
|
||||
branding?: {
|
||||
logoUrl?: string;
|
||||
primaryColor?: string;
|
||||
companyName?: string;
|
||||
favicon?: string;
|
||||
};
|
||||
ai?: {
|
||||
systemPrompt?: string;
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
};
|
||||
features?: {
|
||||
enableFileUpload?: boolean;
|
||||
enableKnowledgeBase?: boolean;
|
||||
enablePayments?: boolean;
|
||||
enableUserMemory?: boolean;
|
||||
enableAnalytics?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TenantDto {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
status: TenantStatus;
|
||||
plan: TenantPlan;
|
||||
maxUsers: number;
|
||||
maxConversationsPerMonth: number;
|
||||
maxStorageMb: number;
|
||||
currentUserCount: number;
|
||||
currentConversationCount: number;
|
||||
currentStorageBytes: number;
|
||||
config: TenantConfigData;
|
||||
billingEmail: string | null;
|
||||
billingName: string | null;
|
||||
billingPhone: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
suspendedAt: string | null;
|
||||
}
|
||||
|
||||
export interface TenantAdminDto {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
username: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
role: string;
|
||||
isActive: boolean;
|
||||
lastLoginAt?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface PaginatedTenants {
|
||||
data: TenantDto[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TenantQueryParams {
|
||||
status?: TenantStatus;
|
||||
plan?: TenantPlan;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface GlobalStats {
|
||||
totalTenants: number;
|
||||
activeTenants: number;
|
||||
suspendedTenants: number;
|
||||
totalUsers: number;
|
||||
totalConversations: number;
|
||||
totalStorage: number;
|
||||
}
|
||||
|
||||
export interface CreateTenantDto {
|
||||
name: string;
|
||||
slug: string;
|
||||
plan?: TenantPlan;
|
||||
maxUsers?: number;
|
||||
maxConversationsPerMonth?: number;
|
||||
maxStorageMb?: number;
|
||||
billingEmail?: string;
|
||||
billingName?: string;
|
||||
billingPhone?: string;
|
||||
config?: TenantConfigData;
|
||||
}
|
||||
|
||||
export interface UpdateTenantDto {
|
||||
name?: string;
|
||||
plan?: TenantPlan;
|
||||
maxUsers?: number;
|
||||
maxConversationsPerMonth?: number;
|
||||
maxStorageMb?: number;
|
||||
billingEmail?: string;
|
||||
billingName?: string;
|
||||
billingPhone?: string;
|
||||
config?: TenantConfigData;
|
||||
}
|
||||
|
||||
export interface CreateTenantAdminDto {
|
||||
username: string;
|
||||
password: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
role?: 'ADMIN' | 'OPERATOR' | 'VIEWER';
|
||||
}
|
||||
|
||||
// ==================== API ====================
|
||||
|
||||
// Note: Super admin API uses /evolution/super-admin/* through Kong gateway
|
||||
const SUPER_ADMIN_BASE = '/evolution/super-admin';
|
||||
|
||||
export const tenantsApi = {
|
||||
// Get global statistics
|
||||
getGlobalStats: async (): Promise<GlobalStats> => {
|
||||
const response = await api.get(`${SUPER_ADMIN_BASE}/tenants/stats`);
|
||||
return response.data.data || response.data;
|
||||
},
|
||||
|
||||
// List tenants with pagination
|
||||
listTenants: async (params: TenantQueryParams): Promise<PaginatedTenants> => {
|
||||
const response = await api.get(`${SUPER_ADMIN_BASE}/tenants`, { params });
|
||||
return response.data.data || response.data;
|
||||
},
|
||||
|
||||
// Get tenant detail
|
||||
getTenantDetail: async (id: string): Promise<TenantDto> => {
|
||||
const response = await api.get(`${SUPER_ADMIN_BASE}/tenants/${id}`);
|
||||
return response.data.data || response.data;
|
||||
},
|
||||
|
||||
// Create tenant
|
||||
createTenant: async (dto: CreateTenantDto): Promise<TenantDto> => {
|
||||
const response = await api.post(`${SUPER_ADMIN_BASE}/tenants`, dto);
|
||||
return response.data.data || response.data;
|
||||
},
|
||||
|
||||
// Update tenant
|
||||
updateTenant: async (id: string, dto: UpdateTenantDto): Promise<TenantDto> => {
|
||||
const response = await api.put(`${SUPER_ADMIN_BASE}/tenants/${id}`, dto);
|
||||
return response.data.data || response.data;
|
||||
},
|
||||
|
||||
// Suspend tenant
|
||||
suspendTenant: async (id: string, reason?: string): Promise<TenantDto> => {
|
||||
const response = await api.post(`${SUPER_ADMIN_BASE}/tenants/${id}/suspend`, { reason });
|
||||
return response.data.data || response.data;
|
||||
},
|
||||
|
||||
// Activate tenant
|
||||
activateTenant: async (id: string): Promise<TenantDto> => {
|
||||
const response = await api.post(`${SUPER_ADMIN_BASE}/tenants/${id}/activate`);
|
||||
return response.data.data || response.data;
|
||||
},
|
||||
|
||||
// Archive tenant
|
||||
archiveTenant: async (id: string): Promise<TenantDto> => {
|
||||
const response = await api.post(`${SUPER_ADMIN_BASE}/tenants/${id}/archive`);
|
||||
return response.data.data || response.data;
|
||||
},
|
||||
|
||||
// Get tenant admins
|
||||
getTenantAdmins: async (tenantId: string): Promise<TenantAdminDto[]> => {
|
||||
const response = await api.get(`${SUPER_ADMIN_BASE}/tenants/${tenantId}/admins`);
|
||||
return response.data.data || response.data;
|
||||
},
|
||||
|
||||
// Create tenant admin
|
||||
createTenantAdmin: async (tenantId: string, dto: CreateTenantAdminDto): Promise<TenantAdminDto> => {
|
||||
const response = await api.post(`${SUPER_ADMIN_BASE}/tenants/${tenantId}/admins`, dto);
|
||||
return response.data.data || response.data;
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,828 @@
|
|||
import { useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
Tag,
|
||||
Space,
|
||||
Select,
|
||||
Button,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
Typography,
|
||||
Drawer,
|
||||
Descriptions,
|
||||
Spin,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
message,
|
||||
Popconfirm,
|
||||
List,
|
||||
Avatar,
|
||||
Progress,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
TeamOutlined,
|
||||
CheckCircleOutlined,
|
||||
StopOutlined,
|
||||
CloudOutlined,
|
||||
UserOutlined,
|
||||
ReloadOutlined,
|
||||
UserAddOutlined,
|
||||
CrownOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
useTenants,
|
||||
useTenantStats,
|
||||
useTenantDetail,
|
||||
useTenantAdmins,
|
||||
useCreateTenant,
|
||||
useUpdateTenant,
|
||||
useSuspendTenant,
|
||||
useActivateTenant,
|
||||
useCreateTenantAdmin,
|
||||
type TenantDto,
|
||||
type TenantStatus,
|
||||
type TenantPlan,
|
||||
type TenantAdminDto,
|
||||
type CreateTenantDto,
|
||||
type CreateTenantAdminDto,
|
||||
} from '../../application';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const STATUS_COLORS: Record<TenantStatus, string> = {
|
||||
ACTIVE: 'green',
|
||||
SUSPENDED: 'orange',
|
||||
ARCHIVED: 'default',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<TenantStatus, string> = {
|
||||
ACTIVE: '正常',
|
||||
SUSPENDED: '已暂停',
|
||||
ARCHIVED: '已归档',
|
||||
};
|
||||
|
||||
const PLAN_COLORS: Record<TenantPlan, string> = {
|
||||
FREE: 'default',
|
||||
STANDARD: 'blue',
|
||||
ENTERPRISE: 'gold',
|
||||
};
|
||||
|
||||
const PLAN_LABELS: Record<TenantPlan, string> = {
|
||||
FREE: '免费版',
|
||||
STANDARD: '标准版',
|
||||
ENTERPRISE: '企业版',
|
||||
};
|
||||
|
||||
export function TenantsPage() {
|
||||
const [filters, setFilters] = useState<{
|
||||
status?: TenantStatus;
|
||||
plan?: TenantPlan;
|
||||
page: number;
|
||||
limit: number;
|
||||
}>({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
const [detailDrawerOpen, setDetailDrawerOpen] = useState(false);
|
||||
const [selectedTenantId, setSelectedTenantId] = useState<string | null>(null);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [adminModalOpen, setAdminModalOpen] = useState(false);
|
||||
|
||||
const [createForm] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [adminForm] = Form.useForm();
|
||||
|
||||
// Queries
|
||||
const { data: tenantsData, isLoading: loadingTenants, refetch } = useTenants(filters);
|
||||
const { data: stats, isLoading: loadingStats } = useTenantStats();
|
||||
const { data: tenantDetail, isLoading: loadingDetail } = useTenantDetail(
|
||||
selectedTenantId || '',
|
||||
!!selectedTenantId && detailDrawerOpen
|
||||
);
|
||||
const { data: tenantAdmins, isLoading: loadingAdmins } = useTenantAdmins(
|
||||
selectedTenantId || '',
|
||||
!!selectedTenantId && detailDrawerOpen
|
||||
);
|
||||
|
||||
// Mutations
|
||||
const createMutation = useCreateTenant();
|
||||
const updateMutation = useUpdateTenant();
|
||||
const suspendMutation = useSuspendTenant();
|
||||
const activateMutation = useActivateTenant();
|
||||
const createAdminMutation = useCreateTenantAdmin();
|
||||
|
||||
const showTenantDetail = (tenant: TenantDto) => {
|
||||
setSelectedTenantId(tenant.id);
|
||||
setDetailDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateTenant = async (values: CreateTenantDto) => {
|
||||
try {
|
||||
await createMutation.mutateAsync(values);
|
||||
message.success('租户创建成功');
|
||||
setCreateModalOpen(false);
|
||||
createForm.resetFields();
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || '创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditTenant = async (values: any) => {
|
||||
if (!selectedTenantId) return;
|
||||
try {
|
||||
await updateMutation.mutateAsync({ id: selectedTenantId, dto: values });
|
||||
message.success('租户更新成功');
|
||||
setEditModalOpen(false);
|
||||
editForm.resetFields();
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || '更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuspend = async (id: string) => {
|
||||
try {
|
||||
await suspendMutation.mutateAsync({ id });
|
||||
message.success('租户已暂停');
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = async (id: string) => {
|
||||
try {
|
||||
await activateMutation.mutateAsync(id);
|
||||
message.success('租户已激活');
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateAdmin = async (values: CreateTenantAdminDto) => {
|
||||
if (!selectedTenantId) return;
|
||||
try {
|
||||
await createAdminMutation.mutateAsync({ tenantId: selectedTenantId, dto: values });
|
||||
message.success('管理员创建成功');
|
||||
setAdminModalOpen(false);
|
||||
adminForm.resetFields();
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || '创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openEditModal = (tenant: TenantDto) => {
|
||||
setSelectedTenantId(tenant.id);
|
||||
editForm.setFieldsValue({
|
||||
name: tenant.name,
|
||||
plan: tenant.plan,
|
||||
maxUsers: tenant.maxUsers,
|
||||
maxConversationsPerMonth: tenant.maxConversationsPerMonth,
|
||||
maxStorageMb: tenant.maxStorageMb,
|
||||
billingEmail: tenant.billingEmail,
|
||||
billingName: tenant.billingName,
|
||||
billingPhone: tenant.billingPhone,
|
||||
});
|
||||
setEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleTableChange = (pagination: { current?: number; pageSize?: number }) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
page: pagination.current || 1,
|
||||
limit: pagination.pageSize || 20,
|
||||
}));
|
||||
};
|
||||
|
||||
const columns: ColumnsType<TenantDto> = [
|
||||
{
|
||||
title: '租户名称',
|
||||
key: 'name',
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Avatar
|
||||
size="small"
|
||||
icon={<CrownOutlined />}
|
||||
style={{ backgroundColor: record.plan === 'ENTERPRISE' ? '#faad14' : '#1890ff' }}
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">{record.name}</div>
|
||||
<div className="text-xs text-gray-400">{record.slug}</div>
|
||||
</div>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (status: TenantStatus) => (
|
||||
<Tag color={STATUS_COLORS[status]}>{STATUS_LABELS[status]}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '套餐',
|
||||
dataIndex: 'plan',
|
||||
key: 'plan',
|
||||
width: 100,
|
||||
render: (plan: TenantPlan) => (
|
||||
<Tag color={PLAN_COLORS[plan]}>{PLAN_LABELS[plan]}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '用户数',
|
||||
key: 'users',
|
||||
width: 120,
|
||||
render: (_, record) => (
|
||||
<Tooltip title={`${record.currentUserCount} / ${record.maxUsers}`}>
|
||||
<Progress
|
||||
percent={Math.round((record.currentUserCount / record.maxUsers) * 100)}
|
||||
size="small"
|
||||
format={() => `${record.currentUserCount}/${record.maxUsers}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '对话数/月',
|
||||
key: 'conversations',
|
||||
width: 140,
|
||||
render: (_, record) => (
|
||||
<Tooltip title={`${record.currentConversationCount} / ${record.maxConversationsPerMonth}`}>
|
||||
<Progress
|
||||
percent={Math.round((record.currentConversationCount / record.maxConversationsPerMonth) * 100)}
|
||||
size="small"
|
||||
format={() => `${record.currentConversationCount}/${record.maxConversationsPerMonth}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 160,
|
||||
render: (date) => dayjs(date).format('YYYY-MM-DD HH:mm'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 180,
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<a onClick={() => showTenantDetail(record)}>详情</a>
|
||||
<a onClick={() => openEditModal(record)}>编辑</a>
|
||||
{record.status === 'ACTIVE' ? (
|
||||
<Popconfirm
|
||||
title="确定要暂停此租户吗?"
|
||||
description="暂停后租户将无法正常使用服务"
|
||||
onConfirm={() => handleSuspend(record.id)}
|
||||
>
|
||||
<a className="text-orange-500">暂停</a>
|
||||
</Popconfirm>
|
||||
) : record.status === 'SUSPENDED' ? (
|
||||
<Popconfirm
|
||||
title="确定要激活此租户吗?"
|
||||
onConfirm={() => handleActivate(record.id)}
|
||||
>
|
||||
<a className="text-green-500">激活</a>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<Title level={4} className="mb-0">租户管理</Title>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => refetch()}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
新建租户
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* Statistics Cards */}
|
||||
<Row gutter={[16, 16]} className="mb-4">
|
||||
<Col xs={24} sm={8} lg={4}>
|
||||
<Card>
|
||||
<Spin spinning={loadingStats}>
|
||||
<Statistic
|
||||
title="总租户数"
|
||||
value={stats?.totalTenants ?? 0}
|
||||
prefix={<TeamOutlined />}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
/>
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8} lg={4}>
|
||||
<Card>
|
||||
<Spin spinning={loadingStats}>
|
||||
<Statistic
|
||||
title="活跃租户"
|
||||
value={stats?.activeTenants ?? 0}
|
||||
prefix={<CheckCircleOutlined />}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
/>
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8} lg={4}>
|
||||
<Card>
|
||||
<Spin spinning={loadingStats}>
|
||||
<Statistic
|
||||
title="暂停租户"
|
||||
value={stats?.suspendedTenants ?? 0}
|
||||
prefix={<StopOutlined />}
|
||||
valueStyle={{ color: '#faad14' }}
|
||||
/>
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8} lg={4}>
|
||||
<Card>
|
||||
<Spin spinning={loadingStats}>
|
||||
<Statistic
|
||||
title="总用户数"
|
||||
value={stats?.totalUsers ?? 0}
|
||||
prefix={<UserOutlined />}
|
||||
valueStyle={{ color: '#722ed1' }}
|
||||
/>
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8} lg={4}>
|
||||
<Card>
|
||||
<Spin spinning={loadingStats}>
|
||||
<Statistic
|
||||
title="总对话数"
|
||||
value={stats?.totalConversations ?? 0}
|
||||
prefix={<TeamOutlined />}
|
||||
valueStyle={{ color: '#13c2c2' }}
|
||||
/>
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8} lg={4}>
|
||||
<Card>
|
||||
<Spin spinning={loadingStats}>
|
||||
<Statistic
|
||||
title="存储使用"
|
||||
value={((stats?.totalStorage ?? 0) / (1024 * 1024 * 1024)).toFixed(2)}
|
||||
suffix="GB"
|
||||
prefix={<CloudOutlined />}
|
||||
valueStyle={{ color: '#eb2f96' }}
|
||||
/>
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="mb-4">
|
||||
<Space wrap>
|
||||
<Select
|
||||
placeholder="租户状态"
|
||||
allowClear
|
||||
value={filters.status}
|
||||
onChange={(value) => setFilters((prev) => ({ ...prev, status: value, page: 1 }))}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: 'ACTIVE', label: '正常' },
|
||||
{ value: 'SUSPENDED', label: '已暂停' },
|
||||
{ value: 'ARCHIVED', label: '已归档' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
placeholder="套餐类型"
|
||||
allowClear
|
||||
value={filters.plan}
|
||||
onChange={(value) => setFilters((prev) => ({ ...prev, plan: value, page: 1 }))}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: 'FREE', label: '免费版' },
|
||||
{ value: 'STANDARD', label: '标准版' },
|
||||
{ value: 'ENTERPRISE', label: '企业版' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* Tenants Table */}
|
||||
<Card>
|
||||
<Spin spinning={loadingTenants}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tenantsData?.data || []}
|
||||
rowKey="id"
|
||||
pagination={{
|
||||
current: filters.page,
|
||||
pageSize: filters.limit,
|
||||
total: tenantsData?.total || 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
</Spin>
|
||||
</Card>
|
||||
|
||||
{/* Tenant Detail Drawer */}
|
||||
<Drawer
|
||||
title="租户详情"
|
||||
placement="right"
|
||||
width={600}
|
||||
open={detailDrawerOpen}
|
||||
onClose={() => {
|
||||
setDetailDrawerOpen(false);
|
||||
setSelectedTenantId(null);
|
||||
}}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<UserAddOutlined />}
|
||||
onClick={() => setAdminModalOpen(true)}
|
||||
>
|
||||
添加管理员
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Spin spinning={loadingDetail}>
|
||||
{tenantDetail && (
|
||||
<div>
|
||||
<div className="text-center mb-6">
|
||||
<Avatar
|
||||
size={64}
|
||||
icon={<CrownOutlined />}
|
||||
style={{ backgroundColor: tenantDetail.plan === 'ENTERPRISE' ? '#faad14' : '#1890ff' }}
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<Title level={4} className="mb-1">{tenantDetail.name}</Title>
|
||||
<Space>
|
||||
<Tag color={STATUS_COLORS[tenantDetail.status]}>
|
||||
{STATUS_LABELS[tenantDetail.status]}
|
||||
</Tag>
|
||||
<Tag color={PLAN_COLORS[tenantDetail.plan]}>
|
||||
{PLAN_LABELS[tenantDetail.plan]}
|
||||
</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Descriptions bordered column={1} size="small" className="mb-6">
|
||||
<Descriptions.Item label="租户ID">
|
||||
<code className="text-xs">{tenantDetail.id}</code>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="标识符">{tenantDetail.slug}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户配额">
|
||||
{tenantDetail.currentUserCount} / {tenantDetail.maxUsers}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="月对话配额">
|
||||
{tenantDetail.currentConversationCount} / {tenantDetail.maxConversationsPerMonth}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="存储配额">
|
||||
{(tenantDetail.currentStorageBytes / (1024 * 1024)).toFixed(2)} MB / {tenantDetail.maxStorageMb} MB
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="账单邮箱">
|
||||
{tenantDetail.billingEmail || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="账单联系人">
|
||||
{tenantDetail.billingName || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="账单电话">
|
||||
{tenantDetail.billingPhone || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">
|
||||
{dayjs(tenantDetail.createdAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</Descriptions.Item>
|
||||
{tenantDetail.suspendedAt && (
|
||||
<Descriptions.Item label="暂停时间">
|
||||
{dayjs(tenantDetail.suspendedAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
|
||||
{/* Tenant Admins */}
|
||||
<Title level={5}>管理员列表</Title>
|
||||
<Spin spinning={loadingAdmins}>
|
||||
{tenantAdmins && tenantAdmins.length > 0 ? (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={tenantAdmins}
|
||||
renderItem={(admin: TenantAdminDto) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
avatar={<Avatar icon={<UserOutlined />} />}
|
||||
title={
|
||||
<Space>
|
||||
{admin.name}
|
||||
<Tag>{admin.role}</Tag>
|
||||
{!admin.isActive && <Tag color="red">已禁用</Tag>}
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<div className="text-xs text-gray-400">
|
||||
<div>用户名: {admin.username}</div>
|
||||
{admin.email && <div>邮箱: {admin.email}</div>}
|
||||
{admin.lastLoginAt && (
|
||||
<div>最后登录: {dayjs(admin.lastLoginAt).format('YYYY-MM-DD HH:mm')}</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center text-gray-400 py-4">暂无管理员</div>
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
</Drawer>
|
||||
|
||||
{/* Create Tenant Modal */}
|
||||
<Modal
|
||||
title="新建租户"
|
||||
open={createModalOpen}
|
||||
onCancel={() => {
|
||||
setCreateModalOpen(false);
|
||||
createForm.resetFields();
|
||||
}}
|
||||
footer={null}
|
||||
width={600}
|
||||
>
|
||||
<Form
|
||||
form={createForm}
|
||||
layout="vertical"
|
||||
onFinish={handleCreateTenant}
|
||||
initialValues={{
|
||||
plan: 'STANDARD',
|
||||
maxUsers: 100,
|
||||
maxConversationsPerMonth: 10000,
|
||||
maxStorageMb: 5120,
|
||||
}}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="租户名称"
|
||||
rules={[{ required: true, message: '请输入租户名称' }]}
|
||||
>
|
||||
<Input placeholder="例如: 某某公司" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="slug"
|
||||
label="标识符"
|
||||
rules={[
|
||||
{ required: true, message: '请输入标识符' },
|
||||
{ pattern: /^[a-z0-9-]+$/, message: '只能包含小写字母、数字和连字符' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="例如: company-name" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="plan" label="套餐">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'FREE', label: '免费版' },
|
||||
{ value: 'STANDARD', label: '标准版' },
|
||||
{ value: 'ENTERPRISE', label: '企业版' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="maxUsers" label="最大用户数">
|
||||
<InputNumber min={1} max={100000} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="maxConversationsPerMonth" label="月对话上限">
|
||||
<InputNumber min={1} max={1000000} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="maxStorageMb" label="存储上限 (MB)">
|
||||
<InputNumber min={1} max={102400} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item name="billingEmail" label="账单邮箱">
|
||||
<Input placeholder="billing@example.com" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="billingName" label="账单联系人">
|
||||
<Input placeholder="联系人姓名" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="billingPhone" label="账单电话">
|
||||
<Input placeholder="联系电话" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item className="mb-0 text-right">
|
||||
<Space>
|
||||
<Button onClick={() => setCreateModalOpen(false)}>取消</Button>
|
||||
<Button type="primary" htmlType="submit" loading={createMutation.isPending}>
|
||||
创建
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Tenant Modal */}
|
||||
<Modal
|
||||
title="编辑租户"
|
||||
open={editModalOpen}
|
||||
onCancel={() => {
|
||||
setEditModalOpen(false);
|
||||
editForm.resetFields();
|
||||
}}
|
||||
footer={null}
|
||||
width={600}
|
||||
>
|
||||
<Form form={editForm} layout="vertical" onFinish={handleEditTenant}>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="name" label="租户名称">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="plan" label="套餐">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'FREE', label: '免费版' },
|
||||
{ value: 'STANDARD', label: '标准版' },
|
||||
{ value: 'ENTERPRISE', label: '企业版' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item name="maxUsers" label="最大用户数">
|
||||
<InputNumber min={1} max={100000} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="maxConversationsPerMonth" label="月对话上限">
|
||||
<InputNumber min={1} max={1000000} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="maxStorageMb" label="存储上限 (MB)">
|
||||
<InputNumber min={1} max={102400} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item name="billingEmail" label="账单邮箱">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="billingName" label="账单联系人">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="billingPhone" label="账单电话">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item className="mb-0 text-right">
|
||||
<Space>
|
||||
<Button onClick={() => setEditModalOpen(false)}>取消</Button>
|
||||
<Button type="primary" htmlType="submit" loading={updateMutation.isPending}>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Create Admin Modal */}
|
||||
<Modal
|
||||
title="添加管理员"
|
||||
open={adminModalOpen}
|
||||
onCancel={() => {
|
||||
setAdminModalOpen(false);
|
||||
adminForm.resetFields();
|
||||
}}
|
||||
footer={null}
|
||||
>
|
||||
<Form
|
||||
form={adminForm}
|
||||
layout="vertical"
|
||||
onFinish={handleCreateAdmin}
|
||||
initialValues={{ role: 'OPERATOR' }}
|
||||
>
|
||||
<Form.Item
|
||||
name="username"
|
||||
label="用户名"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
>
|
||||
<Input placeholder="登录用户名" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码"
|
||||
rules={[
|
||||
{ required: true, message: '请输入密码' },
|
||||
{ min: 6, message: '密码至少6位' },
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="登录密码" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="姓名"
|
||||
rules={[{ required: true, message: '请输入姓名' }]}
|
||||
>
|
||||
<Input placeholder="管理员姓名" />
|
||||
</Form.Item>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="email" label="邮箱">
|
||||
<Input placeholder="邮箱地址" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="phone" label="电话">
|
||||
<Input placeholder="联系电话" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item name="role" label="角色">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'ADMIN', label: '管理员' },
|
||||
{ value: 'OPERATOR', label: '操作员' },
|
||||
{ value: 'VIEWER', label: '查看者' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item className="mb-0 text-right">
|
||||
<Space>
|
||||
<Button onClick={() => setAdminModalOpen(false)}>取消</Button>
|
||||
<Button type="primary" htmlType="submit" loading={createAdminMutation.isPending}>
|
||||
创建
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
AuditOutlined,
|
||||
LineChartOutlined,
|
||||
MessageOutlined,
|
||||
ClusterOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
|
||||
|
|
@ -70,6 +71,11 @@ const menuItems: MenuProps['items'] = [
|
|||
icon: <MessageOutlined />,
|
||||
label: '对话管理',
|
||||
},
|
||||
{
|
||||
key: '/tenants',
|
||||
icon: <ClusterOutlined />,
|
||||
label: '租户管理',
|
||||
},
|
||||
{
|
||||
key: '/settings',
|
||||
icon: <SettingOutlined />,
|
||||
|
|
|
|||
Loading…
Reference in New Issue