优化平台运营概览并对齐管理端布局
统一管理端侧栏与顶栏 56px 高度;运营概览按 P2-08 重做 KPI、增长趋势、套餐分布与操作日志。
This commit is contained in:
@@ -17,32 +17,50 @@ 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, suspendedTotal, expiringTotal int64
|
var tenantTotal, activeTotal, suspendedTotal, expiringTotal, newThisMonth 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 = ?", "suspended").Count(&suspendedTotal)
|
||||||
model.DB.Model(&model.Tenant{}).Where("status = ?", "expiring").Count(&expiringTotal)
|
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 {
|
type planCount struct {
|
||||||
PlanID uint
|
PlanID uint
|
||||||
Count int64
|
Count int64
|
||||||
}
|
}
|
||||||
var counts []planCount
|
var incomeCounts []planCount
|
||||||
model.DB.Model(&model.Tenant{}).
|
model.DB.Model(&model.Tenant{}).
|
||||||
Select("plan_id, count(*) as count").
|
Select("plan_id, count(*) as count").
|
||||||
Where("status IN ? AND plan_id IS NOT NULL", []string{"normal", "expiring"}).
|
Where("status IN ? AND plan_id IS NOT NULL", []string{"normal", "expiring"}).
|
||||||
Group("plan_id").
|
Group("plan_id").
|
||||||
Scan(&counts)
|
Scan(&incomeCounts)
|
||||||
var monthlyIncome int64
|
var monthlyIncome int64
|
||||||
planDist := make([]gin.H, 0)
|
for _, item := range incomeCounts {
|
||||||
for _, item := range counts {
|
|
||||||
var plan model.Plan
|
var plan model.Plan
|
||||||
if err := model.DB.First(&plan, item.PlanID).Error; err != nil {
|
if err := model.DB.First(&plan, item.PlanID).Error; err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
monthlyIncome += int64(plan.PriceMonthly) * item.Count
|
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{
|
planDist = append(planDist, gin.H{
|
||||||
"plan_id": plan.ID,
|
"plan_id": plan.ID,
|
||||||
"name": plan.Name,
|
"name": plan.Name,
|
||||||
@@ -50,17 +68,35 @@ 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
|
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{
|
middleware.JSON(c, gin.H{
|
||||||
"tenant_total": tenantTotal,
|
"tenant_total": tenantTotal,
|
||||||
"active_tenant": activeTotal,
|
"active_tenant": activeTotal,
|
||||||
"suspended_tenant": suspendedTotal,
|
"suspended_tenant": suspendedTotal,
|
||||||
"expiring_tenant": expiringTotal,
|
"expiring_tenant": expiringTotal,
|
||||||
|
"new_tenants_month": newThisMonth,
|
||||||
"monthly_income": monthlyIncome,
|
"monthly_income": monthlyIncome,
|
||||||
"system_uptime": "99.95%",
|
"system_uptime": "99.95%",
|
||||||
"plan_distribution": planDist,
|
"plan_distribution": planDist,
|
||||||
|
"tenant_growth": growth,
|
||||||
"recent_logs": recentLogs,
|
"recent_logs": recentLogs,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
import { Outlet } from 'react-router-dom'
|
import { Outlet } from 'react-router-dom'
|
||||||
import { Layout } from 'antd'
|
|
||||||
import AdminSidebar from './AdminSidebar'
|
import AdminSidebar from './AdminSidebar'
|
||||||
|
|
||||||
const { Content } = Layout
|
|
||||||
|
|
||||||
const AdminLayout = () => {
|
const AdminLayout = () => {
|
||||||
return (
|
return (
|
||||||
<Layout className="h-screen">
|
<div className="flex h-screen overflow-hidden bg-neutral-50">
|
||||||
<AdminSidebar />
|
<AdminSidebar />
|
||||||
<Layout>
|
<div className="flex-1 min-w-0 h-full overflow-hidden">
|
||||||
<Content className="overflow-auto bg-neutral-50 p-6">
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Content>
|
</div>
|
||||||
</Layout>
|
</div>
|
||||||
</Layout>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { Layout, Menu, Dropdown, Avatar } from 'antd'
|
import {
|
||||||
import { DashboardOutlined, TeamOutlined, GiftOutlined, ToolOutlined, LogoutOutlined, UserOutlined } from '@ant-design/icons'
|
DashboardOutlined, TeamOutlined, GiftOutlined, ToolOutlined, LogoutOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
import { useAuth } from '@/stores/auth'
|
import { useAuth } from '@/stores/auth'
|
||||||
|
|
||||||
const { Sider } = Layout
|
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ key: '/admin/dashboard', icon: <DashboardOutlined />, label: '运营概览' },
|
{ key: '/admin/dashboard', icon: <DashboardOutlined />, label: '运营概览' },
|
||||||
{ key: '/admin/tenants', icon: <TeamOutlined />, label: '租户管理' },
|
{ key: '/admin/tenants', icon: <TeamOutlined />, label: '租户管理' },
|
||||||
{ key: '/admin/plans', icon: <GiftOutlined />, label: '套餐管理' },
|
{ key: '/admin/plans', icon: <GiftOutlined />, label: '套餐定价' },
|
||||||
{ key: '/admin/ops', icon: <ToolOutlined />, label: '系统运维' },
|
{ key: '/admin/ops', icon: <ToolOutlined />, label: '系统运维' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -18,6 +17,7 @@ const AdminSidebar = () => {
|
|||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
|
|
||||||
const selectedKey = menuItems.find(item => location.pathname.startsWith(item.key))?.key || '/admin/dashboard'
|
const selectedKey = menuItems.find(item => location.pathname.startsWith(item.key))?.key || '/admin/dashboard'
|
||||||
|
const initial = (user?.nickname || '管').slice(0, 1)
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout()
|
logout()
|
||||||
@@ -25,33 +25,65 @@ const AdminSidebar = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sider width={220} className="!bg-white border-r border-neutral-200 flex flex-col">
|
<aside
|
||||||
<div className="h-14 flex items-center px-5 border-b border-neutral-100">
|
className="shrink-0 flex flex-col h-full bg-white border-r border-neutral-200"
|
||||||
<DashboardOutlined className="text-lg text-[#2563eb] mr-2.5" />
|
style={{ width: 220 }}
|
||||||
<span className="text-base font-semibold text-neutral-900">客服云管理</span>
|
|
||||||
</div>
|
|
||||||
<Menu
|
|
||||||
mode="inline"
|
|
||||||
selectedKeys={[selectedKey]}
|
|
||||||
items={menuItems}
|
|
||||||
onClick={({ key }) => navigate(key)}
|
|
||||||
className="border-e-0 mt-2 flex-1"
|
|
||||||
/>
|
|
||||||
<div className="border-t border-neutral-100 p-3">
|
|
||||||
<Dropdown
|
|
||||||
menu={{ items: [{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', onClick: handleLogout }] }}
|
|
||||||
trigger={['click']}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 cursor-pointer hover:bg-neutral-50 rounded p-1.5">
|
<div
|
||||||
<Avatar size={28} icon={<UserOutlined />} className="!bg-blue-100 !text-blue-500" />
|
className="flex items-center gap-2.5 px-4 shrink-0 border-b border-neutral-200"
|
||||||
<div className="min-w-0">
|
style={{ height: 'var(--header-height)' }}
|
||||||
<div className="text-xs font-medium text-neutral-700 truncate">{user?.nickname || '管理员'}</div>
|
>
|
||||||
<div className="text-xs text-neutral-400">在线</div>
|
<div className="w-8 h-8 rounded-lg bg-[#2563eb] flex items-center justify-center shrink-0">
|
||||||
|
<DashboardOutlined className="text-white text-sm" />
|
||||||
</div>
|
</div>
|
||||||
|
<span className="font-semibold text-neutral-900 text-base truncate">客服云管理</span>
|
||||||
</div>
|
</div>
|
||||||
</Dropdown>
|
|
||||||
|
<nav className="flex-1 overflow-y-auto py-3 px-2">
|
||||||
|
<ul className="flex flex-col gap-0.5 m-0 p-0 list-none">
|
||||||
|
{menuItems.map(item => {
|
||||||
|
const active = selectedKey === item.key
|
||||||
|
return (
|
||||||
|
<li key={item.key}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(item.key)}
|
||||||
|
className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm truncate transition-colors border-0 cursor-pointer ${
|
||||||
|
active
|
||||||
|
? 'bg-[#dbeafe] text-[#2563eb] font-medium'
|
||||||
|
: 'bg-transparent text-neutral-600 hover:bg-neutral-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-base leading-none">{item.icon}</span>
|
||||||
|
<span className="truncate">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2.5 px-4 shrink-0 border-t border-neutral-200"
|
||||||
|
style={{ height: 52 }}
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 rounded-full bg-[#dbeafe] text-[#2563eb] flex items-center justify-center text-sm font-semibold shrink-0">
|
||||||
|
{initial}
|
||||||
</div>
|
</div>
|
||||||
</Sider>
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-neutral-800 truncate">{user?.nickname || '管理员'}</div>
|
||||||
|
<div className="text-[11px] text-neutral-400">平台管理员</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="退出登录"
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="w-8 h-8 rounded-md flex items-center justify-center text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600 border-0 bg-transparent cursor-pointer"
|
||||||
|
>
|
||||||
|
<LogoutOutlined />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,39 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { Card, Table, Tag, Spin, Empty } from 'antd'
|
import { Spin, Empty } from 'antd'
|
||||||
import {
|
import {
|
||||||
ArrowUpOutlined, TeamOutlined, UserOutlined, SafetyCertificateOutlined, DollarOutlined,
|
TeamOutlined, UserOutlined, DollarOutlined, SafetyCertificateOutlined,
|
||||||
|
RiseOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
|
import { useAuth } from '@/stores/auth'
|
||||||
import { getAdminStats, type AdminStats, type OperationLog } from '@/services/api'
|
import { getAdminStats, type AdminStats, type OperationLog } from '@/services/api'
|
||||||
|
|
||||||
const actionLabel: Record<string, string> = {
|
const actionLabel: Record<string, { text: string; bg: string; color: string }> = {
|
||||||
create_tenant: '开通租户',
|
create_tenant: { text: '开通租户', bg: '#dbeafe', color: '#2563eb' },
|
||||||
suspend_tenant: '暂停租户',
|
suspend_tenant: { text: '暂停租户', bg: '#fef2f2', color: '#dc2626' },
|
||||||
resume_tenant: '恢复租户',
|
resume_tenant: { text: '恢复租户', bg: '#f0fdf4', color: '#16a34a' },
|
||||||
update_tenant: '更新租户',
|
update_tenant: { text: '更新租户', bg: '#ecfeff', color: '#0891b2' },
|
||||||
update_plan: '更新套餐',
|
update_plan: { text: '更新套餐', bg: '#f3e8ff', color: '#7c3aed' },
|
||||||
create_announcement: '创建公告',
|
create_plan: { text: '创建套餐', bg: '#f3e8ff', color: '#7c3aed' },
|
||||||
delete_announcement: '删除公告',
|
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 AdminDashboard = () => {
|
||||||
|
const { user } = useAuth()
|
||||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
@@ -26,86 +44,295 @@ const AdminDashboard = () => {
|
|||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (loading) {
|
const planDist = stats?.plan_distribution || []
|
||||||
return <div className="h-full flex items-center justify-center"><Spin size="large" /></div>
|
const planTotal = useMemo(
|
||||||
}
|
() => planDist.reduce((s, p) => s + Number(p.count || 0), 0) || stats?.tenant_total || 0,
|
||||||
|
[planDist, stats?.tenant_total],
|
||||||
|
)
|
||||||
|
|
||||||
const kpiData = [
|
const growth = stats?.tenant_growth || []
|
||||||
{ label: '租户总数', value: stats?.tenant_total ?? 0, icon: <TeamOutlined />, color: '#2563eb', hint: '平台全部租户' },
|
const maxGrowth = useMemo(() => {
|
||||||
{ label: '活跃租户', value: stats?.active_tenant ?? 0, icon: <UserOutlined />, color: '#16a34a', hint: `暂停 ${stats?.suspended_tenant ?? 0} · 将到期 ${stats?.expiring_tenant ?? 0}` },
|
let m = 1
|
||||||
{ label: '估算月收入', value: `¥${(stats?.monthly_income ?? 0).toLocaleString()}`, icon: <DollarOutlined />, color: '#0891b2', hint: '按在售套餐 × 正常租户估算' },
|
growth.forEach(g => {
|
||||||
{ label: '系统可用率', value: stats?.system_uptime || '99.95%', icon: <SafetyCertificateOutlined />, color: '#7c3aed', hint: '运行正常' },
|
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 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: <TeamOutlined />,
|
||||||
|
iconBg: '#dbeafe',
|
||||||
|
iconColor: '#2563eb',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '活跃租户',
|
||||||
|
value: stats?.active_tenant ?? 0,
|
||||||
|
unit: '家',
|
||||||
|
hint: `活跃率 ${activeRate}% · 暂停 ${stats?.suspended_tenant ?? 0}`,
|
||||||
|
badge: `${activeRate}%`,
|
||||||
|
icon: <UserOutlined />,
|
||||||
|
iconBg: '#f0fdf4',
|
||||||
|
iconColor: '#16a34a',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '平台月收入',
|
||||||
|
value: `¥${(stats?.monthly_income ?? 0).toLocaleString()}`,
|
||||||
|
unit: '',
|
||||||
|
hint: '按在用套餐 × 正常租户估算',
|
||||||
|
badge: null,
|
||||||
|
icon: <DollarOutlined />,
|
||||||
|
iconBg: '#fffbeb',
|
||||||
|
iconColor: '#d97706',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '系统可用率',
|
||||||
|
value: stats?.system_uptime || '99.95%',
|
||||||
|
unit: '',
|
||||||
|
hint: '本月 SLA 目标 99.9%',
|
||||||
|
badge: 'SLA达标',
|
||||||
|
icon: <SafetyCertificateOutlined />,
|
||||||
|
iconBg: '#ecfeff',
|
||||||
|
iconColor: '#0891b2',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="h-full flex flex-col min-h-0 overflow-hidden bg-neutral-50">
|
||||||
<h2 className="text-lg font-semibold text-neutral-800 mb-5">运营概览</h2>
|
<header
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-6">
|
className="shrink-0 px-6 flex items-center justify-between border-b border-neutral-200 bg-white"
|
||||||
{kpiData.map((k, i) => (
|
style={{ height: 'var(--header-height)' }}
|
||||||
<Card key={i} className="!rounded-lg" bordered={false}>
|
>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<span className="text-sm text-neutral-400">{k.label}</span>
|
<h1 className="text-base font-semibold text-neutral-900 m-0 truncate">运营概览</h1>
|
||||||
<span className="text-lg" style={{ color: k.color }}>{k.icon}</span>
|
<span className="text-sm text-neutral-400 whitespace-nowrap hidden sm:inline">{formatDate()}</span>
|
||||||
</div>
|
|
||||||
<div className="text-2xl font-bold text-neutral-800 mb-1">{k.value}</div>
|
|
||||||
<div className="text-xs text-green-600 flex items-center gap-1">
|
|
||||||
<ArrowUpOutlined /> {k.hint}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="flex-1 min-h-0 overflow-auto px-6 py-5">
|
||||||
<Card title="套餐分布" className="!rounded-lg" bordered={false}>
|
{loading ? (
|
||||||
{planDist.length === 0 ? (
|
<div className="h-64 flex items-center justify-center"><Spin size="large" /></div>
|
||||||
<Empty description="暂无套餐分布数据" />
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-5 w-full">
|
||||||
{planDist.map(item => {
|
<div>
|
||||||
const total = planDist.reduce((s, p) => s + Number(p.count || 0), 0) || 1
|
<h2 className="text-xl font-semibold text-neutral-900 m-0">
|
||||||
const pct = Math.round((Number(item.count) / total) * 100)
|
欢迎回来,{user?.nickname || '管理员'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-neutral-500 m-0 mt-1">
|
||||||
|
以下是平台当前运营数据概览
|
||||||
|
{stats?.expiring_tenant ? ` · ${stats.expiring_tenant} 家租户即将到期` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI */}
|
||||||
|
<section className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||||
|
{kpis.map(k => (
|
||||||
|
<div
|
||||||
|
key={k.label}
|
||||||
|
className="rounded-xl bg-white border border-neutral-200 px-4 py-4 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-full flex items-center justify-center text-lg shrink-0"
|
||||||
|
style={{ backgroundColor: k.iconBg, color: k.iconColor }}
|
||||||
|
>
|
||||||
|
{k.icon}
|
||||||
|
</div>
|
||||||
|
{k.badge && (
|
||||||
|
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[11px] font-medium bg-[#f0fdf4] text-[#16a34a]">
|
||||||
|
{k.badge.startsWith('+') && <RiseOutlined className="text-[10px]" />}
|
||||||
|
{k.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mb-1 flex items-baseline gap-1">
|
||||||
|
<span className="text-3xl font-bold text-neutral-900 tabular-nums leading-none">
|
||||||
|
{k.value}
|
||||||
|
</span>
|
||||||
|
{k.unit && <span className="text-sm text-neutral-500">{k.unit}</span>}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-neutral-500 m-0">{k.label}</p>
|
||||||
|
<p className="text-xs text-neutral-400 m-0 mt-1 truncate">{k.hint}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 图表区 */}
|
||||||
|
<section className="grid grid-cols-1 lg:grid-cols-[3fr_2fr] gap-4">
|
||||||
|
<div className="rounded-xl bg-white border border-neutral-200 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between px-4 pt-4 pb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="m-0 text-[15px] font-semibold text-neutral-800">租户增长趋势</h3>
|
||||||
|
<p className="m-0 mt-0.5 text-xs text-neutral-400">近6个月新增租户数量</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 shrink-0 text-xs text-neutral-500">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-sm bg-[#2563eb]" />
|
||||||
|
新增租户
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-sm bg-neutral-200" />
|
||||||
|
累计(缩放)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
{growth.length === 0 ? (
|
||||||
|
<div className="h-[180px] flex items-center justify-center text-sm text-neutral-400">暂无数据</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-end gap-3 h-[180px]">
|
||||||
|
{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 (
|
return (
|
||||||
<div key={item.plan_id}>
|
<div key={g.month_key || g.month} className="flex-1 flex flex-col items-center gap-1 min-w-0">
|
||||||
<div className="flex justify-between text-sm mb-1">
|
<div className="w-full flex flex-col items-center justify-end gap-0.5" style={{ height: 150 }}>
|
||||||
<span className="text-neutral-700 font-medium">{item.name}</span>
|
<div
|
||||||
<span className="text-neutral-400">{item.count} 家 · {pct}%</span>
|
className="w-full max-w-[40px] rounded-t-sm bg-neutral-200"
|
||||||
</div>
|
style={{ height: totH }}
|
||||||
<div className="h-2 bg-neutral-100 rounded-full overflow-hidden">
|
title={`累计 ${g.total_count}`}
|
||||||
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${pct}%` }} />
|
/>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-[40px] rounded-t-sm bg-[#2563eb]"
|
||||||
|
style={{ height: newH }}
|
||||||
|
title={`新增 ${g.new_count}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<span className="text-[11px] text-neutral-400 whitespace-nowrap">{g.month}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card title="最近操作记录" className="!rounded-lg" bordered={false}>
|
<div className="rounded-xl bg-white border border-neutral-200 shadow-sm">
|
||||||
<Table
|
<div className="px-4 pt-4 pb-3">
|
||||||
dataSource={logs}
|
<h3 className="m-0 text-[15px] font-semibold text-neutral-800">套餐分布占比</h3>
|
||||||
rowKey="id"
|
<p className="m-0 mt-0.5 text-xs text-neutral-400">当前租户套餐类型分布</p>
|
||||||
pagination={false}
|
</div>
|
||||||
size="middle"
|
<div className="flex items-center gap-6 px-4 pb-5">
|
||||||
locale={{ emptyText: <Empty description="暂无操作记录" /> }}
|
<div className="shrink-0 w-[120px] h-[120px] relative flex items-center justify-center">
|
||||||
columns={[
|
<div
|
||||||
{
|
className="absolute inset-0 rounded-full"
|
||||||
title: '时间', dataIndex: 'created_at', key: 'created_at', width: 150,
|
style={{ background: `conic-gradient(${conic})` }}
|
||||||
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 className="absolute w-[70px] h-[70px] rounded-full bg-white" />
|
||||||
|
<div className="relative z-[1] flex flex-col items-center">
|
||||||
|
<span className="text-xl font-bold text-neutral-900 tabular-nums">{planTotal || stats?.tenant_total || 0}</span>
|
||||||
|
<span className="text-[11px] text-neutral-400">总计</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col gap-2.5">
|
||||||
|
{planDist.length === 0 ? (
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无分布" className="!my-2" />
|
||||||
|
) : (
|
||||||
|
planDist.map((item, i) => {
|
||||||
|
const pct = planTotal > 0 ? Math.round((Number(item.count) / planTotal) * 100) : 0
|
||||||
|
return (
|
||||||
|
<div key={item.plan_id} className="flex items-center justify-between gap-2 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 rounded-sm shrink-0"
|
||||||
|
style={{ backgroundColor: planColors[i % planColors.length] }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-neutral-600 truncate">{item.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-neutral-800 tabular-nums shrink-0">
|
||||||
|
{item.count} · {pct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 最近操作 */}
|
||||||
|
<section className="rounded-xl bg-white border border-neutral-200 shadow-sm overflow-hidden">
|
||||||
|
<div className="px-5 pt-4 pb-2">
|
||||||
|
<h3 className="m-0 text-[15px] font-semibold text-neutral-800">最近操作记录</h3>
|
||||||
|
<p className="m-0 mt-0.5 text-xs text-neutral-400">平台管理操作审计</p>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[640px] border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-[11px] font-medium text-neutral-500 bg-neutral-50">
|
||||||
|
<th className="text-left px-4 py-2.5 border-b border-neutral-200 w-36">时间</th>
|
||||||
|
<th className="text-left px-4 py-2.5 border-b border-neutral-200 w-28">操作</th>
|
||||||
|
<th className="text-left px-4 py-2.5 border-b border-neutral-200">详情</th>
|
||||||
|
<th className="text-left px-4 py-2.5 border-b border-neutral-200 w-28">IP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="text-center text-sm text-neutral-400 py-12">
|
||||||
|
暂无操作记录
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
logs.map(log => {
|
||||||
|
const meta = actionLabel[log.action] || {
|
||||||
|
text: log.action, bg: '#f1f5f9', color: '#64748b',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<tr key={log.id} className="hover:bg-neutral-50">
|
||||||
|
<td className="px-4 py-3 border-b border-neutral-100 text-xs text-neutral-500 whitespace-nowrap">
|
||||||
|
{formatTime(log.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 border-b border-neutral-100">
|
||||||
|
<span
|
||||||
|
className="inline-flex px-2 py-0.5 rounded text-[11px] font-medium whitespace-nowrap"
|
||||||
|
style={{ backgroundColor: meta.bg, color: meta.color }}
|
||||||
|
>
|
||||||
|
{meta.text}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 border-b border-neutral-100 text-sm text-neutral-600 max-w-md truncate">
|
||||||
|
{log.detail || '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 border-b border-neutral-100 text-xs text-neutral-400 whitespace-nowrap">
|
||||||
|
{log.ip || '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -82,9 +82,11 @@ export interface AdminStats {
|
|||||||
active_tenant: number
|
active_tenant: number
|
||||||
suspended_tenant?: number
|
suspended_tenant?: number
|
||||||
expiring_tenant?: number
|
expiring_tenant?: number
|
||||||
|
new_tenants_month?: number
|
||||||
monthly_income: number
|
monthly_income: number
|
||||||
system_uptime: string
|
system_uptime: string
|
||||||
plan_distribution?: { plan_id: number; name: string; count: number }[]
|
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[]
|
recent_logs?: OperationLog[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user