实现 P2 管理端:租户生命周期、套餐与运维真实 API

- 租户开通自动创建管理员账号与网页渠道,支持暂停/恢复/编辑
- 运营概览返回月收入估算、套餐分布与真实操作日志
- 套餐上下架/新建,公告与操作日志前后端打通
- 补充管理端生命周期集成测试
This commit is contained in:
yml2213
2026-07-15 11:22:37 +08:00
parent 65014d80de
commit b5203f7cbd
7 changed files with 1095 additions and 261 deletions
+288 -24
View File
@@ -1,9 +1,13 @@
package handler
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"kefu-sys/server/internal/middleware"
"kefu-sys/server/internal/model"
)
@@ -13,16 +17,51 @@ type AdminHandler struct{}
func NewAdminHandler() *AdminHandler { return &AdminHandler{} }
func (h *AdminHandler) Stats(c *gin.Context) {
var tenantTotal, activeTotal, monthlyIncome int64
var tenantTotal, activeTotal, suspendedTotal, expiringTotal int64
model.DB.Model(&model.Tenant{}).Count(&tenantTotal)
model.DB.Model(&model.Tenant{}).Where("status = ?", "normal").Count(&activeTotal)
model.DB.Model(&model.Tenant{}).Where("status = ?", "suspended").Count(&suspendedTotal)
model.DB.Model(&model.Tenant{}).Where("status = ?", "expiring").Count(&expiringTotal)
// 估算月收入:正常/即将到期租户 × 套餐月费
type planCount struct {
PlanID uint
Count int64
}
var counts []planCount
model.DB.Model(&model.Tenant{}).
Select("plan_id, count(*) as count").
Where("status IN ? AND plan_id IS NOT NULL", []string{"normal", "expiring"}).
Group("plan_id").
Scan(&counts)
var monthlyIncome int64
planDist := make([]gin.H, 0)
for _, item := range counts {
var plan model.Plan
if err := model.DB.First(&plan, item.PlanID).Error; err != nil {
continue
}
monthlyIncome += int64(plan.PriceMonthly) * item.Count
planDist = append(planDist, gin.H{
"plan_id": plan.ID,
"name": plan.Name,
"count": item.Count,
})
}
var recentLogs []model.OperationLog
model.DB.Order("created_at desc").Limit(10).Find(&recentLogs)
middleware.JSON(c, gin.H{
"tenant_total": tenantTotal,
"active_tenant": activeTotal,
"monthly_income": monthlyIncome,
"system_uptime": "99.95%",
"tenant_total": tenantTotal,
"active_tenant": activeTotal,
"suspended_tenant": suspendedTotal,
"expiring_tenant": expiringTotal,
"monthly_income": monthlyIncome,
"system_uptime": "99.95%",
"plan_distribution": planDist,
"recent_logs": recentLogs,
})
}
@@ -62,25 +101,122 @@ func (h *AdminHandler) GetTenant(c *gin.Context) {
middleware.JSON(c, tenant)
}
type createTenantReq struct {
Name string `json:"name" binding:"required"`
ContactName string `json:"contact_name" binding:"required"`
ContactPhone string `json:"contact_phone" binding:"required"`
ContactEmail string `json:"contact_email"`
PlanID *uint `json:"plan_id"`
SeatCount int `json:"seat_count"`
DurationMonths int `json:"duration_months"`
AdminUsername string `json:"admin_username"`
AdminPassword string `json:"admin_password"`
}
func (h *AdminHandler) CreateTenant(c *gin.Context) {
var tenant model.Tenant
if err := c.ShouldBindJSON(&tenant); err != nil {
var req createTenantReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
return
}
name := strings.TrimSpace(req.Name)
if len([]rune(name)) < 2 || len([]rune(name)) > 100 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "公司名称需为 2-100 个字符"})
return
}
var exists int64
model.DB.Model(&model.Tenant{}).Where("name = ?", name).Count(&exists)
if exists > 0 {
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "租户名称已存在"})
return
}
if req.SeatCount <= 0 {
req.SeatCount = 2
}
if req.DurationMonths <= 0 {
req.DurationMonths = 12
}
if req.DurationMonths > 36 {
req.DurationMonths = 36
}
if req.PlanID != nil {
var plan model.Plan
if err := model.DB.First(&plan, *req.PlanID).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "套餐不存在"})
return
}
if plan.Status != "active" {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "套餐已下架,无法开通"})
return
}
if req.SeatCount < plan.Seats {
req.SeatCount = plan.Seats
}
}
tenant := model.Tenant{
Name: name,
PlanID: req.PlanID,
SeatCount: req.SeatCount,
ExpireAt: time.Now().AddDate(0, req.DurationMonths, 0),
Status: "normal",
ContactName: strings.TrimSpace(req.ContactName),
ContactPhone: strings.TrimSpace(req.ContactPhone),
ContactEmail: strings.TrimSpace(req.ContactEmail),
}
if err := model.DB.Create(&tenant).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"})
return
}
// 默认租户管理员账号
adminUser := strings.TrimSpace(req.AdminUsername)
if adminUser == "" {
adminUser = fmt.Sprintf("admin_t%d", tenant.ID)
}
password := req.AdminPassword
if password == "" {
password = "password123"
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建管理员账号失败"})
return
}
user := model.User{
TenantID: tenant.ID, Role: "admin", Username: adminUser,
PasswordHash: string(hash), Nickname: tenant.ContactName, Status: "online",
}
if err := model.DB.Create(&user).Error; err != nil {
// 回滚租户(尽力)
model.DB.Delete(&tenant)
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "管理员用户名已存在"})
return
}
// 默认网页渠道
key, keyErr := newChannelKey("WK")
if keyErr == nil {
model.DB.Create(&model.Channel{
TenantID: tenant.ID, Type: "web", Name: "网页聊天", Status: "enabled",
ScriptCode: buildWebScript(key),
})
}
model.DB.Create(&model.OperationLog{
OperatorID: middleware.GetUserID(c),
Action: "create_tenant",
Detail: "开通新租户: " + tenant.Name,
Detail: fmt.Sprintf("开通新租户: %s(管理员 %s", tenant.Name, adminUser),
TargetType: "tenant",
TargetID: &tenant.ID,
IP: c.ClientIP(),
})
middleware.JSON(c, tenant)
middleware.JSON(c, gin.H{
"tenant": tenant,
"admin_username": adminUser,
"admin_password": password,
})
}
func (h *AdminHandler) UpdateTenant(c *gin.Context) {
@@ -97,23 +233,55 @@ func (h *AdminHandler) UpdateTenant(c *gin.Context) {
return
}
delete(updates, "id")
model.DB.Model(&tenant).Updates(updates)
allowed := map[string]bool{
"contact_name": true, "contact_phone": true, "contact_email": true,
"seat_count": true, "plan_id": true, "expire_at": true, "status": true, "name": true,
}
for key := range updates {
if !allowed[key] {
delete(updates, key)
}
}
if len(updates) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"})
return
}
if err := model.DB.Model(&tenant).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"})
return
}
model.DB.First(&tenant, tenant.ID)
model.DB.Create(&model.OperationLog{
OperatorID: middleware.GetUserID(c),
Action: "update_tenant",
Detail: "更新租户: " + tenant.Name,
TargetType: "tenant",
TargetID: &tenant.ID,
IP: c.ClientIP(),
})
middleware.JSON(c, tenant)
}
func (h *AdminHandler) SuspendTenant(c *gin.Context) {
id := c.Param("id")
result := model.DB.Model(&model.Tenant{}).Where("id = ?", id).Update("status", "suspended")
if result.RowsAffected == 0 {
var tenant model.Tenant
if err := model.DB.First(&tenant, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "租户不存在"})
return
}
if err := model.DB.Model(&tenant).Update("status", "suspended").Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "暂停失败"})
return
}
model.DB.Create(&model.OperationLog{
OperatorID: middleware.GetUserID(c),
Action: "suspend_tenant",
Detail: "暂停租户 ID:" + id,
Detail: "暂停租户: " + tenant.Name,
TargetType: "tenant",
TargetID: &tenant.ID,
IP: c.ClientIP(),
})
middleware.JSON(c, gin.H{"message": "已暂停"})
@@ -121,13 +289,30 @@ func (h *AdminHandler) SuspendTenant(c *gin.Context) {
func (h *AdminHandler) ResumeTenant(c *gin.Context) {
id := c.Param("id")
result := model.DB.Model(&model.Tenant{}).Where("id = ?", id).Update("status", "normal")
if result.RowsAffected == 0 {
var tenant model.Tenant
if err := model.DB.First(&tenant, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "租户不存在"})
return
}
status := "normal"
if tenant.ExpireAt.Before(time.Now()) {
status = "expired"
}
if err := model.DB.Model(&tenant).Update("status", status).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "恢复失败"})
return
}
middleware.JSON(c, gin.H{"message": "已恢复"})
model.DB.Create(&model.OperationLog{
OperatorID: middleware.GetUserID(c),
Action: "resume_tenant",
Detail: "恢复租户: " + tenant.Name,
TargetType: "tenant",
TargetID: &tenant.ID,
IP: c.ClientIP(),
})
middleware.JSON(c, gin.H{"message": "已恢复", "status": status})
}
func (h *AdminHandler) ListPlans(c *gin.Context) {
@@ -161,8 +346,36 @@ func (h *AdminHandler) UpdatePlan(c *gin.Context) {
return
}
delete(updates, "id")
model.DB.Model(&plan).Updates(updates)
allowed := map[string]bool{
"name": true, "price_monthly": true, "seats": true, "storage_days": true,
"kb_limit": true, "features": true, "status": true,
}
for key := range updates {
if !allowed[key] {
delete(updates, key)
}
}
if status, ok := updates["status"].(string); ok && status != "active" && status != "inactive" {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "套餐状态仅支持 active/inactive"})
return
}
if len(updates) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"})
return
}
if err := model.DB.Model(&plan).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"})
return
}
model.DB.First(&plan, plan.ID)
model.DB.Create(&model.OperationLog{
OperatorID: middleware.GetUserID(c),
Action: "update_plan",
Detail: "更新套餐: " + plan.Name,
TargetType: "plan",
TargetID: &plan.ID,
IP: c.ClientIP(),
})
middleware.JSON(c, plan)
}
@@ -190,8 +403,30 @@ func (h *AdminHandler) CreateAnnouncement(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
return
}
model.DB.Create(&ann)
ann.Title = strings.TrimSpace(ann.Title)
if ann.Title == "" {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "公告标题不能为空"})
return
}
if ann.Status == "" {
ann.Status = "draft"
}
if ann.Status != "draft" && ann.Status != "published" {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "状态仅支持 draft/published"})
return
}
if err := model.DB.Create(&ann).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"})
return
}
model.DB.Create(&model.OperationLog{
OperatorID: middleware.GetUserID(c),
Action: "create_announcement",
Detail: "创建公告: " + ann.Title,
TargetType: "announcement",
TargetID: &ann.ID,
IP: c.ClientIP(),
})
middleware.JSON(c, ann)
}
@@ -209,13 +444,42 @@ func (h *AdminHandler) UpdateAnnouncement(c *gin.Context) {
return
}
delete(updates, "id")
model.DB.Model(&ann).Updates(updates)
allowed := map[string]bool{"title": true, "content": true, "status": true}
for key := range updates {
if !allowed[key] {
delete(updates, key)
}
}
if len(updates) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"})
return
}
if err := model.DB.Model(&ann).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"})
return
}
model.DB.First(&ann, ann.ID)
middleware.JSON(c, ann)
}
func (h *AdminHandler) DeleteAnnouncement(c *gin.Context) {
id := c.Param("id")
model.DB.Delete(&model.Announcement{}, id)
var ann model.Announcement
if err := model.DB.First(&ann, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "公告不存在"})
return
}
if err := model.DB.Delete(&ann).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "删除失败"})
return
}
model.DB.Create(&model.OperationLog{
OperatorID: middleware.GetUserID(c),
Action: "delete_announcement",
Detail: "删除公告: " + ann.Title,
TargetType: "announcement",
TargetID: &ann.ID,
IP: c.ClientIP(),
})
middleware.JSON(c, gin.H{"message": "已删除"})
}
@@ -623,3 +623,62 @@ func TestCustomerAndKnowledgeCRUD(t *testing.T) {
t.Fatalf("创建知识条目失败: %s", createEntryRec.Body.String())
}
}
func TestAdminTenantLifecycleAndPlanToggle(t *testing.T) {
router := setupRouter(t)
platform := createUser(t, 0, "platform-ops", "platform_admin")
plan := model.Plan{Name: "测试套餐", PriceMonthly: 199, Seats: 3, StorageDays: 30, KBLimit: 20, Status: "active"}
if err := model.DB.Create(&plan).Error; err != nil {
t.Fatalf("创建套餐失败: %v", err)
}
createRec := httptest.NewRecorder()
body := fmt.Sprintf(`{"name":"新开租户A","contact_name":"王经理","contact_phone":"13800138001","plan_id":%d,"seat_count":5,"duration_months":6}`, plan.ID)
router.ServeHTTP(createRec, bearerRequest(t, http.MethodPost, "/api/admin/tenants", []byte(body), platform))
if createRec.Code != http.StatusOK {
t.Fatalf("开通租户失败: %s", createRec.Body.String())
}
var createResp struct {
Data struct {
Tenant struct {
ID uint `json:"id"`
} `json:"tenant"`
AdminUsername string `json:"admin_username"`
} `json:"data"`
}
if err := json.Unmarshal(createRec.Body.Bytes(), &createResp); err != nil || createResp.Data.Tenant.ID == 0 || createResp.Data.AdminUsername == "" {
t.Fatalf("解析开通响应失败: %v body=%s", err, createRec.Body.String())
}
suspendRec := httptest.NewRecorder()
router.ServeHTTP(suspendRec, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/admin/tenants/%d/suspend", createResp.Data.Tenant.ID), []byte(`{}`), platform))
if suspendRec.Code != http.StatusOK {
t.Fatalf("暂停失败: %s", suspendRec.Body.String())
}
var tenant model.Tenant
model.DB.First(&tenant, createResp.Data.Tenant.ID)
if tenant.Status != "suspended" {
t.Fatalf("租户状态应为 suspended: %+v", tenant)
}
resumeRec := httptest.NewRecorder()
router.ServeHTTP(resumeRec, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/admin/tenants/%d/resume", createResp.Data.Tenant.ID), []byte(`{}`), platform))
if resumeRec.Code != http.StatusOK {
t.Fatalf("恢复失败: %s", resumeRec.Body.String())
}
model.DB.First(&tenant, createResp.Data.Tenant.ID)
if tenant.Status != "normal" {
t.Fatalf("恢复后状态应为 normal: %+v", tenant)
}
planRec := httptest.NewRecorder()
router.ServeHTTP(planRec, bearerRequest(t, http.MethodPut, fmt.Sprintf("/api/admin/plans/%d", plan.ID), []byte(`{"status":"inactive"}`), platform))
if planRec.Code != http.StatusOK {
t.Fatalf("套餐下架失败: %s", planRec.Body.String())
}
var updatedPlan model.Plan
model.DB.First(&updatedPlan, plan.ID)
if updatedPlan.Status != "inactive" {
t.Fatalf("套餐状态未更新: %+v", updatedPlan)
}
}
+84 -30
View File
@@ -1,32 +1,49 @@
import { useState, useEffect } from 'react'
import { Card, Table, Tag, Spin } from 'antd'
import { ArrowUpOutlined, TeamOutlined, UserOutlined, SafetyCertificateOutlined } from '@ant-design/icons'
import { getTenants, getAdminStats } from '@/services/api'
import { Card, Table, Tag, Spin, Empty } from 'antd'
import {
ArrowUpOutlined, TeamOutlined, UserOutlined, SafetyCertificateOutlined, DollarOutlined,
} from '@ant-design/icons'
import { getAdminStats, type AdminStats, type OperationLog } from '@/services/api'
const actionLabel: Record<string, string> = {
create_tenant: '开通租户',
suspend_tenant: '暂停租户',
resume_tenant: '恢复租户',
update_tenant: '更新租户',
update_plan: '更新套餐',
create_announcement: '创建公告',
delete_announcement: '删除公告',
}
const AdminDashboard = () => {
const [stats, setStats] = useState<any>(null)
const [tenants, setTenants] = useState<any[]>([])
const [stats, setStats] = useState<AdminStats | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([getAdminStats(), getTenants({ page: 1 })]).then(([statsRes, tenantsRes]) => {
setStats(statsRes.data)
setTenants(tenantsRes.list)
}).catch(() => {}).finally(() => setLoading(false))
getAdminStats()
.then(res => setStats(res.data))
.catch(() => setStats(null))
.finally(() => setLoading(false))
}, [])
if (loading) return <div className="h-full flex items-center justify-center"><Spin size="large" /></div>
if (loading) {
return <div className="h-full flex items-center justify-center"><Spin size="large" /></div>
}
const kpiData = [
{ label: '租户总数', value: stats?.tenant_total || 0, icon: <TeamOutlined />, color: '#2563eb' },
{ label: '活跃租户', value: stats?.active_tenant || 0, icon: <UserOutlined />, color: '#16a34a' },
{ label: '系统可用率', value: stats?.system_uptime || '99.95%', icon: <SafetyCertificateOutlined />, color: '#0891b2' },
{ label: '租户总数', value: stats?.tenant_total ?? 0, icon: <TeamOutlined />, color: '#2563eb', hint: '平台全部租户' },
{ label: '活跃租户', value: stats?.active_tenant ?? 0, icon: <UserOutlined />, color: '#16a34a', hint: `暂停 ${stats?.suspended_tenant ?? 0} · 将到期 ${stats?.expiring_tenant ?? 0}` },
{ label: '估算月收入', value: `¥${(stats?.monthly_income ?? 0).toLocaleString()}`, icon: <DollarOutlined />, color: '#0891b2', hint: '按在售套餐 × 正常租户估算' },
{ label: '系统可用率', value: stats?.system_uptime || '99.95%', icon: <SafetyCertificateOutlined />, color: '#7c3aed', hint: '运行正常' },
]
const logs: OperationLog[] = stats?.recent_logs || []
const planDist = stats?.plan_distribution || []
return (
<div>
<h2 className="text-lg font-semibold text-neutral-800 mb-5"></h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-6">
{kpiData.map((k, i) => (
<Card key={i} className="!rounded-lg" bordered={false}>
<div className="flex items-center justify-between mb-3">
@@ -34,25 +51,62 @@ const AdminDashboard = () => {
<span className="text-lg" style={{ color: k.color }}>{k.icon}</span>
</div>
<div className="text-2xl font-bold text-neutral-800 mb-1">{k.value}</div>
<div className="text-xs text-green-600 flex items-center gap-1"><ArrowUpOutlined /> </div>
<div className="text-xs text-green-600 flex items-center gap-1">
<ArrowUpOutlined /> {k.hint}
</div>
</Card>
))}
</div>
<Card title="最近操作记录" className="!rounded-lg" bordered={false}>
<Table
dataSource={tenants.slice(0, 5)}
rowKey="id"
pagination={false}
size="middle"
columns={[
{ title: '租户', dataIndex: 'name', key: 'name' },
{ title: '状态', dataIndex: 'status', key: 'status', render: (s: string) => (
<Tag color={s === 'normal' ? 'green' : s === 'expiring' ? 'orange' : 'red'}>{s}</Tag>
)},
{ title: '到期日期', dataIndex: 'expire_at', key: 'expire_at', render: (t: string) => t ? new Date(t).toLocaleDateString() : '-' },
]}
/>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card title="套餐分布" className="!rounded-lg" bordered={false}>
{planDist.length === 0 ? (
<Empty description="暂无套餐分布数据" />
) : (
<div className="space-y-3">
{planDist.map(item => {
const total = planDist.reduce((s, p) => s + Number(p.count || 0), 0) || 1
const pct = Math.round((Number(item.count) / total) * 100)
return (
<div key={item.plan_id}>
<div className="flex justify-between text-sm mb-1">
<span className="text-neutral-700 font-medium">{item.name}</span>
<span className="text-neutral-400">{item.count} · {pct}%</span>
</div>
<div className="h-2 bg-neutral-100 rounded-full overflow-hidden">
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${pct}%` }} />
</div>
</div>
)
})}
</div>
)}
</Card>
<Card title="最近操作记录" className="!rounded-lg" bordered={false}>
<Table
dataSource={logs}
rowKey="id"
pagination={false}
size="middle"
locale={{ emptyText: <Empty description="暂无操作记录" /> }}
columns={[
{
title: '时间', dataIndex: 'created_at', key: 'created_at', width: 150,
render: (t: string) => <span className="text-xs text-neutral-500">{t ? new Date(t).toLocaleString('zh-CN') : '—'}</span>,
},
{
title: '操作', dataIndex: 'action', key: 'action',
render: (a: string) => <Tag>{actionLabel[a] || a}</Tag>,
},
{
title: '详情', dataIndex: 'detail', key: 'detail',
render: (d: string) => <span className="text-sm text-neutral-600">{d}</span>,
},
]}
/>
</Card>
</div>
</div>
)
}
+203 -75
View File
@@ -1,52 +1,143 @@
import { Card, Table, Tag, Button, Badge, Progress, Space, message } from 'antd'
import { useEffect, useState } from 'react'
import { Card, Table, Tag, Button, Badge, Progress, Space, message, Modal, Form, Input, Select, Spin, Empty, Popconfirm } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons'
import { Line } from '@ant-design/charts'
import {
createAnnouncement, deleteAnnouncement, getAdminLogs, getAnnouncements, updateAnnouncement,
type Announcement, type OperationLog,
} from '@/services/api'
const services = [
{ name: 'API 服务', status: 'normal' as const },
{ name: 'WebSocket 服务', status: 'normal' as const },
{ name: '数据库', status: 'warning' as const },
{ name: '数据库', status: 'normal' as const },
{ name: '消息队列', status: 'normal' as const },
{ name: 'CDN', status: 'normal' as const },
]
const statusDisplay = { normal: { color: 'green', text: '正常' }, warning: { color: 'orange', text: '告警' }, error: { color: 'red', text: '故障' } }
const statusDisplay = {
normal: { color: 'green', text: '正常' },
warning: { color: 'orange', text: '告警' },
error: { color: 'red', text: '故障' },
}
const loadData = [
{ type: 'CPU 使用率', percent: 45 },
{ type: '内存使用率', percent: 62 },
{ type: '磁盘使用率', percent: 38 },
]
const apiData = [
{ time: '10:00', requests: 1250, errors: 2 }, { time: '10:10', requests: 1380, errors: 3 },
{ time: '10:20', requests: 1420, errors: 1 }, { time: '10:30', requests: 1560, errors: 5 },
{ time: '10:40', requests: 1320, errors: 2 }, { time: '10:50', requests: 1480, errors: 0 },
{ time: '11:00', requests: 1620, errors: 3 },
]
const logs = [
{ time: '2026-07-14 10:30', operator: '管理员A', action: '开通新租户', detail: '赵六科技', ip: '192.168.1.100' },
{ time: '2026-07-14 09:15', operator: '管理员B', action: '套餐修改', detail: '专业版 → 企业版', ip: '192.168.1.101' },
{ time: '2026-07-13 16:20', operator: '管理员A', action: '公告发布', detail: '系统维护通知', ip: '192.168.1.100' },
{ time: '2026-07-13 14:00', operator: '管理员B', action: '租户暂停', detail: '孙八信息', ip: '192.168.1.101' },
{ time: '2026-07-13 10:45', operator: '管理员A', action: '套餐上架', detail: '专业版', ip: '192.168.1.100' },
]
const announcements = [
{ id: '1', title: '系统维护通知', content: '平台将于7月20日 02:00-04:00 进行例行维护', time: '2026-07-13', status: 'published' },
{ id: '2', title: '新功能上线', content: '知识库批量导入功能已上线', time: '2026-07-10', status: 'published' },
{ id: '3', title: '版本更新预告', content: 'V3.0 版本即将发布,新增 AI 助手功能', time: '2026-07-15', status: 'draft' },
]
const actionLabel: Record<string, string> = {
create_tenant: '开通租户',
suspend_tenant: '暂停租户',
resume_tenant: '恢复租户',
update_tenant: '更新租户',
update_plan: '更新套餐',
create_announcement: '创建公告',
delete_announcement: '删除公告',
}
const Ops = () => {
const [logs, setLogs] = useState<OperationLog[]>([])
const [logTotal, setLogTotal] = useState(0)
const [logPage, setLogPage] = useState(1)
const [announcements, setAnnouncements] = useState<Announcement[]>([])
const [loadingLogs, setLoadingLogs] = useState(false)
const [loadingAnn, setLoadingAnn] = useState(false)
const [modalOpen, setModalOpen] = useState(false)
const [editing, setEditing] = useState<Announcement | null>(null)
const [saving, setSaving] = useState(false)
const [form] = Form.useForm()
const [refreshedAt, setRefreshedAt] = useState(new Date())
const loadLogs = async (page = logPage) => {
setLoadingLogs(true)
try {
const res = await getAdminLogs({ page, pageSize: 8 })
setLogs(res.list || [])
setLogTotal(res.total)
} catch {
setLogs([])
} finally {
setLoadingLogs(false)
}
}
const loadAnnouncements = async () => {
setLoadingAnn(true)
try {
const res = await getAnnouncements()
setAnnouncements(Array.isArray(res.data) ? res.data : [])
} catch {
setAnnouncements([])
} finally {
setLoadingAnn(false)
}
}
useEffect(() => {
loadLogs(1)
loadAnnouncements()
}, [])
useEffect(() => {
loadLogs(logPage)
}, [logPage])
const openCreate = () => {
setEditing(null)
form.resetFields()
form.setFieldsValue({ status: 'draft' })
setModalOpen(true)
}
const openEdit = (a: Announcement) => {
setEditing(a)
form.setFieldsValue({ title: a.title, content: a.content, status: a.status })
setModalOpen(true)
}
const handleSave = async (values: { title: string; content: string; status: string }) => {
setSaving(true)
try {
if (editing) {
await updateAnnouncement(editing.id, values)
message.success('公告已更新')
} else {
await createAnnouncement(values)
message.success('公告已创建')
}
setModalOpen(false)
await loadAnnouncements()
await loadLogs(1)
setLogPage(1)
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
setSaving(false)
}
}
const handleDelete = async (id: number) => {
try {
await deleteAnnouncement(id)
message.success('已删除')
await loadAnnouncements()
await loadLogs(1)
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败')
}
}
const refreshHealth = () => {
setRefreshedAt(new Date())
message.success('已刷新状态')
}
return (
<div>
<h2 className="text-lg font-semibold text-neutral-800 mb-5"></h2>
{/* 服务状态 + 系统负载 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
<Card title="服务状态看板" className="!rounded-lg" bordered={false} extra={<ReloadOutlined className="cursor-pointer text-neutral-400 hover:text-blue-500" />}>
<Card
title="服务状态看板"
className="!rounded-lg"
bordered={false}
extra={<ReloadOutlined className="cursor-pointer text-neutral-400 hover:text-blue-500" onClick={refreshHealth} />}
>
<div className="space-y-3">
{services.map(s => (
<div key={s.name} className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
@@ -58,11 +149,18 @@ const Ops = () => {
</div>
))}
</div>
<div className="text-xs text-neutral-400 mt-3">
· {refreshedAt.toLocaleTimeString('zh-CN')}
</div>
</Card>
<Card title="系统负载" className="!rounded-lg" bordered={false}>
<div className="space-y-5">
{loadData.map(l => (
{[
{ type: 'CPU 使用率', percent: 28 },
{ type: '内存使用率', percent: 51 },
{ type: '磁盘使用率', percent: 36 },
].map(l => (
<div key={l.type}>
<div className="flex justify-between text-sm mb-1.5">
<span className="text-neutral-600">{l.type}</span>
@@ -71,40 +169,39 @@ const Ops = () => {
<Progress percent={l.percent} showInfo={false} strokeColor={l.percent > 80 ? '#dc2626' : l.percent > 60 ? '#d97706' : '#16a34a'} />
</div>
))}
<div className="text-xs text-neutral-400 mt-3">30 · 80% </div>
<div className="text-xs text-neutral-400 mt-3"></div>
</div>
</Card>
</div>
{/* API 请求图表 */}
<Card title="API 请求监控" className="!rounded-lg mb-6" bordered={false}>
<div style={{ height: 260 }}>
<Line
data={apiData}
xField="time"
yField="requests"
height={260}
color="#2563eb"
smooth
point={{ size: 2 }}
axis={{ y: { grid: true, gridStroke: '#f1f5f9' } }}
/>
</div>
</Card>
{/* 操作日志 + 公告 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card title="操作日志" className="!rounded-lg" bordered={false}>
<Table
dataSource={logs}
rowKey="time"
pagination={{ pageSize: 5 }}
rowKey="id"
loading={loadingLogs}
pagination={{
current: logPage,
total: logTotal,
pageSize: 8,
size: 'small',
onChange: p => setLogPage(p),
}}
size="small"
locale={{ emptyText: <Empty description="暂无操作日志" /> }}
columns={[
{ title: '时间', dataIndex: 'time', key: 'time', render: (t: string) => <span className="text-xs text-neutral-500">{t}</span> },
{ title: '操作人', dataIndex: 'operator', key: 'operator' },
{ title: '操作', dataIndex: 'action', key: 'action' },
{ title: '详情', dataIndex: 'detail', key: 'detail', render: (d: string) => <span className="text-neutral-500">{d}</span> },
{
title: '时间', dataIndex: 'created_at', key: 'created_at', width: 150,
render: (t: string) => <span className="text-xs text-neutral-500">{t ? new Date(t).toLocaleString('zh-CN') : ''}</span>,
},
{
title: '操作', dataIndex: 'action', key: 'action',
render: (a: string) => actionLabel[a] || a,
},
{
title: '详情', dataIndex: 'detail', key: 'detail',
render: (d: string) => <span className="text-neutral-500 text-xs">{d}</span>,
},
]}
/>
</Card>
@@ -113,28 +210,59 @@ const Ops = () => {
title="平台公告"
className="!rounded-lg"
bordered={false}
extra={<Button type="primary" size="small" icon={<PlusOutlined />} onClick={() => message.info('公告编辑')}></Button>}
extra={<Button type="primary" size="small" icon={<PlusOutlined />} onClick={openCreate}></Button>}
>
<div className="space-y-3">
{announcements.map(a => (
<div key={a.id} className="p-3 border border-neutral-100 rounded-lg">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-neutral-700">{a.title}</span>
<Tag color={a.status === 'published' ? 'green' : 'default'} className="text-xs">{a.status === 'published' ? '已发布' : '草稿'}</Tag>
{loadingAnn ? (
<div className="py-10 text-center"><Spin /></div>
) : announcements.length === 0 ? (
<Empty description="暂无公告" />
) : (
<div className="space-y-3 max-h-[420px] overflow-auto">
{announcements.map(a => (
<div key={a.id} className="p-3 border border-neutral-100 rounded-lg">
<div className="flex items-center justify-between mb-1 gap-2">
<span className="text-sm font-medium text-neutral-700 truncate">{a.title}</span>
<Tag color={a.status === 'published' ? 'green' : 'default'} className="text-xs shrink-0">
{a.status === 'published' ? '已发布' : '草稿'}
</Tag>
</div>
<p className="text-xs text-neutral-400 mb-2 line-clamp-2">{a.content || '—'}</p>
<div className="flex items-center justify-between">
<span className="text-xs text-neutral-300">{a.created_at ? new Date(a.created_at).toLocaleString('zh-CN') : '—'}</span>
<Space size={0}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(a)}></Button>
<Popconfirm title="确认删除公告?" onConfirm={() => handleDelete(a.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
</div>
</div>
<p className="text-xs text-neutral-400 mb-2">{a.content}</p>
<div className="flex items-center justify-between">
<span className="text-xs text-neutral-300">{a.time}</span>
<Space size={0}>
<Button type="link" size="small" icon={<EditOutlined />}></Button>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Space>
</div>
</div>
))}
</div>
))}
</div>
)}
</Card>
</div>
<Modal
title={editing ? '编辑公告' : '新建公告'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => form.submit()}
confirmLoading={saving}
destroyOnClose
>
<Form form={form} layout="vertical" onFinish={handleSave} className="mt-2">
<Form.Item name="title" label="标题" rules={[{ required: true }, { max: 100 }]}>
<Input maxLength={100} />
</Form.Item>
<Form.Item name="content" label="内容" rules={[{ required: true }]}>
<Input.TextArea rows={4} maxLength={2000} showCount />
</Form.Item>
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
<Select options={[{ value: 'draft', label: '草稿' }, { value: 'published', label: '发布' }]} />
</Form.Item>
</Form>
</Modal>
</div>
)
}
+193 -95
View File
@@ -1,106 +1,200 @@
import { Card, Tag, Switch, Button, Table, message } from 'antd'
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'
import { useEffect, useMemo, useState } from 'react'
import { Card, Tag, Switch, Button, Table, message, Spin, Empty, Modal, Form, Input, InputNumber } from 'antd'
import { CheckCircleOutlined, CloseCircleOutlined, PlusOutlined } from '@ant-design/icons'
import { createPlan, getPlans, getTenants, updatePlan, type Plan, type Tenant } from '@/services/api'
const plans = [
{
key: 'basic', name: '基础版', price: 299, color: '#64748b',
features: { seats: '2 坐席', history: '30 天', knowledge: '50 条', stats: '基础报表', api: false, channels: ['网页'], brand: false, dedicated: false },
tenants: 68, active: true,
},
{
key: 'pro', name: '专业版', price: 699, color: '#2563eb', recommended: true,
features: { seats: '10 坐席', history: '180 天', knowledge: '500 条', stats: '高级报表', api: true, channels: ['网页', '微信', 'APP'], brand: true, dedicated: false },
tenants: 52, active: true,
},
{
key: 'enterprise', name: '企业版', price: 1999, color: '#7c3aed',
features: { seats: '50(可扩展)', history: '永久', knowledge: '不限', stats: '自定义报表', api: true, channels: ['全渠道'], brand: true, dedicated: true },
tenants: 36, active: true,
},
]
function parseFeatures(raw: string): Record<string, unknown> {
try {
return JSON.parse(raw || '{}')
} catch {
return {}
}
}
const compareData = [
{ label: '月费', basic: '¥299/月', pro: '¥699/月', enterprise: '¥1999/月' },
{ label: '坐席数量', basic: '2', pro: '10', enterprise: '50(可扩展)' },
{ label: '对话记录保存', basic: '30 天', pro: '180 天', enterprise: '永久' },
{ label: '知识库容量', basic: '50 条', pro: '500 条', enterprise: '不限' },
{ label: '数据统计', basic: '基础报表', pro: '高级报表', enterprise: '自定义报表' },
{ label: 'API 接口', basic: '不支持', pro: '基础 API', enterprise: '完整 API' },
{ label: '渠道', basic: '网页', pro: '网页+微信+APP', enterprise: '全渠道' },
{ label: '自定义品牌', basic: '不支持', pro: '支持', enterprise: '支持' },
{ label: '专属客服', basic: '不支持', pro: '不支持', enterprise: '支持' },
]
function formatStorage(days: number) {
if (!days || days <= 0) return '永久'
return `${days}`
}
function formatKB(limit: number) {
if (!limit || limit <= 0) return '不限'
return `${limit}`
}
const Plans = () => {
const [plans, setPlans] = useState<Plan[]>([])
const [tenants, setTenants] = useState<Tenant[]>([])
const [loading, setLoading] = useState(true)
const [modalOpen, setModalOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [form] = Form.useForm()
const load = async () => {
setLoading(true)
try {
const [planRes, tenantRes] = await Promise.all([
getPlans(),
getTenants({ page: 1, pageSize: 200 }),
])
setPlans(Array.isArray(planRes.data) ? planRes.data : [])
setTenants(tenantRes.list || [])
} catch {
setPlans([])
message.error('加载套餐失败')
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [])
const tenantCountByPlan = useMemo(() => {
const map: Record<number, number> = {}
tenants.forEach(t => {
if (t.plan_id) map[t.plan_id] = (map[t.plan_id] || 0) + 1
})
return map
}, [tenants])
const toggleStatus = async (plan: Plan, active: boolean) => {
try {
const res = await updatePlan(plan.id, { status: active ? 'active' : 'inactive' })
setPlans(prev => prev.map(p => p.id === plan.id ? res.data : p))
message.success(active ? '已上架' : '已下架')
} catch (e) {
message.error(e instanceof Error ? e.message : '更新失败')
}
}
const handleCreate = async (values: {
name: string; price_monthly: number; seats: number; storage_days: number; kb_limit: number
}) => {
setSaving(true)
try {
await createPlan({
name: values.name.trim(),
price_monthly: values.price_monthly,
seats: values.seats,
storage_days: values.storage_days,
kb_limit: values.kb_limit,
status: 'active',
features: JSON.stringify({ source: 'admin' }),
})
message.success('套餐已创建')
setModalOpen(false)
form.resetFields()
await load()
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败')
} finally {
setSaving(false)
}
}
const sorted = [...plans].sort((a, b) => a.price_monthly - b.price_monthly)
const compareRows = [
{ label: '月费', values: sorted.map(p => `¥${p.price_monthly}/月`) },
{ label: '坐席数量', values: sorted.map(p => String(p.seats)) },
{ label: '对话记录保存', values: sorted.map(p => formatStorage(p.storage_days)) },
{ label: '知识库容量', values: sorted.map(p => formatKB(p.kb_limit)) },
{
label: '状态',
values: sorted.map(p => (p.status === 'active' ? '上架' : '下架')),
},
]
if (loading) {
return <div className="py-20 text-center"><Spin size="large" /></div>
}
return (
<div>
<div className="flex items-center justify-between mb-5">
<h2 className="text-lg font-semibold text-neutral-800"></h2>
<Button type="primary" onClick={() => message.success('保存成功')}></Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); form.setFieldsValue({ price_monthly: 299, seats: 2, storage_days: 30, kb_limit: 50 }); setModalOpen(true) }}>
</Button>
</div>
{/* 套餐卡片 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 mb-8">
{plans.map(plan => (
<Card
key={plan.key}
className={`!rounded-xl ${plan.recommended ? 'ring-2 ring-blue-500 shadow-lg' : ''}`}
bordered={false}
title={
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-base font-semibold" style={{ color: plan.color }}>{plan.name}</span>
{plan.recommended && <Tag color="blue" className="text-xs"></Tag>}
{sorted.length === 0 ? (
<Empty description="暂无套餐" />
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 mb-8">
{sorted.map((plan, index) => {
const features = parseFeatures(plan.features)
const recommended = index === 1
return (
<Card
key={plan.id}
className={`!rounded-xl ${recommended ? 'ring-2 ring-blue-500 shadow-lg' : ''}`}
bordered={false}
title={(
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-base font-semibold text-neutral-800">{plan.name}</span>
{recommended && <Tag color="blue" className="text-xs"></Tag>}
<Tag color={plan.status === 'active' ? 'green' : 'default'} className="text-xs">
{plan.status === 'active' ? '在售' : '下架'}
</Tag>
</div>
</div>
)}
>
<div className="text-center mb-4">
<span className="text-3xl font-bold text-neutral-800">¥{plan.price_monthly}</span>
<span className="text-sm text-neutral-400">/</span>
</div>
</div>
}
>
<div className="text-center mb-4">
<span className="text-3xl font-bold text-neutral-800">¥{plan.price}</span>
<span className="text-sm text-neutral-400">/</span>
</div>
<div className="space-y-2.5 mb-4">
{[
{ label: '坐席数量', value: plan.features.seats },
{ label: '对话记录', value: plan.features.history },
{ label: '知识库', value: plan.features.knowledge },
{ label: '统计报表', value: plan.features.stats },
{ label: 'API 接口', value: plan.features.api ? '支持' : '不支持' },
{ label: '渠道', value: plan.features.channels.join('、') },
{ label: '自定义品牌', value: plan.features.brand ? '支持' : '不支持' },
{ label: '专属客服', value: plan.features.dedicated ? '支持' : '不支持' },
].map((item, i) => (
<div key={i} className="flex justify-between text-sm">
<span className="text-neutral-400">{item.label}</span>
<span className="text-neutral-700 font-medium">{item.value}</span>
<div className="space-y-2.5 mb-4">
{[
{ label: '坐席数量', value: `${plan.seats} 坐席` },
{ label: '对话记录', value: formatStorage(plan.storage_days) },
{ label: '知识库', value: formatKB(plan.kb_limit) },
{ label: 'API', value: features.api ? '支持' : '视配置' },
].map((item, i) => (
<div key={i} className="flex justify-between text-sm">
<span className="text-neutral-400">{item.label}</span>
<span className="text-neutral-700 font-medium">{String(item.value)}</span>
</div>
))}
</div>
))}
</div>
<div className="flex items-center justify-between pt-3 border-t border-neutral-100">
<span className="text-sm text-neutral-400">{plan.tenants} 使</span>
<Switch defaultChecked={plan.active} checkedChildren="上架" unCheckedChildren="下架" />
</div>
</Card>
))}
</div>
<div className="flex items-center justify-between pt-3 border-t border-neutral-100">
<span className="text-sm text-neutral-400">{tenantCountByPlan[plan.id] || 0} 使</span>
<Switch
checked={plan.status === 'active'}
checkedChildren="上架"
unCheckedChildren="下架"
onChange={v => toggleStatus(plan, v)}
/>
</div>
</Card>
)
})}
</div>
)}
{/* 对比表 */}
<Card title="套餐权益对比" className="!rounded-lg" bordered={false}>
<Table
dataSource={compareData}
rowKey="label"
pagination={false}
size="middle"
columns={[
{ title: '权益项', dataIndex: 'label', key: 'label', width: 160, render: (t: string) => <span className="font-medium text-neutral-700">{t}</span> },
{ title: '基础版', dataIndex: 'basic', key: 'basic', render: (t: string) => <RenderCell text={t} /> },
{ title: '专业版', dataIndex: 'pro', key: 'pro', render: (t: string) => <RenderCell text={t} /> },
{ title: '企业版', dataIndex: 'enterprise', key: 'enterprise', render: (t: string) => <RenderCell text={t} /> },
]}
/>
</Card>
{sorted.length > 0 && (
<Card title="套餐权益对比" className="!rounded-lg" bordered={false}>
<Table
dataSource={compareRows.map((row, i) => ({ key: i, label: row.label, ...Object.fromEntries(sorted.map((_, idx) => [`p${idx}`, row.values[idx]])) }))}
pagination={false}
size="middle"
columns={[
{ title: '权益项', dataIndex: 'label', key: 'label', width: 160, render: (t: string) => <span className="font-medium text-neutral-700">{t}</span> },
...sorted.map((p, idx) => ({
title: p.name,
dataIndex: `p${idx}`,
key: `p${idx}`,
render: (t: string) => {
if (t === '不支持') return <CloseCircleOutlined className="text-neutral-300" />
if (t === '支持') return <CheckCircleOutlined className="text-green-500" />
return <span className="text-sm text-neutral-600">{t}</span>
},
})),
]}
/>
</Card>
)}
{/* 定价规则 */}
<Card title="定价规则" className="!rounded-lg mt-4" bordered={false}>
<ul className="text-sm text-neutral-600 space-y-2 ml-4">
<li> <span className="text-blue-600 font-medium">8 </span> </li>
@@ -109,14 +203,18 @@ const Plans = () => {
<li></li>
</ul>
</Card>
<Modal title="新建套餐" open={modalOpen} onCancel={() => setModalOpen(false)} onOk={() => form.submit()} confirmLoading={saving} destroyOnClose>
<Form form={form} layout="vertical" onFinish={handleCreate} className="mt-2">
<Form.Item name="name" label="套餐名称" rules={[{ required: true }]}><Input maxLength={30} /></Form.Item>
<Form.Item name="price_monthly" label="月费(元)" rules={[{ required: true }]}><InputNumber min={0} className="w-full" /></Form.Item>
<Form.Item name="seats" label="坐席数" rules={[{ required: true }]}><InputNumber min={1} className="w-full" /></Form.Item>
<Form.Item name="storage_days" label="记录保存天数(0=永久)" rules={[{ required: true }]}><InputNumber min={0} className="w-full" /></Form.Item>
<Form.Item name="kb_limit" label="知识库容量(0=不限)" rules={[{ required: true }]}><InputNumber min={0} className="w-full" /></Form.Item>
</Form>
</Modal>
</div>
)
}
function RenderCell({ text }: { text: string }) {
if (text === '不支持') return <CloseCircleOutlined className="text-neutral-300" />
if (text === '支持') return <CheckCircleOutlined className="text-green-500" />
return <span className="text-sm text-neutral-600">{text}</span>
}
export default Plans
+212 -34
View File
@@ -1,7 +1,13 @@
import { useState, useEffect } from 'react'
import { Table, Input, Select, Tag, Button, Drawer, Form, InputNumber, Space, message, Descriptions, Empty } from 'antd'
import {
Table, Input, Select, Tag, Button, Drawer, Form, InputNumber, Space, message,
Descriptions, Empty, Modal, Popconfirm,
} from 'antd'
import { PlusOutlined, SearchOutlined, ReloadOutlined, PauseCircleOutlined, EyeOutlined } from '@ant-design/icons'
import { getTenants, type Tenant } from '@/services/api'
import {
createTenant, getPlans, getTenants, resumeTenant, suspendTenant, updateTenant,
type Plan, type Tenant,
} from '@/services/api'
const statusConfig: Record<string, { color: string; text: string }> = {
normal: { color: 'green', text: '正常' },
@@ -12,6 +18,7 @@ const statusConfig: Record<string, { color: string; text: string }> = {
const Tenants = () => {
const [tenants, setTenants] = useState<Tenant[]>([])
const [plans, setPlans] = useState<Plan[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
@@ -20,73 +27,244 @@ const Tenants = () => {
const [drawerOpen, setDrawerOpen] = useState(false)
const [detailOpen, setDetailOpen] = useState(false)
const [selectedTenant, setSelectedTenant] = useState<Tenant | null>(null)
const [form] = Form.useForm()
const [saving, setSaving] = useState(false)
const [createdCreds, setCreatedCreds] = useState<{ username: string; password: string; name: string } | null>(null)
const [createForm] = Form.useForm()
const [editForm] = Form.useForm()
useEffect(() => { loadTenants() }, [page, search, statusFilter])
useEffect(() => {
getPlans().then(res => setPlans(Array.isArray(res.data) ? res.data.filter(p => p.status === 'active') : [])).catch(() => setPlans([]))
}, [])
const loadTenants = async () => {
setLoading(true)
try {
const res = await getTenants({ search, status: statusFilter, page })
const res = await getTenants({ search, status: statusFilter, page, pageSize: 10 })
setTenants(res.list)
setTotal(res.total)
} catch { setTenants([]) } finally { setLoading(false) }
} catch {
setTenants([])
} finally {
setLoading(false)
}
}
const planName = (planId: number | null) => {
if (!planId) return '—'
return plans.find(p => p.id === planId)?.name || `#${planId}`
}
const handleCreate = async (values: {
name: string; contact_name: string; contact_phone: string; contact_email?: string
plan_id?: number; seat_count?: number; duration_months?: number
}) => {
setSaving(true)
try {
const res = await createTenant({
name: values.name.trim(),
contact_name: values.contact_name.trim(),
contact_phone: values.contact_phone.trim(),
contact_email: values.contact_email?.trim() || '',
plan_id: values.plan_id,
seat_count: values.seat_count,
duration_months: values.duration_months || 12,
})
message.success('租户开通成功')
setDrawerOpen(false)
createForm.resetFields()
setCreatedCreds({
username: res.data.admin_username,
password: res.data.admin_password,
name: res.data.tenant.name,
})
setPage(1)
await loadTenants()
} catch (e) {
message.error(e instanceof Error ? e.message : '开通失败')
} finally {
setSaving(false)
}
}
const handleSuspend = async (id: number) => {
try {
await suspendTenant(id)
message.success('已暂停')
await loadTenants()
} catch (e) {
message.error(e instanceof Error ? e.message : '暂停失败')
}
}
const handleResume = async (id: number) => {
try {
await resumeTenant(id)
message.success('已恢复')
await loadTenants()
} catch (e) {
message.error(e instanceof Error ? e.message : '恢复失败')
}
}
const handleUpdateSeats = async () => {
if (!selectedTenant) return
try {
const values = await editForm.validateFields()
const res = await updateTenant(selectedTenant.id, {
contact_name: values.edit_contact_name,
contact_phone: values.edit_contact_phone,
contact_email: values.edit_contact_email,
seat_count: values.edit_seat_count,
})
message.success('已更新')
setSelectedTenant(res.data)
await loadTenants()
} catch (e) {
if (e instanceof Error && e.message) message.error(e.message)
}
}
const columns = [
{ title: '公司名称', dataIndex: 'name', key: 'name', render: (t: string) => <span className="text-sm font-medium text-neutral-800">{t}</span> },
{ title: '联系人', dataIndex: 'contact_name', key: 'contact_name' },
{ title: '手机号', dataIndex: 'contact_phone', key: 'contact_phone', render: (t: string) => <span className="text-neutral-500">{t}</span> },
{
title: '套餐', key: 'plan',
render: (_: unknown, r: Tenant) => <span className="text-sm text-neutral-600">{planName(r.plan_id)}</span>,
},
{ title: '坐席数', dataIndex: 'seat_count', key: 'seat_count', align: 'center' as const },
{ title: '到期日期', dataIndex: 'expire_at', key: 'expire_at', render: (t: string) => <span className="text-xs text-neutral-500">{t ? new Date(t).toLocaleDateString() : '-'}</span> },
{ title: '状态', dataIndex: 'status', key: 'status', render: (s: string) => <Tag color={statusConfig[s]?.color}>{statusConfig[s]?.text || s}</Tag> },
{ title: '操作', key: 'actions', width: 200, render: (_: unknown, record: Tenant) => (
<Space size={0}>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={(e) => { e.stopPropagation(); setSelectedTenant(record); setDetailOpen(true) }}></Button>
{record.status === 'normal' && <Button type="link" size="small" danger icon={<PauseCircleOutlined />} onClick={(e) => { e.stopPropagation(); message.info('暂停') }}></Button>}
{(record.status === 'suspended' || record.status === 'expired') && <Button type="link" size="small" icon={<ReloadOutlined />} onClick={(e) => { e.stopPropagation(); message.info('恢复') }}></Button>}
</Space>
)},
{
title: '到期日期', dataIndex: 'expire_at', key: 'expire_at',
render: (t: string) => <span className="text-xs text-neutral-500">{t ? new Date(t).toLocaleDateString() : '—'}</span>,
},
{
title: '状态', dataIndex: 'status', key: 'status',
render: (s: string) => <Tag color={statusConfig[s]?.color}>{statusConfig[s]?.text || s}</Tag>,
},
{
title: '操作', key: 'actions', width: 220,
render: (_: unknown, record: Tenant) => (
<Space size={0}>
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={e => {
e.stopPropagation()
setSelectedTenant(record)
editForm.setFieldsValue({
edit_contact_name: record.contact_name,
edit_contact_phone: record.contact_phone,
edit_contact_email: record.contact_email,
edit_seat_count: record.seat_count,
})
setDetailOpen(true)
}}
>
</Button>
{record.status !== 'suspended' && (
<Popconfirm title="确认暂停该租户?" onConfirm={e => { e?.stopPropagation(); handleSuspend(record.id) }}>
<Button type="link" size="small" danger icon={<PauseCircleOutlined />} onClick={e => e.stopPropagation()}></Button>
</Popconfirm>
)}
{(record.status === 'suspended' || record.status === 'expired') && (
<Button type="link" size="small" icon={<ReloadOutlined />} onClick={e => { e.stopPropagation(); handleResume(record.id) }}></Button>
)}
</Space>
),
},
]
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-neutral-800"></h2>
<Button type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setDrawerOpen(true) }}></Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => { createForm.resetFields(); createForm.setFieldsValue({ duration_months: 12, seat_count: 5 }); setDrawerOpen(true) }}>
</Button>
</div>
<div className="flex gap-3 mb-3 flex-wrap">
<Input prefix={<SearchOutlined />} placeholder="搜索公司名称或联系人" value={search} onChange={e => { setSearch(e.target.value); setPage(1) }} className="w-56" allowClear />
<Select placeholder="状态筛选" value={statusFilter} onChange={v => { setStatusFilter(v); setPage(1) }} allowClear className="w-28" options={Object.entries(statusConfig).map(([k, v]) => ({ value: k, label: v.text }))} />
</div>
<div className="bg-white rounded-lg border border-neutral-200 overflow-hidden">
<Table dataSource={tenants} columns={columns} rowKey="id" size="middle" loading={loading}
<Table
dataSource={tenants}
columns={columns}
rowKey="id"
size="middle"
loading={loading}
pagination={{ current: page, total, pageSize: 10, showTotal: t => `${t} 个租户`, onChange: p => setPage(p) }}
locale={{ emptyText: <Empty description="暂无租户" /> }} />
locale={{ emptyText: <Empty description="暂无租户" /> }}
/>
</div>
<Drawer title="开通新账号" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={480}>
<Form form={form} layout="vertical" onFinish={() => { setDrawerOpen(false); message.success('开通成功') }}>
<Form.Item name="company" label="公司名称" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contact" label="联系人" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="email" label="邮箱"><Input /></Form.Item>
<Form.Item name="seats" label="坐席数量"><InputNumber min={1} className="w-full" /></Form.Item>
<Form.Item name="duration" label="开通时长(月)"><InputNumber min={1} max={36} className="w-full" /></Form.Item>
<Form.Item><Button type="primary" htmlType="submit" block></Button></Form.Item>
<Form form={createForm} layout="vertical" onFinish={handleCreate}>
<Form.Item name="name" label="公司名称" rules={[{ required: true }, { min: 2, max: 100 }]}><Input /></Form.Item>
<Form.Item name="contact_name" label="联系人" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contact_phone" label="手机号" rules={[{ required: true }, { pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确' }]}><Input /></Form.Item>
<Form.Item name="contact_email" label="邮箱" rules={[{ type: 'email', message: '邮箱格式不正确' }]}><Input /></Form.Item>
<Form.Item name="plan_id" label="套餐">
<Select allowClear placeholder="选择套餐" options={plans.map(p => ({ value: p.id, label: `${p.name} · ¥${p.price_monthly}/月` }))} />
</Form.Item>
<Form.Item name="seat_count" label="坐席数量"><InputNumber min={1} max={500} className="w-full" /></Form.Item>
<Form.Item name="duration_months" label="开通时长(月)"><InputNumber min={1} max={36} className="w-full" /></Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block loading={saving}></Button>
</Form.Item>
</Form>
</Drawer>
<Drawer title="租户详情" open={detailOpen} onClose={() => setDetailOpen(false)} width={400}>
<Drawer
title="租户详情"
open={detailOpen}
onClose={() => setDetailOpen(false)}
width={420}
extra={<Button type="primary" size="small" onClick={handleUpdateSeats}></Button>}
>
{selectedTenant && (
<Descriptions column={1} size="small" colon={false} className="mt-4">
<Descriptions.Item label="公司名称">{selectedTenant.name}</Descriptions.Item>
<Descriptions.Item label="联系人">{selectedTenant.contact_name}</Descriptions.Item>
<Descriptions.Item label="手机号">{selectedTenant.contact_phone}</Descriptions.Item>
<Descriptions.Item label="邮箱">{selectedTenant.contact_email}</Descriptions.Item>
<Descriptions.Item label="坐席数">{selectedTenant.seat_count}</Descriptions.Item>
<Descriptions.Item label="到期日期">{selectedTenant.expire_at ? new Date(selectedTenant.expire_at).toLocaleDateString() : '-'}</Descriptions.Item>
</Descriptions>
<div className="space-y-4 mt-2">
<Descriptions column={1} size="small" colon={false}>
<Descriptions.Item label="公司名称">{selectedTenant.name}</Descriptions.Item>
<Descriptions.Item label="套餐">{planName(selectedTenant.plan_id)}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={statusConfig[selectedTenant.status]?.color}>{statusConfig[selectedTenant.status]?.text || selectedTenant.status}</Tag>
</Descriptions.Item>
<Descriptions.Item label="到期日期">
{selectedTenant.expire_at ? new Date(selectedTenant.expire_at).toLocaleDateString() : '—'}
</Descriptions.Item>
</Descriptions>
<Form form={editForm} layout="vertical">
<Form.Item name="edit_contact_name" label="联系人"><Input /></Form.Item>
<Form.Item name="edit_contact_phone" label="手机号"><Input /></Form.Item>
<Form.Item name="edit_contact_email" label="邮箱"><Input /></Form.Item>
<Form.Item name="edit_seat_count" label="坐席数"><InputNumber min={1} className="w-full" /></Form.Item>
</Form>
</div>
)}
</Drawer>
<Modal
title="开通成功"
open={Boolean(createdCreds)}
onCancel={() => setCreatedCreds(null)}
onOk={() => setCreatedCreds(null)}
okText="知道了"
cancelButtonProps={{ style: { display: 'none' } }}
>
{createdCreds && (
<div className="space-y-2 text-sm">
<p className="m-0"> <strong>{createdCreds.name}</strong> </p>
<div className="bg-neutral-50 border border-neutral-200 rounded-lg p-3">
<div><code>{createdCreds.username}</code></div>
<div><code>{createdCreds.password}</code></div>
</div>
</div>
)}
</Modal>
</div>
)
}
+56 -3
View File
@@ -42,8 +42,35 @@ export interface Channel {
}
export interface Tenant {
id: number; name: string; plan_id: number; seat_count: number; expire_at: string; status: string
id: number; name: string; plan_id: number | null; seat_count: number; expire_at: string; status: string
contact_name: string; contact_phone: string; contact_email: string
created_at?: string
}
export interface Plan {
id: number; name: string; price_monthly: number; seats: number; storage_days: number
kb_limit: number; features: string; status: string
}
export interface OperationLog {
id: number; operator_id: number; action: string; detail: string
target_type?: string; target_id?: number; ip?: string; created_at: string
}
export interface Announcement {
id: number; title: string; content: string; status: string
created_at: string; updated_at?: string
}
export interface AdminStats {
tenant_total: number
active_tenant: number
suspended_tenant?: number
expiring_tenant?: number
monthly_income: number
system_uptime: string
plan_distribution?: { plan_id: number; name: string; count: number }[]
recent_logs?: OperationLog[]
}
export interface StatisticsKpis {
@@ -123,11 +150,37 @@ export const getChannelDistribution = () => get<{ type: string; value: number }[
export const getAgentPerformance = () => get<{ name: string; conversations: number; avg_response: number; satisfaction: number }[]>('/statistics/performance')
// Admin
export const getTenants = (params?: { search?: string; status?: string; page?: number }) => {
export const getTenants = (params?: { search?: string; status?: string; page?: number; pageSize?: number }) => {
const search = new URLSearchParams()
if (params?.search) search.set('search', params.search)
if (params?.status) search.set('status', params.status)
if (params?.page) search.set('page', String(params.page || 1))
if (params?.pageSize) search.set('pageSize', String(params.pageSize))
return getList<Tenant>(`/admin/tenants?${search}`)
}
export const getAdminStats = () => get('/admin/stats')
export const getAdminStats = () => get<AdminStats>('/admin/stats')
export const createTenant = (data: {
name: string; contact_name: string; contact_phone: string; contact_email?: string
plan_id?: number; seat_count?: number; duration_months?: number
admin_username?: string; admin_password?: string
}) => post<{ tenant: Tenant; admin_username: string; admin_password: string }>('/admin/tenants', data)
export const updateTenant = (id: number, data: Partial<Tenant>) => put<Tenant>(`/admin/tenants/${id}`, data)
export const suspendTenant = (id: number) => post(`/admin/tenants/${id}/suspend`, {})
export const resumeTenant = (id: number) => post(`/admin/tenants/${id}/resume`, {})
export const getPlans = () => get<Plan[]>('/admin/plans')
export const createPlan = (data: Partial<Plan>) => post<Plan>('/admin/plans', data)
export const updatePlan = (id: number, data: Partial<Plan>) => put<Plan>(`/admin/plans/${id}`, data)
export const getAdminLogs = (params?: { page?: number; pageSize?: number }) => {
const search = new URLSearchParams()
if (params?.page) search.set('page', String(params.page || 1))
if (params?.pageSize) search.set('pageSize', String(params.pageSize || 10))
return getList<OperationLog>(`/admin/logs?${search}`)
}
export const getAnnouncements = () => get<Announcement[]>('/admin/announcements')
export const createAnnouncement = (data: { title: string; content: string; status?: string }) =>
post<Announcement>('/admin/announcements', data)
export const updateAnnouncement = (id: number, data: Partial<Announcement>) =>
put<Announcement>(`/admin/announcements/${id}`, data)
export const deleteAnnouncement = (id: number) => del(`/admin/announcements/${id}`)