重做平台系统运维页布局与交互
统一 56px 顶栏与现代卡片分区:服务健康、资源占用示意、操作日志列表与公告管理。
This commit is contained in:
+315
-139
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user