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 { UsersPage } from './features/users';
|
||||||
import { ConversationsPage } from './features/conversations';
|
import { ConversationsPage } from './features/conversations';
|
||||||
import { SettingsPage } from './features/settings';
|
import { SettingsPage } from './features/settings';
|
||||||
|
import { TenantsPage } from './features/tenants';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -33,6 +34,7 @@ function App() {
|
||||||
<Route path="audit" element={<AuditPage />} />
|
<Route path="audit" element={<AuditPage />} />
|
||||||
<Route path="users" element={<UsersPage />} />
|
<Route path="users" element={<UsersPage />} />
|
||||||
<Route path="conversations" element={<ConversationsPage />} />
|
<Route path="conversations" element={<ConversationsPage />} />
|
||||||
|
<Route path="tenants" element={<TenantsPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
</Route>
|
</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,
|
AuditOutlined,
|
||||||
LineChartOutlined,
|
LineChartOutlined,
|
||||||
MessageOutlined,
|
MessageOutlined,
|
||||||
|
ClusterOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
|
||||||
|
|
@ -70,6 +71,11 @@ const menuItems: MenuProps['items'] = [
|
||||||
icon: <MessageOutlined />,
|
icon: <MessageOutlined />,
|
||||||
label: '对话管理',
|
label: '对话管理',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: '/tenants',
|
||||||
|
icon: <ClusterOutlined />,
|
||||||
|
label: '租户管理',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: '/settings',
|
key: '/settings',
|
||||||
icon: <SettingOutlined />,
|
icon: <SettingOutlined />,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue