diff --git a/server/internal/handler/admin.go b/server/internal/handler/admin.go index c27ee7c..95ea9d4 100644 --- a/server/internal/handler/admin.go +++ b/server/internal/handler/admin.go @@ -17,32 +17,50 @@ type AdminHandler struct{} func NewAdminHandler() *AdminHandler { return &AdminHandler{} } func (h *AdminHandler) Stats(c *gin.Context) { - var tenantTotal, activeTotal, suspendedTotal, expiringTotal int64 + var tenantTotal, activeTotal, suspendedTotal, expiringTotal, newThisMonth 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) + now := time.Now() + monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + model.DB.Model(&model.Tenant{}).Where("created_at >= ?", monthStart).Count(&newThisMonth) + // 估算月收入:正常/即将到期租户 × 套餐月费 type planCount struct { PlanID uint Count int64 } - var counts []planCount + var incomeCounts []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) + Scan(&incomeCounts) var monthlyIncome int64 - planDist := make([]gin.H, 0) - for _, item := range counts { + for _, item := range incomeCounts { var plan model.Plan if err := model.DB.First(&plan, item.PlanID).Error; err != nil { continue } monthlyIncome += int64(plan.PriceMonthly) * item.Count + } + + // 套餐分布:全部已绑定套餐的租户 + var distCounts []planCount + model.DB.Model(&model.Tenant{}). + Select("plan_id, count(*) as count"). + Where("plan_id IS NOT NULL"). + Group("plan_id"). + Scan(&distCounts) + planDist := make([]gin.H, 0, len(distCounts)) + for _, item := range distCounts { + var plan model.Plan + if err := model.DB.First(&plan, item.PlanID).Error; err != nil { + continue + } planDist = append(planDist, gin.H{ "plan_id": plan.ID, "name": plan.Name, @@ -50,18 +68,36 @@ func (h *AdminHandler) Stats(c *gin.Context) { }) } + // 近 6 个月租户增长 + growth := make([]gin.H, 0, 6) + for offset := 5; offset >= 0; offset-- { + mStart := time.Date(now.Year(), now.Month()-time.Month(offset), 1, 0, 0, 0, 0, now.Location()) + mEnd := mStart.AddDate(0, 1, 0) + var newCnt, totalCnt int64 + model.DB.Model(&model.Tenant{}).Where("created_at >= ? AND created_at < ?", mStart, mEnd).Count(&newCnt) + model.DB.Model(&model.Tenant{}).Where("created_at < ?", mEnd).Count(&totalCnt) + growth = append(growth, gin.H{ + "month": mStart.Format("1月"), + "month_key": mStart.Format("2006-01"), + "new_count": newCnt, + "total_count": totalCnt, + }) + } + var recentLogs []model.OperationLog - model.DB.Order("created_at desc").Limit(10).Find(&recentLogs) + model.DB.Order("created_at desc").Limit(12).Find(&recentLogs) middleware.JSON(c, gin.H{ - "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, + "tenant_total": tenantTotal, + "active_tenant": activeTotal, + "suspended_tenant": suspendedTotal, + "expiring_tenant": expiringTotal, + "new_tenants_month": newThisMonth, + "monthly_income": monthlyIncome, + "system_uptime": "99.95%", + "plan_distribution": planDist, + "tenant_growth": growth, + "recent_logs": recentLogs, }) } diff --git a/web/src/components/layout/AdminLayout.tsx b/web/src/components/layout/AdminLayout.tsx index 48fd118..3f951bf 100644 --- a/web/src/components/layout/AdminLayout.tsx +++ b/web/src/components/layout/AdminLayout.tsx @@ -1,19 +1,14 @@ import { Outlet } from 'react-router-dom' -import { Layout } from 'antd' import AdminSidebar from './AdminSidebar' -const { Content } = Layout - const AdminLayout = () => { return ( - +
- - - - - - +
+ +
+
) } diff --git a/web/src/components/layout/AdminSidebar.tsx b/web/src/components/layout/AdminSidebar.tsx index c6fde20..a92b015 100644 --- a/web/src/components/layout/AdminSidebar.tsx +++ b/web/src/components/layout/AdminSidebar.tsx @@ -1,14 +1,13 @@ import { useLocation, useNavigate } from 'react-router-dom' -import { Layout, Menu, Dropdown, Avatar } from 'antd' -import { DashboardOutlined, TeamOutlined, GiftOutlined, ToolOutlined, LogoutOutlined, UserOutlined } from '@ant-design/icons' +import { + DashboardOutlined, TeamOutlined, GiftOutlined, ToolOutlined, LogoutOutlined, +} from '@ant-design/icons' import { useAuth } from '@/stores/auth' -const { Sider } = Layout - const menuItems = [ { key: '/admin/dashboard', icon: , label: '运营概览' }, { key: '/admin/tenants', icon: , label: '租户管理' }, - { key: '/admin/plans', icon: , label: '套餐管理' }, + { key: '/admin/plans', icon: , label: '套餐定价' }, { key: '/admin/ops', icon: , label: '系统运维' }, ] @@ -18,6 +17,7 @@ const AdminSidebar = () => { const { user, logout } = useAuth() const selectedKey = menuItems.find(item => location.pathname.startsWith(item.key))?.key || '/admin/dashboard' + const initial = (user?.nickname || '管').slice(0, 1) const handleLogout = () => { logout() @@ -25,33 +25,65 @@ const AdminSidebar = () => { } return ( - -
- - 客服云管理 + ) } diff --git a/web/src/pages/admin/Dashboard.tsx b/web/src/pages/admin/Dashboard.tsx index 9dd9141..3162df4 100644 --- a/web/src/pages/admin/Dashboard.tsx +++ b/web/src/pages/admin/Dashboard.tsx @@ -1,21 +1,39 @@ -import { useState, useEffect } from 'react' -import { Card, Table, Tag, Spin, Empty } from 'antd' +import { useState, useEffect, useMemo } from 'react' +import { Spin, Empty } from 'antd' import { - ArrowUpOutlined, TeamOutlined, UserOutlined, SafetyCertificateOutlined, DollarOutlined, + TeamOutlined, UserOutlined, DollarOutlined, SafetyCertificateOutlined, + RiseOutlined, } from '@ant-design/icons' +import { useAuth } from '@/stores/auth' 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 actionLabel: Record = { + create_tenant: { text: '开通租户', bg: '#dbeafe', color: '#2563eb' }, + suspend_tenant: { text: '暂停租户', bg: '#fef2f2', color: '#dc2626' }, + resume_tenant: { text: '恢复租户', bg: '#f0fdf4', color: '#16a34a' }, + update_tenant: { text: '更新租户', bg: '#ecfeff', color: '#0891b2' }, + update_plan: { text: '更新套餐', bg: '#f3e8ff', color: '#7c3aed' }, + create_plan: { text: '创建套餐', bg: '#f3e8ff', color: '#7c3aed' }, + create_announcement: { text: '创建公告', bg: '#fffbeb', color: '#d97706' }, + delete_announcement: { text: '删除公告', bg: '#f1f5f9', color: '#64748b' }, +} + +const planColors = ['#2563eb', '#16a34a', '#d97706', '#0891b2', '#7c3aed', '#dc2626'] + +function formatDate(d = new Date()) { + const week = ['日', '一', '二', '三', '四', '五', '六'] + return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 星期${week[d.getDay()]}` +} + +function formatTime(iso?: string) { + if (!iso) return '—' + return new Date(iso).toLocaleString('zh-CN', { + month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', + }) } const AdminDashboard = () => { + const { user } = useAuth() const [stats, setStats] = useState(null) const [loading, setLoading] = useState(true) @@ -26,86 +44,295 @@ const AdminDashboard = () => { .finally(() => setLoading(false)) }, []) - if (loading) { - return
- } + const planDist = stats?.plan_distribution || [] + const planTotal = useMemo( + () => planDist.reduce((s, p) => s + Number(p.count || 0), 0) || stats?.tenant_total || 0, + [planDist, stats?.tenant_total], + ) - const kpiData = [ - { 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 growth = stats?.tenant_growth || [] + const maxGrowth = useMemo(() => { + let m = 1 + growth.forEach(g => { + m = Math.max(m, Number(g.new_count || 0), Number(g.total_count || 0) / 4) + }) + return m + }, [growth]) + + const conic = useMemo(() => { + if (planDist.length === 0 || planTotal === 0) return 'var(--neutral-100) 0deg 360deg' + let acc = 0 + const parts: string[] = [] + planDist.forEach((item, i) => { + const pct = (Number(item.count) / planTotal) * 360 + const start = acc + acc += pct + parts.push(`${planColors[i % planColors.length]} ${start}deg ${acc}deg`) + }) + return parts.join(', ') + }, [planDist, planTotal]) + + const activeRate = stats && stats.tenant_total > 0 + ? ((stats.active_tenant / stats.tenant_total) * 100).toFixed(1) + : '0' const logs: OperationLog[] = stats?.recent_logs || [] - const planDist = stats?.plan_distribution || [] + const newMonth = stats?.new_tenants_month ?? 0 + + const kpis = [ + { + label: '租户总数', + value: stats?.tenant_total ?? 0, + unit: '家', + hint: `本月新增 ${newMonth} 家`, + badge: newMonth > 0 ? `+${newMonth}` : null, + icon: , + iconBg: '#dbeafe', + iconColor: '#2563eb', + }, + { + label: '活跃租户', + value: stats?.active_tenant ?? 0, + unit: '家', + hint: `活跃率 ${activeRate}% · 暂停 ${stats?.suspended_tenant ?? 0}`, + badge: `${activeRate}%`, + icon: , + iconBg: '#f0fdf4', + iconColor: '#16a34a', + }, + { + label: '平台月收入', + value: `¥${(stats?.monthly_income ?? 0).toLocaleString()}`, + unit: '', + hint: '按在用套餐 × 正常租户估算', + badge: null, + icon: , + iconBg: '#fffbeb', + iconColor: '#d97706', + }, + { + label: '系统可用率', + value: stats?.system_uptime || '99.95%', + unit: '', + hint: '本月 SLA 目标 99.9%', + badge: 'SLA达标', + icon: , + iconBg: '#ecfeff', + iconColor: '#0891b2', + }, + ] return ( -
-

运营概览

-
- {kpiData.map((k, i) => ( - -
- {k.label} - {k.icon} -
-
{k.value}
-
- {k.hint} -
-
- ))} -
+
+
+
+

运营概览

+ {formatDate()} +
+
-
- - {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}% +
+ {loading ? ( +
+ ) : ( +
+
+

+ 欢迎回来,{user?.nickname || '管理员'} +

+

+ 以下是平台当前运营数据概览 + {stats?.expiring_tenant ? ` · ${stats.expiring_tenant} 家租户即将到期` : ''} +

+
+ + {/* KPI */} +
+ {kpis.map(k => ( +
+
+
+ {k.icon}
-
-
+ {k.badge && ( + + {k.badge.startsWith('+') && } + {k.badge} + + )} +
+
+ + {k.value} + + {k.unit && {k.unit}} +
+

{k.label}

+

{k.hint}

+
+ ))} +
+ + {/* 图表区 */} +
+
+
+
+

租户增长趋势

+

近6个月新增租户数量

+
+
+ + + 新增租户 + + + + 累计(缩放) + +
+
+
+ {growth.length === 0 ? ( +
暂无数据
+ ) : ( +
+ {growth.map(g => { + const newH = Math.max(4, Math.round((Number(g.new_count) / maxGrowth) * 140)) + const totH = Math.max(4, Math.round((Number(g.total_count) / 4 / maxGrowth) * 140)) + return ( +
+
+
+
+
+ {g.month} +
+ ) + })} +
+ )} +
+
+ +
+
+

套餐分布占比

+

当前租户套餐类型分布

+
+
+
+
+
+
+ {planTotal || stats?.tenant_total || 0} + 总计
- ) - })} -
- )} - +
+ {planDist.length === 0 ? ( + + ) : ( + planDist.map((item, i) => { + const pct = planTotal > 0 ? Math.round((Number(item.count) / planTotal) * 100) : 0 + 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}, - }, - ]} - /> - + {/* 最近操作 */} +
+
+

最近操作记录

+

平台管理操作审计

+
+
+
+ + + + + + + + + + {logs.length === 0 ? ( + + + + ) : ( + logs.map(log => { + const meta = actionLabel[log.action] || { + text: log.action, bg: '#f1f5f9', color: '#64748b', + } + return ( + + + + + + + ) + }) + )} + +
时间操作详情IP
+ 暂无操作记录 +
+ {formatTime(log.created_at)} + + + {meta.text} + + + {log.detail || '—'} + + {log.ip || '—'} +
+
+ +
+ )}
) diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 5a092c4..6138cbd 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -82,9 +82,11 @@ export interface AdminStats { active_tenant: number suspended_tenant?: number expiring_tenant?: number + new_tenants_month?: number monthly_income: number system_uptime: string plan_distribution?: { plan_id: number; name: string; count: number }[] + tenant_growth?: { month: string; month_key: string; new_count: number; total_count: number }[] recent_logs?: OperationLog[] }