From 481c13b67db7deaff709b4ae6e4dbd0819f43bad Mon Sep 17 00:00:00 2001 From: hailin Date: Mon, 26 Jan 2026 07:03:14 -0800 Subject: [PATCH] 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 --- packages/admin-client/src/App.tsx | 2 + .../src/features/tenants/application/index.ts | 1 + .../tenants/application/useTenants.ts | 150 ++++ .../src/features/tenants/index.ts | 2 + .../features/tenants/infrastructure/index.ts | 1 + .../tenants/infrastructure/tenants.api.ts | 184 ++++ .../presentation/pages/TenantsPage.tsx | 828 ++++++++++++++++++ .../src/shared/components/MainLayout.tsx | 6 + 8 files changed, 1174 insertions(+) create mode 100644 packages/admin-client/src/features/tenants/application/index.ts create mode 100644 packages/admin-client/src/features/tenants/application/useTenants.ts create mode 100644 packages/admin-client/src/features/tenants/index.ts create mode 100644 packages/admin-client/src/features/tenants/infrastructure/index.ts create mode 100644 packages/admin-client/src/features/tenants/infrastructure/tenants.api.ts create mode 100644 packages/admin-client/src/features/tenants/presentation/pages/TenantsPage.tsx diff --git a/packages/admin-client/src/App.tsx b/packages/admin-client/src/App.tsx index 148ac4d..90af944 100644 --- a/packages/admin-client/src/App.tsx +++ b/packages/admin-client/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> diff --git a/packages/admin-client/src/features/tenants/application/index.ts b/packages/admin-client/src/features/tenants/application/index.ts new file mode 100644 index 0000000..e00811a --- /dev/null +++ b/packages/admin-client/src/features/tenants/application/index.ts @@ -0,0 +1 @@ +export * from './useTenants'; diff --git a/packages/admin-client/src/features/tenants/application/useTenants.ts b/packages/admin-client/src/features/tenants/application/useTenants.ts new file mode 100644 index 0000000..c282921 --- /dev/null +++ b/packages/admin-client/src/features/tenants/application/useTenants.ts @@ -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({ + queryKey: TENANT_KEYS.stats, + queryFn: () => tenantsApi.getGlobalStats(), + refetchInterval: 60000, // Refresh every minute + }); +} + +// Tenant list query +export function useTenants(params: TenantQueryParams) { + return useQuery({ + queryKey: TENANT_KEYS.list(params), + queryFn: () => tenantsApi.listTenants(params), + }); +} + +// Tenant detail query +export function useTenantDetail(id: string, enabled = true) { + return useQuery({ + queryKey: TENANT_KEYS.detail(id), + queryFn: () => tenantsApi.getTenantDetail(id), + enabled: enabled && !!id, + }); +} + +// Tenant admins query +export function useTenantAdmins(tenantId: string, enabled = true) { + return useQuery({ + 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'; diff --git a/packages/admin-client/src/features/tenants/index.ts b/packages/admin-client/src/features/tenants/index.ts new file mode 100644 index 0000000..0331468 --- /dev/null +++ b/packages/admin-client/src/features/tenants/index.ts @@ -0,0 +1,2 @@ +export * from './application'; +export { TenantsPage } from './presentation/pages/TenantsPage'; diff --git a/packages/admin-client/src/features/tenants/infrastructure/index.ts b/packages/admin-client/src/features/tenants/infrastructure/index.ts new file mode 100644 index 0000000..690531d --- /dev/null +++ b/packages/admin-client/src/features/tenants/infrastructure/index.ts @@ -0,0 +1 @@ +export * from './tenants.api'; diff --git a/packages/admin-client/src/features/tenants/infrastructure/tenants.api.ts b/packages/admin-client/src/features/tenants/infrastructure/tenants.api.ts new file mode 100644 index 0000000..26fc8b9 --- /dev/null +++ b/packages/admin-client/src/features/tenants/infrastructure/tenants.api.ts @@ -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 => { + 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 => { + const response = await api.get(`${SUPER_ADMIN_BASE}/tenants`, { params }); + return response.data.data || response.data; + }, + + // Get tenant detail + getTenantDetail: async (id: string): Promise => { + const response = await api.get(`${SUPER_ADMIN_BASE}/tenants/${id}`); + return response.data.data || response.data; + }, + + // Create tenant + createTenant: async (dto: CreateTenantDto): Promise => { + 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 => { + 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 => { + 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 => { + const response = await api.post(`${SUPER_ADMIN_BASE}/tenants/${id}/activate`); + return response.data.data || response.data; + }, + + // Archive tenant + archiveTenant: async (id: string): Promise => { + 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 => { + 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 => { + const response = await api.post(`${SUPER_ADMIN_BASE}/tenants/${tenantId}/admins`, dto); + return response.data.data || response.data; + }, +}; diff --git a/packages/admin-client/src/features/tenants/presentation/pages/TenantsPage.tsx b/packages/admin-client/src/features/tenants/presentation/pages/TenantsPage.tsx new file mode 100644 index 0000000..09d085f --- /dev/null +++ b/packages/admin-client/src/features/tenants/presentation/pages/TenantsPage.tsx @@ -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 = { + ACTIVE: 'green', + SUSPENDED: 'orange', + ARCHIVED: 'default', +}; + +const STATUS_LABELS: Record = { + ACTIVE: '正常', + SUSPENDED: '已暂停', + ARCHIVED: '已归档', +}; + +const PLAN_COLORS: Record = { + FREE: 'default', + STANDARD: 'blue', + ENTERPRISE: 'gold', +}; + +const PLAN_LABELS: Record = { + 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(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 = [ + { + title: '租户名称', + key: 'name', + render: (_, record) => ( + + } + style={{ backgroundColor: record.plan === 'ENTERPRISE' ? '#faad14' : '#1890ff' }} + /> +
+
{record.name}
+
{record.slug}
+
+
+ ), + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 100, + render: (status: TenantStatus) => ( + {STATUS_LABELS[status]} + ), + }, + { + title: '套餐', + dataIndex: 'plan', + key: 'plan', + width: 100, + render: (plan: TenantPlan) => ( + {PLAN_LABELS[plan]} + ), + }, + { + title: '用户数', + key: 'users', + width: 120, + render: (_, record) => ( + + `${record.currentUserCount}/${record.maxUsers}`} + /> + + ), + }, + { + title: '对话数/月', + key: 'conversations', + width: 140, + render: (_, record) => ( + + `${record.currentConversationCount}/${record.maxConversationsPerMonth}`} + /> + + ), + }, + { + title: '创建时间', + dataIndex: 'createdAt', + key: 'createdAt', + width: 160, + render: (date) => dayjs(date).format('YYYY-MM-DD HH:mm'), + }, + { + title: '操作', + key: 'action', + width: 180, + render: (_, record) => ( + + showTenantDetail(record)}>详情 + openEditModal(record)}>编辑 + {record.status === 'ACTIVE' ? ( + handleSuspend(record.id)} + > + 暂停 + + ) : record.status === 'SUSPENDED' ? ( + handleActivate(record.id)} + > + 激活 + + ) : null} + + ), + }, + ]; + + return ( +
+
+ 租户管理 + + + + +
+ + {/* Statistics Cards */} + + + + + } + valueStyle={{ color: '#1890ff' }} + /> + + + + + + + } + valueStyle={{ color: '#52c41a' }} + /> + + + + + + + } + valueStyle={{ color: '#faad14' }} + /> + + + + + + + } + valueStyle={{ color: '#722ed1' }} + /> + + + + + + + } + valueStyle={{ color: '#13c2c2' }} + /> + + + + + + + } + valueStyle={{ color: '#eb2f96' }} + /> + + + + + + {/* Filters */} + + + setFilters((prev) => ({ ...prev, plan: value, page: 1 }))} + style={{ width: 120 }} + options={[ + { value: 'FREE', label: '免费版' }, + { value: 'STANDARD', label: '标准版' }, + { value: 'ENTERPRISE', label: '企业版' }, + ]} + /> + + + + {/* Tenants Table */} + + + `共 ${total} 条`, + }} + onChange={handleTableChange} + /> + + + + {/* Tenant Detail Drawer */} + { + setDetailDrawerOpen(false); + setSelectedTenantId(null); + }} + extra={ + + } + > + + {tenantDetail && ( +
+
+ } + style={{ backgroundColor: tenantDetail.plan === 'ENTERPRISE' ? '#faad14' : '#1890ff' }} + /> +
+ {tenantDetail.name} + + + {STATUS_LABELS[tenantDetail.status]} + + + {PLAN_LABELS[tenantDetail.plan]} + + +
+
+ + + + {tenantDetail.id} + + {tenantDetail.slug} + + {tenantDetail.currentUserCount} / {tenantDetail.maxUsers} + + + {tenantDetail.currentConversationCount} / {tenantDetail.maxConversationsPerMonth} + + + {(tenantDetail.currentStorageBytes / (1024 * 1024)).toFixed(2)} MB / {tenantDetail.maxStorageMb} MB + + + {tenantDetail.billingEmail || '-'} + + + {tenantDetail.billingName || '-'} + + + {tenantDetail.billingPhone || '-'} + + + {dayjs(tenantDetail.createdAt).format('YYYY-MM-DD HH:mm:ss')} + + {tenantDetail.suspendedAt && ( + + {dayjs(tenantDetail.suspendedAt).format('YYYY-MM-DD HH:mm:ss')} + + )} + + + {/* Tenant Admins */} + 管理员列表 + + {tenantAdmins && tenantAdmins.length > 0 ? ( + ( + + } />} + title={ + + {admin.name} + {admin.role} + {!admin.isActive && 已禁用} + + } + description={ +
+
用户名: {admin.username}
+ {admin.email &&
邮箱: {admin.email}
} + {admin.lastLoginAt && ( +
最后登录: {dayjs(admin.lastLoginAt).format('YYYY-MM-DD HH:mm')}
+ )} +
+ } + /> +
+ )} + /> + ) : ( +
暂无管理员
+ )} +
+
+ )} +
+
+ + {/* Create Tenant Modal */} + { + setCreateModalOpen(false); + createForm.resetFields(); + }} + footer={null} + width={600} + > +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Edit Tenant Modal */} + { + setEditModalOpen(false); + editForm.resetFields(); + }} + footer={null} + width={600} + > +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Create Admin Modal */} + { + setAdminModalOpen(false); + adminForm.resetFields(); + }} + footer={null} + > +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + +