优化平台运营概览并对齐管理端布局

统一管理端侧栏与顶栏 56px 高度;运营概览按 P2-08 重做 KPI、增长趋势、套餐分布与操作日志。
This commit is contained in:
yml2213
2026-07-15 13:45:38 +08:00
parent f6f7fd478e
commit 425cd1f0e2
5 changed files with 426 additions and 134 deletions
+42 -6
View File
@@ -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,
}) })
} }
+4 -9
View File
@@ -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>
) )
} }
+60 -28
View File
@@ -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>
) )
} }
+301 -74
View File
@@ -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>
) )
+2
View File
@@ -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[]
} }