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:
hailin 2026-01-26 07:03:14 -08:00
parent e1e9ba1a77
commit 481c13b67d
8 changed files with 1174 additions and 0 deletions

View File

@ -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>

View File

@ -0,0 +1 @@
export * from './useTenants';

View File

@ -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';

View File

@ -0,0 +1,2 @@
export * from './application';
export { TenantsPage } from './presentation/pages/TenantsPage';

View File

@ -0,0 +1 @@
export * from './tenants.api';

View File

@ -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;
},
};

View File

@ -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>
);
}

View File

@ -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 />,