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

统一管理端侧栏与顶栏 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
+50 -14
View File
@@ -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,
})
}
+5 -10
View File
@@ -1,19 +1,14 @@
import { Outlet } from 'react-router-dom'
import { Layout } from 'antd'
import AdminSidebar from './AdminSidebar'
const { Content } = Layout
const AdminLayout = () => {
return (
<Layout className="h-screen">
<div className="flex h-screen overflow-hidden bg-neutral-50">
<AdminSidebar />
<Layout>
<Content className="overflow-auto bg-neutral-50 p-6">
<Outlet />
</Content>
</Layout>
</Layout>
<div className="flex-1 min-w-0 h-full overflow-hidden">
<Outlet />
</div>
</div>
)
}
+61 -29
View File
@@ -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: <DashboardOutlined />, 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: '系统运维' },
]
@@ -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 (
<Sider width={220} className="!bg-white border-r border-neutral-200 flex flex-col">
<div className="h-14 flex items-center px-5 border-b border-neutral-100">
<DashboardOutlined className="text-lg text-[#2563eb] mr-2.5" />
<span className="text-base font-semibold text-neutral-900"></span>
<aside
className="shrink-0 flex flex-col h-full bg-white border-r border-neutral-200"
style={{ width: 220 }}
>
<div
className="flex items-center gap-2.5 px-4 shrink-0 border-b border-neutral-200"
style={{ height: 'var(--header-height)' }}
>
<div className="w-8 h-8 rounded-lg bg-[#2563eb] flex items-center justify-center shrink-0">
<DashboardOutlined className="text-white text-sm" />
</div>
<span className="font-semibold text-neutral-900 text-base truncate"></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']}
<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 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"
>
<div className="flex items-center gap-2 cursor-pointer hover:bg-neutral-50 rounded p-1.5">
<Avatar size={28} icon={<UserOutlined />} className="!bg-blue-100 !text-blue-500" />
<div className="min-w-0">
<div className="text-xs font-medium text-neutral-700 truncate">{user?.nickname || '管理员'}</div>
<div className="text-xs text-neutral-400">线</div>
</div>
</div>
</Dropdown>
<LogoutOutlined />
</button>
</div>
</Sider>
</aside>
)
}
+308 -81
View File
@@ -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<string, string> = {
create_tenant: '开通租户',
suspend_tenant: '暂停租户',
resume_tenant: '恢复租户',
update_tenant: '更新租户',
update_plan: '更新套餐',
create_announcement: '创建公告',
delete_announcement: '删除公告',
const actionLabel: Record<string, { text: string; bg: string; color: string }> = {
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<AdminStats | null>(null)
const [loading, setLoading] = useState(true)
@@ -26,86 +44,295 @@ const AdminDashboard = () => {
.finally(() => setLoading(false))
}, [])
if (loading) {
return <div className="h-full flex items-center justify-center"><Spin size="large" /></div>
}
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: <TeamOutlined />, color: '#2563eb', hint: '平台全部租户' },
{ label: '活跃租户', value: stats?.active_tenant ?? 0, icon: <UserOutlined />, color: '#16a34a', hint: `暂停 ${stats?.suspended_tenant ?? 0} · 将到期 ${stats?.expiring_tenant ?? 0}` },
{ label: '估算月收入', value: `¥${(stats?.monthly_income ?? 0).toLocaleString()}`, icon: <DollarOutlined />, color: '#0891b2', hint: '按在售套餐 × 正常租户估算' },
{ label: '系统可用率', value: stats?.system_uptime || '99.95%', icon: <SafetyCertificateOutlined />, color: '#7c3aed', hint: '运行正常' },
]
const 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: <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 (
<div>
<h2 className="text-lg font-semibold text-neutral-800 mb-5"></h2>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-6">
{kpiData.map((k, i) => (
<Card key={i} className="!rounded-lg" bordered={false}>
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-neutral-400">{k.label}</span>
<span className="text-lg" style={{ color: k.color }}>{k.icon}</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 className="h-full flex flex-col min-h-0 overflow-hidden bg-neutral-50">
<header
className="shrink-0 px-6 flex items-center justify-between border-b border-neutral-200 bg-white"
style={{ height: 'var(--header-height)' }}
>
<div className="flex items-center gap-3 min-w-0">
<h1 className="text-base font-semibold text-neutral-900 m-0 truncate"></h1>
<span className="text-sm text-neutral-400 whitespace-nowrap hidden sm:inline">{formatDate()}</span>
</div>
</header>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card title="套餐分布" className="!rounded-lg" bordered={false}>
{planDist.length === 0 ? (
<Empty description="暂无套餐分布数据" />
) : (
<div className="space-y-3">
{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 (
<div key={item.plan_id}>
<div className="flex justify-between text-sm mb-1">
<span className="text-neutral-700 font-medium">{item.name}</span>
<span className="text-neutral-400">{item.count} · {pct}%</span>
<div className="flex-1 min-h-0 overflow-auto px-6 py-5">
{loading ? (
<div className="h-64 flex items-center justify-center"><Spin size="large" /></div>
) : (
<div className="flex flex-col gap-5 w-full">
<div>
<h2 className="text-xl font-semibold text-neutral-900 m-0">
{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>
<div className="h-2 bg-neutral-100 rounded-full overflow-hidden">
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${pct}%` }} />
{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 (
<div key={g.month_key || g.month} className="flex-1 flex flex-col items-center gap-1 min-w-0">
<div className="w-full flex flex-col items-center justify-end gap-0.5" style={{ height: 150 }}>
<div
className="w-full max-w-[40px] rounded-t-sm bg-neutral-200"
style={{ height: totH }}
title={`累计 ${g.total_count}`}
/>
<div
className="w-full max-w-[40px] rounded-t-sm bg-[#2563eb]"
style={{ height: newH }}
title={`新增 ${g.new_count}`}
/>
</div>
<span className="text-[11px] text-neutral-400 whitespace-nowrap">{g.month}</span>
</div>
)
})}
</div>
)}
</div>
</div>
<div className="rounded-xl bg-white border border-neutral-200 shadow-sm">
<div className="px-4 pt-4 pb-3">
<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="flex items-center gap-6 px-4 pb-5">
<div className="shrink-0 w-[120px] h-[120px] relative flex items-center justify-center">
<div
className="absolute inset-0 rounded-full"
style={{ background: `conic-gradient(${conic})` }}
/>
<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>
)}
</Card>
<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>
<Card title="最近操作记录" className="!rounded-lg" bordered={false}>
<Table
dataSource={logs}
rowKey="id"
pagination={false}
size="middle"
locale={{ emptyText: <Empty description="暂无操作记录" /> }}
columns={[
{
title: '时间', dataIndex: 'created_at', key: 'created_at', width: 150,
render: (t: string) => <span className="text-xs text-neutral-500">{t ? new Date(t).toLocaleString('zh-CN') : '—'}</span>,
},
{
title: '操作', dataIndex: 'action', key: 'action',
render: (a: string) => <Tag>{actionLabel[a] || a}</Tag>,
},
{
title: '详情', dataIndex: 'detail', key: 'detail',
render: (d: string) => <span className="text-sm text-neutral-600">{d}</span>,
},
]}
/>
</Card>
{/* 最近操作 */}
<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>
)
+2
View File
@@ -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[]
}