实现 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 package handler
import ( import (
"fmt"
"net/http" "net/http"
"strings"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"kefu-sys/server/internal/middleware" "kefu-sys/server/internal/middleware"
"kefu-sys/server/internal/model" "kefu-sys/server/internal/model"
) )
@@ -13,16 +17,51 @@ type AdminHandler struct{}
func NewAdminHandler() *AdminHandler { return &AdminHandler{} } func NewAdminHandler() *AdminHandler { return &AdminHandler{} }
func (h *AdminHandler) Stats(c *gin.Context) { 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{}).Count(&tenantTotal)
model.DB.Model(&model.Tenant{}).Where("status = ?", "normal").Count(&activeTotal) 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{ middleware.JSON(c, gin.H{
"tenant_total": tenantTotal, "tenant_total": tenantTotal,
"active_tenant": activeTotal, "active_tenant": activeTotal,
"monthly_income": monthlyIncome, "suspended_tenant": suspendedTotal,
"system_uptime": "99.95%", "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) 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) { func (h *AdminHandler) CreateTenant(c *gin.Context) {
var tenant model.Tenant var req createTenantReq
if err := c.ShouldBindJSON(&tenant); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
return 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 { if err := model.DB.Create(&tenant).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"}) c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"})
return 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{ model.DB.Create(&model.OperationLog{
OperatorID: middleware.GetUserID(c), OperatorID: middleware.GetUserID(c),
Action: "create_tenant", 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) { func (h *AdminHandler) UpdateTenant(c *gin.Context) {
@@ -97,23 +233,55 @@ func (h *AdminHandler) UpdateTenant(c *gin.Context) {
return return
} }
delete(updates, "id") allowed := map[string]bool{
model.DB.Model(&tenant).Updates(updates) "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) middleware.JSON(c, tenant)
} }
func (h *AdminHandler) SuspendTenant(c *gin.Context) { func (h *AdminHandler) SuspendTenant(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
result := model.DB.Model(&model.Tenant{}).Where("id = ?", id).Update("status", "suspended") var tenant model.Tenant
if result.RowsAffected == 0 { if err := model.DB.First(&tenant, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "租户不存在"}) c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "租户不存在"})
return 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{ model.DB.Create(&model.OperationLog{
OperatorID: middleware.GetUserID(c), OperatorID: middleware.GetUserID(c),
Action: "suspend_tenant", Action: "suspend_tenant",
Detail: "暂停租户 ID:" + id, Detail: "暂停租户: " + tenant.Name,
TargetType: "tenant",
TargetID: &tenant.ID,
IP: c.ClientIP(),
}) })
middleware.JSON(c, gin.H{"message": "已暂停"}) middleware.JSON(c, gin.H{"message": "已暂停"})
@@ -121,13 +289,30 @@ func (h *AdminHandler) SuspendTenant(c *gin.Context) {
func (h *AdminHandler) ResumeTenant(c *gin.Context) { func (h *AdminHandler) ResumeTenant(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
result := model.DB.Model(&model.Tenant{}).Where("id = ?", id).Update("status", "normal") var tenant model.Tenant
if result.RowsAffected == 0 { if err := model.DB.First(&tenant, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "租户不存在"}) c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "租户不存在"})
return 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) { func (h *AdminHandler) ListPlans(c *gin.Context) {
@@ -161,8 +346,36 @@ func (h *AdminHandler) UpdatePlan(c *gin.Context) {
return return
} }
delete(updates, "id") allowed := map[string]bool{
model.DB.Model(&plan).Updates(updates) "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) middleware.JSON(c, plan)
} }
@@ -190,8 +403,30 @@ func (h *AdminHandler) CreateAnnouncement(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
return return
} }
ann.Title = strings.TrimSpace(ann.Title)
model.DB.Create(&ann) 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) middleware.JSON(c, ann)
} }
@@ -209,13 +444,42 @@ func (h *AdminHandler) UpdateAnnouncement(c *gin.Context) {
return return
} }
delete(updates, "id") allowed := map[string]bool{"title": true, "content": true, "status": true}
model.DB.Model(&ann).Updates(updates) 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) middleware.JSON(c, ann)
} }
func (h *AdminHandler) DeleteAnnouncement(c *gin.Context) { func (h *AdminHandler) DeleteAnnouncement(c *gin.Context) {
id := c.Param("id") 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": "已删除"}) middleware.JSON(c, gin.H{"message": "已删除"})
} }
@@ -623,3 +623,62 @@ func TestCustomerAndKnowledgeCRUD(t *testing.T) {
t.Fatalf("创建知识条目失败: %s", createEntryRec.Body.String()) 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 { useState, useEffect } from 'react'
import { Card, Table, Tag, Spin } from 'antd' import { Card, Table, Tag, Spin, Empty } from 'antd'
import { ArrowUpOutlined, TeamOutlined, UserOutlined, SafetyCertificateOutlined } from '@ant-design/icons' import {
import { getTenants, getAdminStats } from '@/services/api' 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 AdminDashboard = () => {
const [stats, setStats] = useState<any>(null) const [stats, setStats] = useState<AdminStats | null>(null)
const [tenants, setTenants] = useState<any[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
Promise.all([getAdminStats(), getTenants({ page: 1 })]).then(([statsRes, tenantsRes]) => { getAdminStats()
setStats(statsRes.data) .then(res => setStats(res.data))
setTenants(tenantsRes.list) .catch(() => setStats(null))
}).catch(() => {}).finally(() => setLoading(false)) .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 = [ const kpiData = [
{ label: '租户总数', value: stats?.tenant_total || 0, icon: <TeamOutlined />, color: '#2563eb' }, { label: '租户总数', value: stats?.tenant_total ?? 0, icon: <TeamOutlined />, color: '#2563eb', hint: '平台全部租户' },
{ label: '活跃租户', value: stats?.active_tenant || 0, icon: <UserOutlined />, color: '#16a34a' }, { label: '活跃租户', value: stats?.active_tenant ?? 0, icon: <UserOutlined />, color: '#16a34a', hint: `暂停 ${stats?.suspended_tenant ?? 0} · 将到期 ${stats?.expiring_tenant ?? 0}` },
{ label: '系统可用率', value: stats?.system_uptime || '99.95%', icon: <SafetyCertificateOutlined />, color: '#0891b2' }, { 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 ( return (
<div> <div>
<h2 className="text-lg font-semibold text-neutral-800 mb-5"></h2> <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) => ( {kpiData.map((k, i) => (
<Card key={i} className="!rounded-lg" bordered={false}> <Card key={i} className="!rounded-lg" bordered={false}>
<div className="flex items-center justify-between mb-3"> <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> <span className="text-lg" style={{ color: k.color }}>{k.icon}</span>
</div> </div>
<div className="text-2xl font-bold text-neutral-800 mb-1">{k.value}</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> </Card>
))} ))}
</div> </div>
<Card title="最近操作记录" className="!rounded-lg" bordered={false}>
<Table <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
dataSource={tenants.slice(0, 5)} <Card title="套餐分布" className="!rounded-lg" bordered={false}>
rowKey="id" {planDist.length === 0 ? (
pagination={false} <Empty description="暂无套餐分布数据" />
size="middle" ) : (
columns={[ <div className="space-y-3">
{ title: '租户', dataIndex: 'name', key: 'name' }, {planDist.map(item => {
{ title: '状态', dataIndex: 'status', key: 'status', render: (s: string) => ( const total = planDist.reduce((s, p) => s + Number(p.count || 0), 0) || 1
<Tag color={s === 'normal' ? 'green' : s === 'expiring' ? 'orange' : 'red'}>{s}</Tag> const pct = Math.round((Number(item.count) / total) * 100)
)}, return (
{ title: '到期日期', dataIndex: 'expire_at', key: 'expire_at', render: (t: string) => t ? new Date(t).toLocaleDateString() : '-' }, <div key={item.plan_id}>
]} <div className="flex justify-between text-sm mb-1">
/> <span className="text-neutral-700 font-medium">{item.name}</span>
</Card> <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> </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 { 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 = [ const services = [
{ name: 'API 服务', status: 'normal' as const }, { name: 'API 服务', status: 'normal' as const },
{ name: 'WebSocket 服务', 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: '消息队列', status: 'normal' as const },
{ name: 'CDN', 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 = [ const actionLabel: Record<string, string> = {
{ type: 'CPU 使用率', percent: 45 }, create_tenant: '开通租户',
{ type: '内存使用率', percent: 62 }, suspend_tenant: '暂停租户',
{ type: '磁盘使用率', percent: 38 }, resume_tenant: '恢复租户',
] update_tenant: '更新租户',
update_plan: '更新套餐',
const apiData = [ create_announcement: '创建公告',
{ time: '10:00', requests: 1250, errors: 2 }, { time: '10:10', requests: 1380, errors: 3 }, delete_announcement: '删除公告',
{ 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 Ops = () => { 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 ( return (
<div> <div>
<h2 className="text-lg font-semibold text-neutral-800 mb-5"></h2> <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"> <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"> <div className="space-y-3">
{services.map(s => ( {services.map(s => (
<div key={s.name} className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg"> <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> </div>
<div className="text-xs text-neutral-400 mt-3">
· {refreshedAt.toLocaleTimeString('zh-CN')}
</div>
</Card> </Card>
<Card title="系统负载" className="!rounded-lg" bordered={false}> <Card title="系统负载" className="!rounded-lg" bordered={false}>
<div className="space-y-5"> <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 key={l.type}>
<div className="flex justify-between text-sm mb-1.5"> <div className="flex justify-between text-sm mb-1.5">
<span className="text-neutral-600">{l.type}</span> <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'} /> <Progress percent={l.percent} showInfo={false} strokeColor={l.percent > 80 ? '#dc2626' : l.percent > 60 ? '#d97706' : '#16a34a'} />
</div> </div>
))} ))}
<div className="text-xs text-neutral-400 mt-3">30 · 80% </div> <div className="text-xs text-neutral-400 mt-3"></div>
</div> </div>
</Card> </Card>
</div> </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"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card title="操作日志" className="!rounded-lg" bordered={false}> <Card title="操作日志" className="!rounded-lg" bordered={false}>
<Table <Table
dataSource={logs} dataSource={logs}
rowKey="time" rowKey="id"
pagination={{ pageSize: 5 }} loading={loadingLogs}
pagination={{
current: logPage,
total: logTotal,
pageSize: 8,
size: 'small',
onChange: p => setLogPage(p),
}}
size="small" size="small"
locale={{ emptyText: <Empty description="暂无操作日志" /> }}
columns={[ 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: 'created_at', key: 'created_at', width: 150,
{ title: '操作', dataIndex: 'action', key: 'action' }, render: (t: string) => <span className="text-xs text-neutral-500">{t ? new Date(t).toLocaleString('zh-CN') : ''}</span>,
{ title: '详情', dataIndex: 'detail', key: 'detail', render: (d: string) => <span className="text-neutral-500">{d}</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> </Card>
@@ -113,28 +210,59 @@ const Ops = () => {
title="平台公告" title="平台公告"
className="!rounded-lg" className="!rounded-lg"
bordered={false} 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"> {loadingAnn ? (
{announcements.map(a => ( <div className="py-10 text-center"><Spin /></div>
<div key={a.id} className="p-3 border border-neutral-100 rounded-lg"> ) : announcements.length === 0 ? (
<div className="flex items-center justify-between mb-1"> <Empty description="暂无公告" />
<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> <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> </div>
<p className="text-xs text-neutral-400 mb-2">{a.content}</p> ))}
<div className="flex items-center justify-between"> </div>
<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>
</Card> </Card>
</div> </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> </div>
) )
} }
+193 -95
View File
@@ -1,106 +1,200 @@
import { Card, Tag, Switch, Button, Table, message } from 'antd' import { useEffect, useMemo, useState } from 'react'
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons' 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 = [ function parseFeatures(raw: string): Record<string, unknown> {
{ try {
key: 'basic', name: '基础版', price: 299, color: '#64748b', return JSON.parse(raw || '{}')
features: { seats: '2 坐席', history: '30 天', knowledge: '50 条', stats: '基础报表', api: false, channels: ['网页'], brand: false, dedicated: false }, } catch {
tenants: 68, active: true, return {}
}, }
{ }
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,
},
]
const compareData = [ function formatStorage(days: number) {
{ label: '月费', basic: '¥299/月', pro: '¥699/月', enterprise: '¥1999/月' }, if (!days || days <= 0) return '永久'
{ label: '坐席数量', basic: '2', pro: '10', enterprise: '50(可扩展)' }, return `${days}`
{ label: '对话记录保存', basic: '30 天', pro: '180 天', enterprise: '永久' }, }
{ label: '知识库容量', basic: '50 条', pro: '500 条', enterprise: '不限' },
{ label: '数据统计', basic: '基础报表', pro: '高级报表', enterprise: '自定义报表' }, function formatKB(limit: number) {
{ label: 'API 接口', basic: '不支持', pro: '基础 API', enterprise: '完整 API' }, if (!limit || limit <= 0) return '不限'
{ label: '渠道', basic: '网页', pro: '网页+微信+APP', enterprise: '全渠道' }, return `${limit}`
{ label: '自定义品牌', basic: '不支持', pro: '支持', enterprise: '支持' }, }
{ label: '专属客服', basic: '不支持', pro: '不支持', enterprise: '支持' },
]
const Plans = () => { 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 ( return (
<div> <div>
<div className="flex items-center justify-between mb-5"> <div className="flex items-center justify-between mb-5">
<h2 className="text-lg font-semibold text-neutral-800"></h2> <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>
{/* 套餐卡片 */} {sorted.length === 0 ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 mb-8"> <Empty description="暂无套餐" />
{plans.map(plan => ( ) : (
<Card <div className="grid grid-cols-1 md:grid-cols-3 gap-5 mb-8">
key={plan.key} {sorted.map((plan, index) => {
className={`!rounded-xl ${plan.recommended ? 'ring-2 ring-blue-500 shadow-lg' : ''}`} const features = parseFeatures(plan.features)
bordered={false} const recommended = index === 1
title={ return (
<div className="flex items-center justify-between"> <Card
<div className="flex items-center gap-2"> key={plan.id}
<span className="text-base font-semibold" style={{ color: plan.color }}>{plan.name}</span> className={`!rounded-xl ${recommended ? 'ring-2 ring-blue-500 shadow-lg' : ''}`}
{plan.recommended && <Tag color="blue" className="text-xs"></Tag>} 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> <div className="space-y-2.5 mb-4">
} {[
> { label: '坐席数量', value: `${plan.seats} 坐席` },
<div className="text-center mb-4"> { label: '对话记录', value: formatStorage(plan.storage_days) },
<span className="text-3xl font-bold text-neutral-800">¥{plan.price}</span> { label: '知识库', value: formatKB(plan.kb_limit) },
<span className="text-sm text-neutral-400">/</span> { label: 'API', value: features.api ? '支持' : '视配置' },
</div> ].map((item, i) => (
<div className="space-y-2.5 mb-4"> <div key={i} className="flex justify-between text-sm">
{[ <span className="text-neutral-400">{item.label}</span>
{ label: '坐席数量', value: plan.features.seats }, <span className="text-neutral-700 font-medium">{String(item.value)}</span>
{ label: '对话记录', value: plan.features.history }, </div>
{ 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> </div>
))} <div className="flex items-center justify-between pt-3 border-t border-neutral-100">
</div> <span className="text-sm text-neutral-400">{tenantCountByPlan[plan.id] || 0} 使</span>
<div className="flex items-center justify-between pt-3 border-t border-neutral-100"> <Switch
<span className="text-sm text-neutral-400">{plan.tenants} 使</span> checked={plan.status === 'active'}
<Switch defaultChecked={plan.active} checkedChildren="上架" unCheckedChildren="下架" /> checkedChildren="上架"
</div> unCheckedChildren="下架"
</Card> onChange={v => toggleStatus(plan, v)}
))} />
</div> </div>
</Card>
)
})}
</div>
)}
{/* 对比表 */} {sorted.length > 0 && (
<Card title="套餐权益对比" className="!rounded-lg" bordered={false}> <Card title="套餐权益对比" className="!rounded-lg" bordered={false}>
<Table <Table
dataSource={compareData} dataSource={compareRows.map((row, i) => ({ key: i, label: row.label, ...Object.fromEntries(sorted.map((_, idx) => [`p${idx}`, row.values[idx]])) }))}
rowKey="label" pagination={false}
pagination={false} size="middle"
size="middle" columns={[
columns={[ { title: '权益项', dataIndex: 'label', key: 'label', width: 160, render: (t: string) => <span className="font-medium text-neutral-700">{t}</span> },
{ title: '权益项', dataIndex: 'label', key: 'label', width: 160, render: (t: string) => <span className="font-medium text-neutral-700">{t}</span> }, ...sorted.map((p, idx) => ({
{ title: '基础版', dataIndex: 'basic', key: 'basic', render: (t: string) => <RenderCell text={t} /> }, title: p.name,
{ title: '专业版', dataIndex: 'pro', key: 'pro', render: (t: string) => <RenderCell text={t} /> }, dataIndex: `p${idx}`,
{ title: '企业版', dataIndex: 'enterprise', key: 'enterprise', render: (t: string) => <RenderCell text={t} /> }, key: `p${idx}`,
]} render: (t: string) => {
/> if (t === '不支持') return <CloseCircleOutlined className="text-neutral-300" />
</Card> 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}> <Card title="定价规则" className="!rounded-lg mt-4" bordered={false}>
<ul className="text-sm text-neutral-600 space-y-2 ml-4"> <ul className="text-sm text-neutral-600 space-y-2 ml-4">
<li> <span className="text-blue-600 font-medium">8 </span> </li> <li> <span className="text-blue-600 font-medium">8 </span> </li>
@@ -109,14 +203,18 @@ const Plans = () => {
<li></li> <li></li>
</ul> </ul>
</Card> </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> </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 export default Plans
+212 -34
View File
@@ -1,7 +1,13 @@
import { useState, useEffect } from 'react' 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 { 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 }> = { const statusConfig: Record<string, { color: string; text: string }> = {
normal: { color: 'green', text: '正常' }, normal: { color: 'green', text: '正常' },
@@ -12,6 +18,7 @@ const statusConfig: Record<string, { color: string; text: string }> = {
const Tenants = () => { const Tenants = () => {
const [tenants, setTenants] = useState<Tenant[]>([]) const [tenants, setTenants] = useState<Tenant[]>([])
const [plans, setPlans] = useState<Plan[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
@@ -20,73 +27,244 @@ const Tenants = () => {
const [drawerOpen, setDrawerOpen] = useState(false) const [drawerOpen, setDrawerOpen] = useState(false)
const [detailOpen, setDetailOpen] = useState(false) const [detailOpen, setDetailOpen] = useState(false)
const [selectedTenant, setSelectedTenant] = useState<Tenant | null>(null) 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(() => { 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 () => { const loadTenants = async () => {
setLoading(true) setLoading(true)
try { try {
const res = await getTenants({ search, status: statusFilter, page }) const res = await getTenants({ search, status: statusFilter, page, pageSize: 10 })
setTenants(res.list) setTenants(res.list)
setTotal(res.total) 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 = [ const columns = [
{ title: '公司名称', dataIndex: 'name', key: 'name', render: (t: string) => <span className="text-sm font-medium text-neutral-800">{t}</span> }, { 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_name', key: 'contact_name' },
{ title: '手机号', dataIndex: 'contact_phone', key: 'contact_phone', render: (t: string) => <span className="text-neutral-500">{t}</span> }, { 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: '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: '到期日期', dataIndex: 'expire_at', key: 'expire_at',
{ title: '操作', key: 'actions', width: 200, render: (_: unknown, record: Tenant) => ( render: (t: string) => <span className="text-xs text-neutral-500">{t ? new Date(t).toLocaleDateString() : '—'}</span>,
<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>} title: '状态', dataIndex: 'status', key: 'status',
{(record.status === 'suspended' || record.status === 'expired') && <Button type="link" size="small" icon={<ReloadOutlined />} onClick={(e) => { e.stopPropagation(); message.info('恢复') }}></Button>} render: (s: string) => <Tag color={statusConfig[s]?.color}>{statusConfig[s]?.text || s}</Tag>,
</Space> },
)}, {
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 ( return (
<div> <div>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-neutral-800"></h2> <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>
<div className="flex gap-3 mb-3 flex-wrap"> <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 /> <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 }))} /> <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>
<div className="bg-white rounded-lg border border-neutral-200 overflow-hidden"> <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) }} pagination={{ current: page, total, pageSize: 10, showTotal: t => `${t} 个租户`, onChange: p => setPage(p) }}
locale={{ emptyText: <Empty description="暂无租户" /> }} /> locale={{ emptyText: <Empty description="暂无租户" /> }}
/>
</div> </div>
<Drawer title="开通新账号" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={480}> <Drawer title="开通新账号" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={480}>
<Form form={form} layout="vertical" onFinish={() => { setDrawerOpen(false); message.success('开通成功') }}> <Form form={createForm} layout="vertical" onFinish={handleCreate}>
<Form.Item name="company" label="公司名称" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="name" label="公司名称" rules={[{ required: true }, { min: 2, max: 100 }]}><Input /></Form.Item>
<Form.Item name="contact" label="联系人" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="contact_name" label="联系人" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" 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="email" label="邮箱"><Input /></Form.Item> <Form.Item name="contact_email" label="邮箱" rules={[{ type: 'email', message: '邮箱格式不正确' }]}><Input /></Form.Item>
<Form.Item name="seats" label="坐席数量"><InputNumber min={1} className="w-full" /></Form.Item> <Form.Item name="plan_id" label="套餐">
<Form.Item name="duration" label="开通时长(月)"><InputNumber min={1} max={36} className="w-full" /></Form.Item> <Select allowClear placeholder="选择套餐" options={plans.map(p => ({ value: p.id, label: `${p.name} · ¥${p.price_monthly}/月` }))} />
<Form.Item><Button type="primary" htmlType="submit" block></Button></Form.Item> </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> </Form>
</Drawer> </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 && ( {selectedTenant && (
<Descriptions column={1} size="small" colon={false} className="mt-4"> <div className="space-y-4 mt-2">
<Descriptions.Item label="公司名称">{selectedTenant.name}</Descriptions.Item> <Descriptions column={1} size="small" colon={false}>
<Descriptions.Item label="联系人">{selectedTenant.contact_name}</Descriptions.Item> <Descriptions.Item label="公司名称">{selectedTenant.name}</Descriptions.Item>
<Descriptions.Item label="手机号">{selectedTenant.contact_phone}</Descriptions.Item> <Descriptions.Item label="套餐">{planName(selectedTenant.plan_id)}</Descriptions.Item>
<Descriptions.Item label="邮箱">{selectedTenant.contact_email}</Descriptions.Item> <Descriptions.Item label="状态">
<Descriptions.Item label="坐席数">{selectedTenant.seat_count}</Descriptions.Item> <Tag color={statusConfig[selectedTenant.status]?.color}>{statusConfig[selectedTenant.status]?.text || selectedTenant.status}</Tag>
<Descriptions.Item label="到期日期">{selectedTenant.expire_at ? new Date(selectedTenant.expire_at).toLocaleDateString() : '-'}</Descriptions.Item> </Descriptions.Item>
</Descriptions> <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> </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> </div>
) )
} }
+56 -3
View File
@@ -42,8 +42,35 @@ export interface Channel {
} }
export interface Tenant { 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 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 { 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') export const getAgentPerformance = () => get<{ name: string; conversations: number; avg_response: number; satisfaction: number }[]>('/statistics/performance')
// Admin // 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() const search = new URLSearchParams()
if (params?.search) search.set('search', params.search) if (params?.search) search.set('search', params.search)
if (params?.status) search.set('status', params.status) if (params?.status) search.set('status', params.status)
if (params?.page) search.set('page', String(params.page || 1)) 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}`) 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}`)