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

统一 56px 顶栏与现代卡片分区:服务健康、资源占用示意、操作日志列表与公告管理。
This commit is contained in:
yml2213
2026-07-15 13:56:13 +08:00
parent 362bd79008
commit e157fc0ba5
+301 -125
View File
@@ -1,33 +1,71 @@
import { useEffect, useState } from 'react'
import { Card, Table, Tag, Button, Badge, Progress, Space, message, Modal, Form, Input, Select, Spin, Empty, Popconfirm } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons'
import { useEffect, useState, type ReactNode } 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,
} from '@ant-design/icons'
import {
createAnnouncement, deleteAnnouncement, getAdminLogs, getAnnouncements, updateAnnouncement,
type Announcement, type OperationLog,
} from '@/services/api'
const services = [
{ name: 'API 服务', status: 'normal' as const },
{ name: 'WebSocket 服务', status: 'normal' as const },
{ name: '数据库', status: 'normal' as const },
{ name: '消息队列', status: 'normal' as const },
{ name: 'CDN', status: 'normal' as const },
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 statusDisplay = {
normal: { color: 'green', text: '正常' },
warning: { color: 'orange', text: '告警' },
error: { color: 'red', text: '故障' },
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 actionLabel: Record<string, string> = {
create_tenant: '开通租户',
suspend_tenant: '暂停租户',
resume_tenant: '恢复租户',
update_tenant: '更新租户',
update_plan: '更新套餐',
create_announcement: '创建公告',
delete_announcement: '删除公告',
const actionMeta: 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' },
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 = () => {
@@ -42,11 +80,12 @@ 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 loadLogs = async (page = logPage) => {
setLoadingLogs(true)
try {
const res = await getAdminLogs({ page, pageSize: 8 })
const res = await getAdminLogs({ page, pageSize: 10 })
setLogs(res.list || [])
setLogTotal(res.total)
} catch {
@@ -123,143 +162,280 @@ const Ops = () => {
}
const refreshHealth = () => {
setLoads({
cpu: mockLoad(1),
mem: mockLoad(2),
disk: mockLoad(3),
})
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 (
<div>
<h2 className="text-lg font-semibold text-neutral-800 mb-5"></h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
<Card
title="服务状态看板"
className="!rounded-lg"
bordered={false}
extra={<ReloadOutlined className="cursor-pointer text-neutral-400 hover:text-blue-500" onClick={refreshHealth} />}
<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="space-y-3">
{services.map(s => (
<div key={s.name} className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
<span className="text-sm text-neutral-700">{s.name}</span>
<Space>
<Badge status={s.status === 'normal' ? 'success' : s.status === 'warning' ? 'warning' : 'error'} />
<Tag color={statusDisplay[s.status].color}>{statusDisplay[s.status].text}</Tag>
</Space>
<div className="min-w-0">
<h1 className="text-[15px] font-semibold text-neutral-900 m-0 tracking-tight"></h1>
</div>
))}
</div>
<div className="text-xs text-neutral-400 mt-3">
· {refreshedAt.toLocaleTimeString('zh-CN')}
</div>
</Card>
<Button
icon={<ReloadOutlined />}
className="!h-8 !rounded-lg"
onClick={refreshHealth}
>
</Button>
</header>
<Card title="系统负载" className="!rounded-lg" bordered={false}>
<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]
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}
</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">
{[
{ type: 'CPU 使用率', percent: 28 },
{ type: '内存使用率', percent: 51 },
{ type: '磁盘使用率', percent: 36 },
].map(l => (
<div key={l.type}>
{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">{l.type}</span>
<span className={`font-medium ${l.percent > 80 ? 'text-red-500' : l.percent > 60 ? 'text-orange-500' : 'text-green-600'}`}>{l.percent}%</span>
<span className="text-neutral-600">{item.label}</span>
<span className="font-semibold tabular-nums" style={{ color }}>{item.percent}%</span>
</div>
<Progress percent={l.percent} showInfo={false} strokeColor={l.percent > 80 ? '#dc2626' : l.percent > 60 ? '#d97706' : '#16a34a'} />
</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>,
},
]}
<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 }}
/>
</Card>
</div>
</div>
)
})}
</div>
<p className="text-[11px] text-neutral-400 m-0 mt-4">
·
</p>
</div>
</section>
<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="暂无公告" />
{/* 日志 + 公告 */}
<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} />
) : (
<div className="space-y-3 max-h-[420px] overflow-auto">
<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 => (
<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">
<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' ? '已发布' : '草稿'}
</Tag>
</span>
</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>
<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>
</Space>
</div>
</div>
</article>
))}
</div>
)}
</Card>
</div>
</div>
</section>
</div>
</div>
<Modal
title={editing ? '编辑公告' : '新建公告'}
title={editing ? '编辑公告' : '发布公告'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => form.submit()}
confirmLoading={saving}
destroyOnClose
okText="保存"
width={520}
>
<Form form={form} layout="vertical" onFinish={handleSave} className="mt-2">
<Form.Item name="title" label="标题" rules={[{ required: true }, { max: 100 }]}>
<Input maxLength={100} />
<Form form={form} layout="vertical" onFinish={handleSave} requiredMark={false} className="mt-1">
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请输入标题' }, { max: 100 }]}>
<Input maxLength={100} size="large" placeholder="公告标题" />
</Form.Item>
<Form.Item name="content" label="内容" rules={[{ required: true }]}>
<Input.TextArea rows={4} maxLength={2000} showCount />
<Form.Item name="content" label="内容" rules={[{ required: true, message: '请输入内容' }]}>
<Input.TextArea rows={5} maxLength={2000} showCount placeholder="公告正文" />
</Form.Item>
<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>
</Modal>