重做平台系统运维页布局与交互

统一 56px 顶栏与现代卡片分区:服务健康、资源占用示意、操作日志列表与公告管理。
This commit is contained in:
yml2213
2026-07-15 13:56:13 +08:00
parent 362bd79008
commit e157fc0ba5
+315 -139
View File
@@ -1,33 +1,71 @@
import { useEffect, useState } from 'react' import { useEffect, useState, type ReactNode } from 'react'
import { Card, Table, Tag, Button, Badge, Progress, Space, message, Modal, Form, Input, Select, Spin, Empty, Popconfirm } from 'antd' import {
import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons' 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,
} from '@ant-design/icons'
import { import {
createAnnouncement, deleteAnnouncement, getAdminLogs, getAnnouncements, updateAnnouncement, createAnnouncement, deleteAnnouncement, getAdminLogs, getAnnouncements, updateAnnouncement,
type Announcement, type OperationLog, type Announcement, type OperationLog,
} from '@/services/api' } from '@/services/api'
const services = [ type SvcStatus = 'normal' | 'warning' | 'error'
{ name: 'API 服务', status: 'normal' as const },
{ name: 'WebSocket 服务', status: 'normal' as const }, const services: { name: string; status: SvcStatus; icon: ReactNode; hint: string }[] = [
{ name: '数据库', status: 'normal' as const }, { name: 'API 服务', status: 'normal', icon: <ApiOutlined />, hint: 'HTTP / REST' },
{ name: '消息队列', status: 'normal' as const }, { name: 'WebSocket', status: 'normal', icon: <CloudServerOutlined />, hint: '实时消息' },
{ name: 'CDN', status: 'normal' as const }, { name: '数据库', status: 'normal', icon: <DatabaseOutlined />, hint: 'PostgreSQL' },
{ name: '对象存储', status: 'normal', icon: <ClusterOutlined />, hint: 'MinIO / S3' },
{ name: '静态资源', status: 'normal', icon: <GlobalOutlined />, hint: '前端与 Widget' },
] ]
const statusDisplay = { const statusUi: Record<SvcStatus, { text: string; bg: string; color: string; icon: ReactNode }> = {
normal: { color: 'green', text: '正常' }, normal: {
warning: { color: 'orange', text: '告警' }, text: '正常',
error: { color: 'red', 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 actionLabel: Record<string, string> = { const actionMeta: 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' },
update_announcement: { text: '更新公告', bg: '#fffbeb', color: '#d97706' },
}
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', second: '2-digit',
})
}
/** 示意负载:按分钟轻微波动,非真实监控 */
function mockLoad(seed: number) {
const t = Math.floor(Date.now() / 60000)
return 22 + ((t * 7 + seed * 13) % 35)
} }
const Ops = () => { const Ops = () => {
@@ -42,11 +80,12 @@ const Ops = () => {
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [form] = Form.useForm() const [form] = Form.useForm()
const [refreshedAt, setRefreshedAt] = useState(new Date()) const [refreshedAt, setRefreshedAt] = useState(new Date())
const [loads, setLoads] = useState({ cpu: 28, mem: 51, disk: 36 })
const loadLogs = async (page = logPage) => { const loadLogs = async (page = logPage) => {
setLoadingLogs(true) setLoadingLogs(true)
try { try {
const res = await getAdminLogs({ page, pageSize: 8 }) const res = await getAdminLogs({ page, pageSize: 10 })
setLogs(res.list || []) setLogs(res.list || [])
setLogTotal(res.total) setLogTotal(res.total)
} catch { } catch {
@@ -123,143 +162,280 @@ const Ops = () => {
} }
const refreshHealth = () => { const refreshHealth = () => {
setLoads({
cpu: mockLoad(1),
mem: mockLoad(2),
disk: mockLoad(3),
})
setRefreshedAt(new Date()) setRefreshedAt(new Date())
message.success('已刷新状态') loadLogs(logPage)
loadAnnouncements()
message.success('已刷新')
} }
const loadItems = [
{ label: 'CPU', percent: loads.cpu },
{ label: '内存', percent: loads.mem },
{ label: '磁盘', percent: loads.disk },
]
return ( return (
<div> <div className="h-full flex flex-col min-h-0 overflow-hidden bg-[#f4f6f9]">
<h2 className="text-lg font-semibold text-neutral-800 mb-5"></h2> <header
className="shrink-0 px-6 flex items-center justify-between border-b border-neutral-200/80 bg-white/90 backdrop-blur-sm"
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6"> style={{ height: 'var(--header-height)' }}
<Card >
title="服务状态看板" <div className="min-w-0">
className="!rounded-lg" <h1 className="text-[15px] font-semibold text-neutral-900 m-0 tracking-tight"></h1>
bordered={false} </div>
extra={<ReloadOutlined className="cursor-pointer text-neutral-400 hover:text-blue-500" onClick={refreshHealth} />} <Button
icon={<ReloadOutlined />}
className="!h-8 !rounded-lg"
onClick={refreshHealth}
> >
<div className="space-y-3">
{services.map(s => ( </Button>
<div key={s.name} className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg"> </header>
<span className="text-sm text-neutral-700">{s.name}</span>
<Space> <div className="flex-1 min-h-0 overflow-auto">
<Badge status={s.status === 'normal' ? 'success' : s.status === 'warning' ? 'warning' : 'error'} /> <div className="px-6 py-5 w-full max-w-[1280px] mx-auto flex flex-col gap-5">
<Tag color={statusDisplay[s.status].color}>{statusDisplay[s.status].text}</Tag> <p className="text-sm text-neutral-500 m-0">
</Space>
<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>
))} <div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3">
</div> {services.map(s => {
<div className="text-xs text-neutral-400 mt-3"> const st = statusUi[s.status]
· {refreshedAt.toLocaleTimeString('zh-CN')} return (
</div> <div
</Card> key={s.name}
className="flex items-center gap-3 p-3 rounded-xl bg-neutral-50/80 border border-neutral-100"
<Card title="系统负载" className="!rounded-lg" bordered={false}> >
<div className="space-y-5"> <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}
{ type: 'CPU 使用率', percent: 28 }, </div>
{ type: '内存使用率', percent: 51 }, <div className="min-w-0 flex-1">
{ type: '磁盘使用率', percent: 36 }, <div className="text-sm font-medium text-neutral-800 truncate">{s.name}</div>
].map(l => ( <div className="text-[11px] text-neutral-400 truncate">{s.hint}</div>
<div key={l.type}> </div>
<div className="flex justify-between text-sm mb-1.5"> <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}`}>
<span className="text-neutral-600">{l.type}</span> {st.icon}
<span className={`font-medium ${l.percent > 80 ? 'text-red-500' : l.percent > 60 ? 'text-orange-500' : 'text-green-600'}`}>{l.percent}%</span> {st.text}
</div> </span>
<Progress percent={l.percent} showInfo={false} strokeColor={l.percent > 80 ? '#dc2626' : l.percent > 60 ? '#d97706' : '#16a34a'} /> </div>
)
})}
</div> </div>
))}
<div className="text-xs text-neutral-400 mt-3"></div>
</div>
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card title="操作日志" className="!rounded-lg" bordered={false}>
<Table
dataSource={logs}
rowKey="id"
loading={loadingLogs}
pagination={{
current: logPage,
total: logTotal,
pageSize: 8,
size: 'small',
onChange: p => setLogPage(p),
}}
size="small"
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) => actionLabel[a] || a,
},
{
title: '详情', dataIndex: 'detail', key: 'detail',
render: (d: string) => <span className="text-neutral-500 text-xs">{d}</span>,
},
]}
/>
</Card>
<Card
title="平台公告"
className="!rounded-lg"
bordered={false}
extra={<Button type="primary" size="small" icon={<PlusOutlined />} onClick={openCreate}></Button>}
>
{loadingAnn ? (
<div className="py-10 text-center"><Spin /></div>
) : announcements.length === 0 ? (
<Empty description="暂无公告" />
) : (
<div className="space-y-3 max-h-[420px] overflow-auto">
{announcements.map(a => (
<div key={a.id} className="p-3 border border-neutral-100 rounded-lg">
<div className="flex items-center justify-between mb-1 gap-2">
<span className="text-sm font-medium text-neutral-700 truncate">{a.title}</span>
<Tag color={a.status === 'published' ? 'green' : 'default'} className="text-xs shrink-0">
{a.status === 'published' ? '已发布' : '草稿'}
</Tag>
</div>
<p className="text-xs text-neutral-400 mb-2 line-clamp-2">{a.content || '—'}</p>
<div className="flex items-center justify-between">
<span className="text-xs text-neutral-300">{a.created_at ? new Date(a.created_at).toLocaleString('zh-CN') : '—'}</span>
<Space size={0}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(a)}></Button>
<Popconfirm title="确认删除公告?" onConfirm={() => handleDelete(a.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
</div>
</div>
))}
</div> </div>
)}
</Card> <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>
<p className="text-[11px] text-neutral-400 m-0 mt-4">
·
</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>
</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 => {
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">
<span
className="inline-flex px-1.5 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>
)
})}
</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>
)}
</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"
size="small"
icon={<PlusOutlined />}
className="!h-8 !rounded-lg shrink-0"
onClick={openCreate}
>
</Button>
</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'
}`}
>
{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">
<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>
<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>
</div>
</article>
))}
</div>
)}
</div>
</div>
</section>
</div>
</div> </div>
<Modal <Modal
title={editing ? '编辑公告' : '新建公告'} title={editing ? '编辑公告' : '发布公告'}
open={modalOpen} open={modalOpen}
onCancel={() => setModalOpen(false)} onCancel={() => setModalOpen(false)}
onOk={() => form.submit()} onOk={() => form.submit()}
confirmLoading={saving} confirmLoading={saving}
destroyOnClose destroyOnClose
okText="保存"
width={520}
> >
<Form form={form} layout="vertical" onFinish={handleSave} className="mt-2"> <Form form={form} layout="vertical" onFinish={handleSave} requiredMark={false} className="mt-1">
<Form.Item name="title" label="标题" rules={[{ required: true }, { max: 100 }]}> <Form.Item name="title" label="标题" rules={[{ required: true, message: '请输入标题' }, { max: 100 }]}>
<Input maxLength={100} /> <Input maxLength={100} size="large" placeholder="公告标题" />
</Form.Item> </Form.Item>
<Form.Item name="content" label="内容" rules={[{ required: true }]}> <Form.Item name="content" label="内容" rules={[{ required: true, message: '请输入内容' }]}>
<Input.TextArea rows={4} maxLength={2000} showCount /> <Input.TextArea rows={5} maxLength={2000} showCount placeholder="公告正文" />
</Form.Item> </Form.Item>
<Form.Item name="status" label="状态" rules={[{ required: true }]}> <Form.Item name="status" label="状态" rules={[{ required: true }]}>
<Select options={[{ value: 'draft', label: '草稿' }, { value: 'published', label: '发布' }]} /> <Select
size="large"
options={[
{ value: 'draft', label: '草稿' },
{ value: 'published', label: '立即发布' },
]}
/>
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>