完善系统运维页:补齐监控图表并贴合设计布局

按 P2-11 重组服务状态与系统负载,新增 API 请求量趋势与错误率监控示意,操作日志改为全宽表格。
This commit is contained in:
yml2213
2026-07-15 14:01:30 +08:00
parent e157fc0ba5
commit 34d3866520
+376 -235
View File
@@ -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 小时示意错误率 05% */
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,239 +219,334 @@ 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="min-w-0">
<h1 className="text-[15px] font-semibold text-neutral-900 m-0 tracking-tight"></h1>
</div>
<Button
icon={<ReloadOutlined />}
className="!h-8 !rounded-lg"
onClick={refreshHealth}
>
</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')}
<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-xl font-semibold text-neutral-900 m-0 tracking-tight"></h1>
<p className="text-sm text-neutral-500 m-0 mt-1">
</p>
</div>
<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>
</p>
<span className="text-xs text-neutral-400 whitespace-nowrap">
{formatRelative(refreshedAt)}
</span>
<Button icon={<ReloadOutlined />} size="small" className="!h-8" onClick={refreshAll}>
</Button>
</div>
</div>
</div>
{/* 服务 + 负载 */}
<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]
return (
<div
key={s.name}
className="flex items-center gap-3 p-3 rounded-xl bg-neutral-50/80 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-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={item.label}
className="rounded-lg px-4 py-3 bg-neutral-50 border border-neutral-100"
>
<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 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: c.bar }}
/>
</div>
<p className="text-[11px] text-neutral-400 m-0 mt-1.5">{item.sub}</p>
</div>
)
})}
</div>
</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="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-full rounded-full transition-all duration-500"
style={{ width: `${item.percent}%`, backgroundColor: color }}
/>
</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>
<p className="text-[11px] text-neutral-400 m-0 mt-4">
·
<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>
</div>
<span className="text-xs text-neutral-400 tabular-nums"> {logTotal} </span>
{/* 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>
<div className="flex-1 overflow-auto">
{loadingLogs ? (
<div className="py-16 text-center"><Spin /></div>
) : logs.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 => {
<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>
<div className="overflow-x-auto">
{loadingLogs ? (
<div className="py-16 text-center"><Spin /></div>
) : filteredLogs.length === 0 ? (
<Empty className="py-12" description="暂无操作日志" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<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>
)}
</div>
{logTotal > 10 && (
<div className="px-4 py-3 border-t border-neutral-100 flex justify-end">
<Pagination
size="small"
current={logPage}
total={logTotal}
pageSize={10}
onChange={p => setLogPage(p)}
showSizeChanger={false}
/>
</div>
</tbody>
</table>
)}
</div>
<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>
</div>
<Button
type="primary"
{logTotal > 10 && (
<div className="px-5 py-3 border-t border-neutral-100 flex justify-end">
<Pagination
size="small"
icon={<PlusOutlined />}
className="!h-8 !rounded-lg shrink-0"
onClick={openCreate}
>
</Button>
current={logPage}
total={logTotal}
pageSize={10}
onChange={p => setLogPage(p)}
showSizeChanger={false}
/>
</div>
<div className="flex-1 overflow-auto p-4">
{loadingAnn ? (
<div className="py-16 text-center"><Spin /></div>
) : announcements.length === 0 ? (
<Empty
className="py-12"
description="暂无公告"
image={Empty.PRESENTED_IMAGE_SIMPLE}
>
<Button type="primary" onClick={openCreate}></Button>
</Empty>
) : (
<div className="space-y-3">
{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"
>
<div className="flex items-start justify-between gap-2 mb-2">
<h3 className="text-sm font-semibold text-neutral-900 m-0 leading-snug">
{a.title}
</h3>
<span
className={`shrink-0 text-[11px] font-medium px-2 py-0.5 rounded-full ${
a.status === 'published'
? 'bg-emerald-50 text-emerald-700'
: 'bg-neutral-100 text-neutral-500'
}`}
)}
</section>
{/* 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"
onClick={openCreate}
>
</Button>
</div>
<div className="p-5">
{loadingAnn ? (
<div className="py-12 text-center"><Spin /></div>
) : announcements.length === 0 ? (
<Empty description="暂无公告" image={Empty.PRESENTED_IMAGE_SIMPLE}>
<Button type="primary" onClick={openCreate}></Button>
</Empty>
) : (
<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-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 line-clamp-2">
{a.title}
</h3>
<span
className={`shrink-0 text-[11px] font-medium px-2 py-0.5 rounded-full ${
a.status === 'published'
? 'bg-emerald-50 text-emerald-700'
: 'bg-neutral-100 text-neutral-500'
}`}
>
{a.status === 'published' ? '已发布' : '草稿'}
</span>
</div>
<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">
<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"
>
{a.status === 'published' ? '已发布' : '草稿'}
</span>
</div>
<p className="text-[13px] text-neutral-500 m-0 line-clamp-2 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">
<EditOutlined className="text-xs" />
</button>
<Popconfirm title="确认删除该公告?" onConfirm={() => handleDelete(a.id)}>
<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="编辑"
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"
>
<EditOutlined className="text-xs" />
<DeleteOutlined className="text-xs" />
</button>
<Popconfirm title="确认删除该公告?" onConfirm={() => handleDelete(a.id)}>
<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>
</Popconfirm>
</div>
</Popconfirm>
</div>
</article>
))}
</div>
)}
</div>
</div>
</article>
))}
</div>
)}
</div>
</section>
</div>