完善系统运维页:补齐监控图表并贴合设计布局
按 P2-11 重组服务状态与系统负载,新增 API 请求量趋势与错误率监控示意,操作日志改为全宽表格。
This commit is contained in:
+303
-162
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Button, message, Modal, Form, Input, Select, Spin, Empty, Popconfirm, Pagination,
|
||||
} from 'antd'
|
||||
import {
|
||||
PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined,
|
||||
CloudServerOutlined, ApiOutlined, DatabaseOutlined, ClusterOutlined,
|
||||
GlobalOutlined, CheckCircleFilled, WarningFilled, CloseCircleFilled,
|
||||
CloudServerOutlined, AlertOutlined, FileTextOutlined,
|
||||
NotificationOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
createAnnouncement, deleteAnnouncement, getAdminLogs, getAnnouncements, updateAnnouncement,
|
||||
@@ -14,35 +14,14 @@ import {
|
||||
|
||||
type SvcStatus = 'normal' | 'warning' | 'error'
|
||||
|
||||
const services: { name: string; status: SvcStatus; icon: ReactNode; hint: string }[] = [
|
||||
{ name: 'API 服务', status: 'normal', icon: <ApiOutlined />, hint: 'HTTP / REST' },
|
||||
{ name: 'WebSocket', status: 'normal', icon: <CloudServerOutlined />, hint: '实时消息' },
|
||||
{ name: '数据库', status: 'normal', icon: <DatabaseOutlined />, hint: 'PostgreSQL' },
|
||||
{ name: '对象存储', status: 'normal', icon: <ClusterOutlined />, hint: 'MinIO / S3' },
|
||||
{ name: '静态资源', status: 'normal', icon: <GlobalOutlined />, hint: '前端与 Widget' },
|
||||
const services: { name: string; status: SvcStatus }[] = [
|
||||
{ name: 'API服务', status: 'normal' },
|
||||
{ name: 'WebSocket', status: 'normal' },
|
||||
{ name: '数据库', status: 'normal' },
|
||||
{ name: '消息队列', status: 'normal' },
|
||||
{ name: 'CDN', status: 'normal' },
|
||||
]
|
||||
|
||||
const statusUi: Record<SvcStatus, { text: string; bg: string; color: string; icon: ReactNode }> = {
|
||||
normal: {
|
||||
text: '正常',
|
||||
bg: 'bg-emerald-50',
|
||||
color: 'text-emerald-700',
|
||||
icon: <CheckCircleFilled className="text-emerald-500" />,
|
||||
},
|
||||
warning: {
|
||||
text: '告警',
|
||||
bg: 'bg-amber-50',
|
||||
color: 'text-amber-700',
|
||||
icon: <WarningFilled className="text-amber-500" />,
|
||||
},
|
||||
error: {
|
||||
text: '故障',
|
||||
bg: 'bg-red-50',
|
||||
color: 'text-red-700',
|
||||
icon: <CloseCircleFilled className="text-red-500" />,
|
||||
},
|
||||
}
|
||||
|
||||
const actionMeta: Record<string, { text: string; bg: string; color: string }> = {
|
||||
create_tenant: { text: '开通租户', bg: '#dbeafe', color: '#2563eb' },
|
||||
suspend_tenant: { text: '暂停租户', bg: '#fef2f2', color: '#dc2626' },
|
||||
@@ -62,16 +41,47 @@ function formatTime(iso?: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 示意负载:按分钟轻微波动,非真实监控 */
|
||||
function mockLoad(seed: number) {
|
||||
const t = Math.floor(Date.now() / 60000)
|
||||
return 22 + ((t * 7 + seed * 13) % 35)
|
||||
function formatRelative(d: Date) {
|
||||
const sec = Math.max(0, Math.floor((Date.now() - d.getTime()) / 1000))
|
||||
if (sec < 60) return '刚刚'
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)} 分钟前`
|
||||
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
/** 近 24 小时示意请求量(工作时段高峰) */
|
||||
function genApiTrend(seed: number) {
|
||||
const bars: { hour: number; value: number }[] = []
|
||||
for (let h = 0; h < 24; h++) {
|
||||
const work = h >= 8 && h <= 18
|
||||
const base = work ? 55 + Math.sin((h - 8) / 10 * Math.PI) * 40 : 8 + (h % 5) * 2
|
||||
const noise = ((seed * 17 + h * 31) % 15) - 7
|
||||
bars.push({ hour: h, value: Math.max(5, Math.round(base + noise)) })
|
||||
}
|
||||
return bars
|
||||
}
|
||||
|
||||
/** 近 24 小时示意错误率 0–5% */
|
||||
function genErrorRate(seed: number) {
|
||||
const points: number[] = []
|
||||
for (let h = 0; h < 24; h++) {
|
||||
const spike = h === 9 || h === 10 ? 2.2 : 0
|
||||
const v = 0.4 + spike + ((seed + h * 3) % 10) / 20
|
||||
points.push(Math.min(4.8, Math.max(0.1, Number(v.toFixed(2)))))
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
function loadColor(p: number) {
|
||||
if (p >= 80) return { text: '#dc2626', bar: '#dc2626' }
|
||||
if (p >= 55) return { text: '#d97706', bar: '#d97706' }
|
||||
return { text: '#16a34a', bar: '#16a34a' }
|
||||
}
|
||||
|
||||
const Ops = () => {
|
||||
const [logs, setLogs] = useState<OperationLog[]>([])
|
||||
const [logTotal, setLogTotal] = useState(0)
|
||||
const [logPage, setLogPage] = useState(1)
|
||||
const [logSearch, setLogSearch] = useState('')
|
||||
const [announcements, setAnnouncements] = useState<Announcement[]>([])
|
||||
const [loadingLogs, setLoadingLogs] = useState(false)
|
||||
const [loadingAnn, setLoadingAnn] = useState(false)
|
||||
@@ -80,7 +90,31 @@ const Ops = () => {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
const [refreshedAt, setRefreshedAt] = useState(new Date())
|
||||
const [loads, setLoads] = useState({ cpu: 28, mem: 51, disk: 36 })
|
||||
const [seed, setSeed] = useState(0)
|
||||
const [loads, setLoads] = useState({ cpu: 23, mem: 61, disk: 45 })
|
||||
|
||||
const apiTrend = useMemo(() => genApiTrend(seed), [seed])
|
||||
const errorRates = useMemo(() => genErrorRate(seed), [seed])
|
||||
const maxApi = Math.max(...apiTrend.map(b => b.value), 1)
|
||||
const maxErr = 5
|
||||
|
||||
const errorPolyline = useMemo(() => {
|
||||
const w = 200
|
||||
const h = 100
|
||||
return errorRates
|
||||
.map((v, i) => {
|
||||
const x = (i / 23) * w
|
||||
const y = h - (v / maxErr) * (h - 8) - 4
|
||||
return `${x},${y}`
|
||||
})
|
||||
.join(' ')
|
||||
}, [errorRates, maxErr])
|
||||
|
||||
const errorArea = useMemo(() => {
|
||||
const w = 200
|
||||
const h = 100
|
||||
return `${errorPolyline} ${w},${h} 0,${h}`
|
||||
}, [errorPolyline])
|
||||
|
||||
const loadLogs = async (page = logPage) => {
|
||||
setLoadingLogs(true)
|
||||
@@ -116,6 +150,16 @@ const Ops = () => {
|
||||
loadLogs(logPage)
|
||||
}, [logPage])
|
||||
|
||||
const filteredLogs = useMemo(() => {
|
||||
const q = logSearch.trim().toLowerCase()
|
||||
if (!q) return logs
|
||||
return logs.filter(l =>
|
||||
(l.detail || '').toLowerCase().includes(q)
|
||||
|| (l.action || '').toLowerCase().includes(q)
|
||||
|| (actionMeta[l.action]?.text || '').includes(q),
|
||||
)
|
||||
}, [logs, logSearch])
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
form.resetFields()
|
||||
@@ -161,11 +205,13 @@ const Ops = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const refreshHealth = () => {
|
||||
const refreshAll = () => {
|
||||
const s = seed + 1
|
||||
setSeed(s)
|
||||
setLoads({
|
||||
cpu: mockLoad(1),
|
||||
mem: mockLoad(2),
|
||||
disk: mockLoad(3),
|
||||
cpu: 18 + ((s * 11) % 40),
|
||||
mem: 40 + ((s * 7) % 35),
|
||||
disk: 35 + ((s * 5) % 30),
|
||||
})
|
||||
setRefreshedAt(new Date())
|
||||
loadLogs(logPage)
|
||||
@@ -173,147 +219,250 @@ const Ops = () => {
|
||||
message.success('已刷新')
|
||||
}
|
||||
|
||||
const loadItems = [
|
||||
{ label: 'CPU', percent: loads.cpu },
|
||||
{ label: '内存', percent: loads.mem },
|
||||
{ label: '磁盘', percent: loads.disk },
|
||||
const loadCards = [
|
||||
{ label: 'CPU', percent: loads.cpu, sub: '8核 / 使用中' },
|
||||
{ label: '内存', percent: loads.mem, sub: '16GB / 使用中' },
|
||||
{ label: '磁盘', percent: loads.disk, sub: '500GB / 使用中' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col min-h-0 overflow-hidden bg-[#f4f6f9]">
|
||||
<header
|
||||
className="shrink-0 px-6 flex items-center justify-between border-b border-neutral-200/80 bg-white/90 backdrop-blur-sm"
|
||||
style={{ height: 'var(--header-height)' }}
|
||||
>
|
||||
<div className="h-full flex flex-col min-h-0 overflow-hidden bg-neutral-50">
|
||||
{/* 页头:对齐设计 — 标题区 + 状态 */}
|
||||
<div className="shrink-0 px-6 pt-5 pb-4 bg-white border-b border-neutral-200">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-[15px] font-semibold text-neutral-900 m-0 tracking-tight">系统运维</h1>
|
||||
<h1 className="text-xl font-semibold text-neutral-900 m-0 tracking-tight">系统运维</h1>
|
||||
<p className="text-sm text-neutral-500 m-0 mt-1">
|
||||
监控平台运行状态、查看操作日志、管理平台公告
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
className="!h-8 !rounded-lg"
|
||||
onClick={refreshHealth}
|
||||
>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs bg-[#f0fdf4] text-[#16a34a] border border-[#bbf7d0]">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#16a34a]" />
|
||||
系统正常运行
|
||||
</span>
|
||||
<span className="text-xs text-neutral-400 whitespace-nowrap">
|
||||
最后更新:{formatRelative(refreshedAt)}
|
||||
</span>
|
||||
<Button icon={<ReloadOutlined />} size="small" className="!h-8" onClick={refreshAll}>
|
||||
刷新
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
<div className="px-6 py-5 w-full max-w-[1280px] mx-auto flex flex-col gap-5">
|
||||
<p className="text-sm text-neutral-500 m-0">
|
||||
查看服务健康、操作审计与平台公告。负载指标为开发环境示意,生产可对接主机监控。
|
||||
<span className="text-neutral-400 ml-2">
|
||||
更新于 {refreshedAt.toLocaleTimeString('zh-CN')}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{/* 服务 + 负载 */}
|
||||
<section className="grid grid-cols-1 lg:grid-cols-5 gap-4">
|
||||
<div className="lg:col-span-3 rounded-2xl bg-white border border-neutral-200/80 shadow-[0_1px_2px_rgba(15,23,42,0.04)] p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-[15px] font-semibold text-neutral-900 m-0">服务状态</h2>
|
||||
<span className="text-xs text-emerald-600 font-medium flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
全部正常
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
{services.map(s => {
|
||||
const st = statusUi[s.status]
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-auto px-6 py-5">
|
||||
<div className="flex flex-col gap-5 w-full">
|
||||
|
||||
{/* 1. 服务状态 + 系统负载 */}
|
||||
<section className="rounded-xl bg-white border border-neutral-200 p-5 shadow-sm">
|
||||
<div className="flex items-center gap-1.5 mb-3">
|
||||
<CloudServerOutlined className="text-[#2563eb]" />
|
||||
<span className="font-semibold text-[15px] text-neutral-800">服务状态</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-2 mb-5">
|
||||
{services.map(s => (
|
||||
<div key={s.name} className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-[#16a34a]" />
|
||||
<span className="text-sm text-neutral-700">{s.name}</span>
|
||||
<span className="text-xs text-[#16a34a]">正常</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 mb-3">
|
||||
<span className="font-semibold text-[15px] text-neutral-800">系统负载</span>
|
||||
<span className="text-[11px] text-neutral-400 ml-1">开发环境示意</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{loadCards.map(item => {
|
||||
const c = loadColor(item.percent)
|
||||
return (
|
||||
<div
|
||||
key={s.name}
|
||||
className="flex items-center gap-3 p-3 rounded-xl bg-neutral-50/80 border border-neutral-100"
|
||||
key={item.label}
|
||||
className="rounded-lg px-4 py-3 bg-neutral-50 border border-neutral-100"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-lg bg-white border border-neutral-100 flex items-center justify-center text-neutral-600 shrink-0">
|
||||
{s.icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-neutral-800 truncate">{s.name}</div>
|
||||
<div className="text-[11px] text-neutral-400 truncate">{s.hint}</div>
|
||||
</div>
|
||||
<span className={`shrink-0 inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ${st.bg} ${st.color}`}>
|
||||
{st.icon}
|
||||
{st.text}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-neutral-600">{item.label}</span>
|
||||
<span className="text-sm font-semibold tabular-nums" style={{ color: c.text }}>
|
||||
{item.percent}%
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2 rounded-2xl bg-white border border-neutral-200/80 shadow-[0_1px_2px_rgba(15,23,42,0.04)] p-5">
|
||||
<h2 className="text-[15px] font-semibold text-neutral-900 m-0 mb-4">资源占用</h2>
|
||||
<div className="space-y-5">
|
||||
{loadItems.map(item => {
|
||||
const color = item.percent > 80 ? '#dc2626' : item.percent > 60 ? '#d97706' : '#16a34a'
|
||||
return (
|
||||
<div key={item.label}>
|
||||
<div className="flex justify-between text-sm mb-1.5">
|
||||
<span className="text-neutral-600">{item.label}</span>
|
||||
<span className="font-semibold tabular-nums" style={{ color }}>{item.percent}%</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-neutral-100 overflow-hidden">
|
||||
<div className="h-2 rounded-full bg-neutral-200 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{ width: `${item.percent}%`, backgroundColor: color }}
|
||||
style={{ width: `${item.percent}%`, backgroundColor: c.bar }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-neutral-400 m-0 mt-1.5">{item.sub}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[11px] text-neutral-400 m-0 mt-4">
|
||||
示意数据 · 点击右上角刷新会轻微波动
|
||||
</section>
|
||||
|
||||
{/* 2. API 趋势 + 错误率 */}
|
||||
<section className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
<div className="rounded-xl bg-white border border-neutral-200 p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<CloudServerOutlined className="text-[#2563eb] shrink-0" />
|
||||
<span className="font-semibold text-[15px] text-neutral-800">API请求量趋势</span>
|
||||
</div>
|
||||
<span className="text-xs text-neutral-400 whitespace-nowrap">近24小时 · 示意</span>
|
||||
</div>
|
||||
<div className="relative" style={{ height: 160 }}>
|
||||
<div className="absolute left-0 top-0 bottom-6 flex flex-col justify-between w-9 text-[10px] text-neutral-400">
|
||||
<span>高</span>
|
||||
<span>中</span>
|
||||
<span>0</span>
|
||||
</div>
|
||||
<div
|
||||
className="absolute left-10 right-0 top-0 bottom-6 flex items-end gap-0.5 border-b border-neutral-200"
|
||||
>
|
||||
{apiTrend.map(b => (
|
||||
<div
|
||||
key={b.hour}
|
||||
className="flex-1 flex flex-col justify-end h-full min-w-0"
|
||||
title={`${String(b.hour).padStart(2, '0')}:00 · 相对量 ${b.value}`}
|
||||
>
|
||||
<div
|
||||
className="w-full rounded-t-sm transition-all"
|
||||
style={{
|
||||
height: `${Math.max(4, (b.value / maxApi) * 100)}%`,
|
||||
backgroundColor: b.value > maxApi * 0.55 ? '#2563eb' : '#93c5fd',
|
||||
minHeight: 2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-10 right-0 flex justify-between text-[10px] text-neutral-400 h-5 items-end">
|
||||
<span>00</span>
|
||||
<span>06</span>
|
||||
<span>12</span>
|
||||
<span>18</span>
|
||||
<span>23</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-white border border-neutral-200 p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<AlertOutlined className="text-[#d97706] shrink-0" />
|
||||
<span className="font-semibold text-[15px] text-neutral-800">错误率监控</span>
|
||||
</div>
|
||||
<span className="text-xs text-neutral-400 whitespace-nowrap">近24小时 · 示意</span>
|
||||
</div>
|
||||
<div className="relative" style={{ height: 160 }}>
|
||||
<div className="absolute left-0 top-0 bottom-6 flex flex-col justify-between w-9 text-[10px] text-neutral-400">
|
||||
<span>5%</span>
|
||||
<span>2.5%</span>
|
||||
<span>0%</span>
|
||||
</div>
|
||||
<svg
|
||||
className="absolute left-10 right-0 top-0 bottom-6"
|
||||
style={{ width: 'calc(100% - 2.5rem)', height: 'calc(100% - 1.5rem)' }}
|
||||
viewBox="0 0 200 100"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{/* 阈值线 2% ≈ y=60 */}
|
||||
<line
|
||||
x1="0" y1="60" x2="200" y2="60"
|
||||
stroke="#d97706" strokeWidth="0.6" strokeDasharray="3,3" opacity="0.55"
|
||||
/>
|
||||
<polygon points={errorArea} fill="#dc2626" opacity="0.08" />
|
||||
<polyline
|
||||
points={errorPolyline}
|
||||
fill="none"
|
||||
stroke="#dc2626"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute bottom-0 left-10 right-0 flex justify-between text-[10px] text-neutral-400 h-5 items-end">
|
||||
<span>00</span>
|
||||
<span>06</span>
|
||||
<span>12</span>
|
||||
<span>18</span>
|
||||
<span>23</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-neutral-400 m-0 mt-1">
|
||||
虚线为 2% 告警阈值 · 当前峰值约 {Math.max(...errorRates).toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 日志 + 公告 */}
|
||||
<section className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="rounded-2xl bg-white border border-neutral-200/80 shadow-[0_1px_2px_rgba(15,23,42,0.04)] overflow-hidden flex flex-col min-h-[420px]">
|
||||
<div className="px-5 py-4 border-b border-neutral-100 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-semibold text-neutral-900 m-0">操作日志</h2>
|
||||
<p className="text-xs text-neutral-400 m-0 mt-0.5">平台管理审计记录</p>
|
||||
{/* 3. 操作日志(全宽表格) */}
|
||||
<section className="rounded-xl bg-white border border-neutral-200 shadow-sm overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-3.5 border-b border-neutral-200 gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<FileTextOutlined className="text-[#2563eb]" />
|
||||
<span className="font-semibold text-[15px] text-neutral-800">操作日志</span>
|
||||
<span className="text-xs text-neutral-400 ml-1">共 {logTotal} 条</span>
|
||||
</div>
|
||||
<span className="text-xs text-neutral-400 tabular-nums">共 {logTotal} 条</span>
|
||||
<div className="flex items-center h-8 px-2.5 rounded-lg bg-neutral-100 border border-neutral-200">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索日志..."
|
||||
value={logSearch}
|
||||
onChange={e => setLogSearch(e.target.value)}
|
||||
className="bg-transparent border-0 outline-none text-sm text-neutral-800 w-36 placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
{loadingLogs ? (
|
||||
<div className="py-16 text-center"><Spin /></div>
|
||||
) : logs.length === 0 ? (
|
||||
<Empty className="py-12" description="暂无日志" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
) : filteredLogs.length === 0 ? (
|
||||
<Empty className="py-12" description="暂无操作日志" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
) : (
|
||||
<ul className="m-0 p-0 list-none divide-y divide-neutral-50">
|
||||
{logs.map(log => {
|
||||
<table className="w-full min-w-[720px] border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-neutral-50 text-[11px] font-semibold text-neutral-500">
|
||||
<th className="text-left px-5 py-2.5 border-b border-neutral-200 w-40">时间</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-32">IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredLogs.map(log => {
|
||||
const meta = actionMeta[log.action] || {
|
||||
text: log.action, bg: '#f1f5f9', color: '#64748b',
|
||||
}
|
||||
return (
|
||||
<li key={log.id} className="px-5 py-3 hover:bg-neutral-50/80">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<tr key={log.id} className="hover:bg-neutral-50">
|
||||
<td className="px-5 py-3 border-b border-neutral-100 text-xs text-neutral-500 whitespace-nowrap tabular-nums">
|
||||
{formatTime(log.created_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 border-b border-neutral-100">
|
||||
<span
|
||||
className="inline-flex px-1.5 py-0.5 rounded text-[11px] font-medium"
|
||||
className="inline-flex px-2 py-0.5 rounded text-[11px] font-medium"
|
||||
style={{ backgroundColor: meta.bg, color: meta.color }}
|
||||
>
|
||||
{meta.text}
|
||||
</span>
|
||||
<span className="text-[11px] text-neutral-400 tabular-nums">
|
||||
{formatTime(log.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-700 m-0 line-clamp-2">{log.detail || '—'}</p>
|
||||
{log.ip && (
|
||||
<p className="text-[11px] text-neutral-400 m-0 mt-1 font-mono">{log.ip}</p>
|
||||
)}
|
||||
</li>
|
||||
</td>
|
||||
<td className="px-4 py-3 border-b border-neutral-100 text-sm text-neutral-700 max-w-md truncate">
|
||||
{log.detail || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 border-b border-neutral-100 text-xs text-neutral-400 font-mono">
|
||||
{log.ip || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
{logTotal > 10 && (
|
||||
<div className="px-4 py-3 border-t border-neutral-100 flex justify-end">
|
||||
<div className="px-5 py-3 border-t border-neutral-100 flex justify-end">
|
||||
<Pagination
|
||||
size="small"
|
||||
current={logPage}
|
||||
@@ -324,44 +473,41 @@ const Ops = () => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="rounded-2xl bg-white border border-neutral-200/80 shadow-[0_1px_2px_rgba(15,23,42,0.04)] overflow-hidden flex flex-col min-h-[420px]">
|
||||
<div className="px-5 py-4 border-b border-neutral-100 flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-semibold text-neutral-900 m-0">平台公告</h2>
|
||||
<p className="text-xs text-neutral-400 m-0 mt-0.5">面向租户的系统通知</p>
|
||||
{/* 4. 平台公告 */}
|
||||
<section className="rounded-xl bg-white border border-neutral-200 shadow-sm overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-3.5 border-b border-neutral-200">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<NotificationOutlined className="text-[#2563eb]" />
|
||||
<span className="font-semibold text-[15px] text-neutral-800">平台公告</span>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
className="!h-8 !rounded-lg shrink-0"
|
||||
className="!h-8 !rounded-lg"
|
||||
onClick={openCreate}
|
||||
>
|
||||
发布公告
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<div className="p-5">
|
||||
{loadingAnn ? (
|
||||
<div className="py-16 text-center"><Spin /></div>
|
||||
<div className="py-12 text-center"><Spin /></div>
|
||||
) : announcements.length === 0 ? (
|
||||
<Empty
|
||||
className="py-12"
|
||||
description="暂无公告"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
>
|
||||
<Empty description="暂无公告" image={Empty.PRESENTED_IMAGE_SIMPLE}>
|
||||
<Button type="primary" onClick={openCreate}>写第一条公告</Button>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{announcements.map(a => (
|
||||
<article
|
||||
key={a.id}
|
||||
className="rounded-xl border border-neutral-100 bg-neutral-50/50 p-4 hover:border-neutral-200 transition-colors"
|
||||
className="rounded-xl border border-neutral-200 bg-neutral-50/40 p-4 flex flex-col"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 m-0 leading-snug">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 m-0 leading-snug line-clamp-2">
|
||||
{a.title}
|
||||
</h3>
|
||||
<span
|
||||
@@ -374,19 +520,16 @@ const Ops = () => {
|
||||
{a.status === 'published' ? '已发布' : '草稿'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[13px] text-neutral-500 m-0 line-clamp-2 leading-relaxed">
|
||||
<p className="text-[13px] text-neutral-500 m-0 line-clamp-2 flex-1 leading-relaxed">
|
||||
{a.content || '—'}
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-3 pt-2 border-t border-neutral-100/80">
|
||||
<span className="text-[11px] text-neutral-400">
|
||||
{formatTime(a.created_at)}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center justify-between mt-3 pt-2 border-t border-neutral-100">
|
||||
<span className="text-[11px] text-neutral-400">{formatTime(a.created_at)}</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(a)}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center text-neutral-400 hover:text-[#2563eb] hover:bg-blue-50 border-0 bg-transparent cursor-pointer"
|
||||
title="编辑"
|
||||
>
|
||||
<EditOutlined className="text-xs" />
|
||||
</button>
|
||||
@@ -394,7 +537,6 @@ const Ops = () => {
|
||||
<button
|
||||
type="button"
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center text-neutral-400 hover:text-red-500 hover:bg-red-50 border-0 bg-transparent cursor-pointer"
|
||||
title="删除"
|
||||
>
|
||||
<DeleteOutlined className="text-xs" />
|
||||
</button>
|
||||
@@ -406,7 +548,6 @@ const Ops = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user