diff --git a/server/internal/handler/admin.go b/server/internal/handler/admin.go index 9e8a13d..abe9aeb 100644 --- a/server/internal/handler/admin.go +++ b/server/internal/handler/admin.go @@ -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": "已删除"}) } diff --git a/server/internal/handler/security_integration_test.go b/server/internal/handler/security_integration_test.go index d3bef9e..290e745 100644 --- a/server/internal/handler/security_integration_test.go +++ b/server/internal/handler/security_integration_test.go @@ -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) + } +} diff --git a/web/src/pages/admin/Dashboard.tsx b/web/src/pages/admin/Dashboard.tsx index c64f227..9dd9141 100644 --- a/web/src/pages/admin/Dashboard.tsx +++ b/web/src/pages/admin/Dashboard.tsx @@ -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 = { + create_tenant: '开通租户', + suspend_tenant: '暂停租户', + resume_tenant: '恢复租户', + update_tenant: '更新租户', + update_plan: '更新套餐', + create_announcement: '创建公告', + delete_announcement: '删除公告', +} const AdminDashboard = () => { - const [stats, setStats] = useState(null) - const [tenants, setTenants] = useState([]) + const [stats, setStats] = useState(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
+ if (loading) { + return
+ } const kpiData = [ - { label: '租户总数', value: stats?.tenant_total || 0, icon: , color: '#2563eb' }, - { label: '活跃租户', value: stats?.active_tenant || 0, icon: , color: '#16a34a' }, - { label: '系统可用率', value: stats?.system_uptime || '99.95%', icon: , color: '#0891b2' }, + { label: '租户总数', value: stats?.tenant_total ?? 0, icon: , color: '#2563eb', hint: '平台全部租户' }, + { label: '活跃租户', value: stats?.active_tenant ?? 0, icon: , color: '#16a34a', hint: `暂停 ${stats?.suspended_tenant ?? 0} · 将到期 ${stats?.expiring_tenant ?? 0}` }, + { label: '估算月收入', value: `¥${(stats?.monthly_income ?? 0).toLocaleString()}`, icon: , color: '#0891b2', hint: '按在售套餐 × 正常租户估算' }, + { label: '系统可用率', value: stats?.system_uptime || '99.95%', icon: , color: '#7c3aed', hint: '运行正常' }, ] + const logs: OperationLog[] = stats?.recent_logs || [] + const planDist = stats?.plan_distribution || [] + return (

运营概览

-
+
{kpiData.map((k, i) => (
@@ -34,25 +51,62 @@ const AdminDashboard = () => { {k.icon}
{k.value}
-
运行正常
+
+ {k.hint} +
))}
- - ( - {s} - )}, - { title: '到期日期', dataIndex: 'expire_at', key: 'expire_at', render: (t: string) => t ? new Date(t).toLocaleDateString() : '-' }, - ]} - /> - + +
+ + {planDist.length === 0 ? ( + + ) : ( +
+ {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 ( +
+
+ {item.name} + {item.count} 家 · {pct}% +
+
+
+
+
+ ) + })} +
+ )} + + + +
}} + columns={[ + { + title: '时间', dataIndex: 'created_at', key: 'created_at', width: 150, + render: (t: string) => {t ? new Date(t).toLocaleString('zh-CN') : '—'}, + }, + { + title: '操作', dataIndex: 'action', key: 'action', + render: (a: string) => {actionLabel[a] || a}, + }, + { + title: '详情', dataIndex: 'detail', key: 'detail', + render: (d: string) => {d}, + }, + ]} + /> + + ) } diff --git a/web/src/pages/admin/Ops.tsx b/web/src/pages/admin/Ops.tsx index 1ae4370..358eacd 100644 --- a/web/src/pages/admin/Ops.tsx +++ b/web/src/pages/admin/Ops.tsx @@ -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 = { + create_tenant: '开通租户', + suspend_tenant: '暂停租户', + resume_tenant: '恢复租户', + update_tenant: '更新租户', + update_plan: '更新套餐', + create_announcement: '创建公告', + delete_announcement: '删除公告', +} const Ops = () => { + const [logs, setLogs] = useState([]) + const [logTotal, setLogTotal] = useState(0) + const [logPage, setLogPage] = useState(1) + const [announcements, setAnnouncements] = useState([]) + const [loadingLogs, setLoadingLogs] = useState(false) + const [loadingAnn, setLoadingAnn] = useState(false) + const [modalOpen, setModalOpen] = useState(false) + const [editing, setEditing] = useState(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 (

系统运维

- {/* 服务状态 + 系统负载 */}
- }> + } + >
{services.map(s => (
@@ -58,11 +149,18 @@ const Ops = () => {
))}
+
+ 健康检查基于进程存活(开发环境示意)· 刷新于 {refreshedAt.toLocaleTimeString('zh-CN')} +
- {loadData.map(l => ( + {[ + { type: 'CPU 使用率', percent: 28 }, + { type: '内存使用率', percent: 51 }, + { type: '磁盘使用率', percent: 36 }, + ].map(l => (
{l.type} @@ -71,40 +169,39 @@ const Ops = () => { 80 ? '#dc2626' : l.percent > 60 ? '#d97706' : '#16a34a'} />
))} -
数据刷新频率:30秒 · 超过 80% 触发告警
+
负载指标为示意数据,生产环境可对接主机监控。
- {/* API 请求图表 */} - -
- -
-
- - {/* 操作日志 + 公告 */}
setLogPage(p), + }} size="small" + locale={{ emptyText: }} columns={[ - { title: '时间', dataIndex: 'time', key: 'time', render: (t: string) => {t} }, - { title: '操作人', dataIndex: 'operator', key: 'operator' }, - { title: '操作', dataIndex: 'action', key: 'action' }, - { title: '详情', dataIndex: 'detail', key: 'detail', render: (d: string) => {d} }, + { + title: '时间', dataIndex: 'created_at', key: 'created_at', width: 150, + render: (t: string) => {t ? new Date(t).toLocaleString('zh-CN') : '—'}, + }, + { + title: '操作', dataIndex: 'action', key: 'action', + render: (a: string) => actionLabel[a] || a, + }, + { + title: '详情', dataIndex: 'detail', key: 'detail', + render: (d: string) => {d}, + }, ]} /> @@ -113,28 +210,59 @@ const Ops = () => { title="平台公告" className="!rounded-lg" bordered={false} - extra={} + extra={} > -
- {announcements.map(a => ( -
-
- {a.title} - {a.status === 'published' ? '已发布' : '草稿'} + {loadingAnn ? ( +
+ ) : announcements.length === 0 ? ( + + ) : ( +
+ {announcements.map(a => ( +
+
+ {a.title} + + {a.status === 'published' ? '已发布' : '草稿'} + +
+

{a.content || '—'}

+
+ {a.created_at ? new Date(a.created_at).toLocaleString('zh-CN') : '—'} + + + handleDelete(a.id)}> + + + +
-

{a.content}

-
- {a.time} - - - - -
-
- ))} -
+ ))} +
+ )}
+ + setModalOpen(false)} + onOk={() => form.submit()} + confirmLoading={saving} + destroyOnClose + > +
+ + + + + + + +
{t} }, - { title: '基础版', dataIndex: 'basic', key: 'basic', render: (t: string) => }, - { title: '专业版', dataIndex: 'pro', key: 'pro', render: (t: string) => }, - { title: '企业版', dataIndex: 'enterprise', key: 'enterprise', render: (t: string) => }, - ]} - /> - + {sorted.length > 0 && ( + +
({ 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) => {t} }, + ...sorted.map((p, idx) => ({ + title: p.name, + dataIndex: `p${idx}`, + key: `p${idx}`, + render: (t: string) => { + if (t === '不支持') return + if (t === '支持') return + return {t} + }, + })), + ]} + /> + + )} - {/* 定价规则 */}
  • 按月订阅,年付享受 8 折 优惠
  • @@ -109,14 +203,18 @@ const Plans = () => {
  • 企业版支持自定义报价,请联系销售团队
+ + setModalOpen(false)} onOk={() => form.submit()} confirmLoading={saving} destroyOnClose> + + + + + + + + ) } -function RenderCell({ text }: { text: string }) { - if (text === '不支持') return - if (text === '支持') return - return {text} -} - export default Plans diff --git a/web/src/pages/admin/Tenants.tsx b/web/src/pages/admin/Tenants.tsx index 71dbeb5..b5d40f6 100644 --- a/web/src/pages/admin/Tenants.tsx +++ b/web/src/pages/admin/Tenants.tsx @@ -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 = { normal: { color: 'green', text: '正常' }, @@ -12,6 +18,7 @@ const statusConfig: Record = { const Tenants = () => { const [tenants, setTenants] = useState([]) + const [plans, setPlans] = useState([]) 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(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) => {t} }, { title: '联系人', dataIndex: 'contact_name', key: 'contact_name' }, { title: '手机号', dataIndex: 'contact_phone', key: 'contact_phone', render: (t: string) => {t} }, + { + title: '套餐', key: 'plan', + render: (_: unknown, r: Tenant) => {planName(r.plan_id)}, + }, { title: '坐席数', dataIndex: 'seat_count', key: 'seat_count', align: 'center' as const }, - { title: '到期日期', dataIndex: 'expire_at', key: 'expire_at', render: (t: string) => {t ? new Date(t).toLocaleDateString() : '-'} }, - { title: '状态', dataIndex: 'status', key: 'status', render: (s: string) => {statusConfig[s]?.text || s} }, - { title: '操作', key: 'actions', width: 200, render: (_: unknown, record: Tenant) => ( - - - {record.status === 'normal' && } - {(record.status === 'suspended' || record.status === 'expired') && } - - )}, + { + title: '到期日期', dataIndex: 'expire_at', key: 'expire_at', + render: (t: string) => {t ? new Date(t).toLocaleDateString() : '—'}, + }, + { + title: '状态', dataIndex: 'status', key: 'status', + render: (s: string) => {statusConfig[s]?.text || s}, + }, + { + title: '操作', key: 'actions', width: 220, + render: (_: unknown, record: Tenant) => ( + + + {record.status !== 'suspended' && ( + { e?.stopPropagation(); handleSuspend(record.id) }}> + + + )} + {(record.status === 'suspended' || record.status === 'expired') && ( + + )} + + ), + }, ] return (

租户管理

- +
} placeholder="搜索公司名称或联系人" value={search} onChange={e => { setSearch(e.target.value); setPage(1) }} className="w-56" allowClear />
`共 ${t} 个租户`, onChange: p => setPage(p) }} - locale={{ emptyText: }} /> + locale={{ emptyText: }} + /> + setDrawerOpen(false)} width={480}> -
{ setDrawerOpen(false); message.success('开通成功') }}> - - - - - - - + + + + + + + + + + + + )}
+ + setCreatedCreds(null)} + onOk={() => setCreatedCreds(null)} + okText="知道了" + cancelButtonProps={{ style: { display: 'none' } }} + > + {createdCreds && ( +
+

租户 {createdCreds.name} 已开通,请妥善保存登录信息:

+
+
管理员账号:{createdCreds.username}
+
初始密码:{createdCreds.password}
+
+
+ )} +
) } diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 85acafd..0ae5323 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -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(`/admin/tenants?${search}`) } -export const getAdminStats = () => get('/admin/stats') +export const getAdminStats = () => get('/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) => put(`/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('/admin/plans') +export const createPlan = (data: Partial) => post('/admin/plans', data) +export const updatePlan = (id: number, data: Partial) => put(`/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(`/admin/logs?${search}`) +} +export const getAnnouncements = () => get('/admin/announcements') +export const createAnnouncement = (data: { title: string; content: string; status?: string }) => + post('/admin/announcements', data) +export const updateAnnouncement = (id: number, data: Partial) => + put(`/admin/announcements/${id}`, data) +export const deleteAnnouncement = (id: number) => del(`/admin/announcements/${id}`)