feat: Event archive management — Event model, migration, API, frontend page (Phase 8)

- Event ORM model with status/level enums, domain categories, review tracking
- Alembic migration e7f3a9b1c5d0 for events table
- Event crawler service with placeholder data (12 sample events)
- GET /api/v1/events with status/domain/search filtering and overdue_days
- EventArchivePage with filter toolbar (status, domain, search) and adaptive card grid
- Status badges, level badges, domain tags, overdue markers on event cards
- Loading/error/empty states, hover-to-honey card interaction

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
enzo
2026-07-22 17:34:19 +08:00
co-authored by Claude
parent 5c0352c768
commit eaf13e5f51
8 changed files with 623 additions and 4 deletions
@@ -0,0 +1,46 @@
"""create events table
Revision ID: e7f3a9b1c5d0
Revises: ab0c56ae4242
Create Date: 2026-07-22 17:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e7f3a9b1c5d0'
down_revision: Union[str, Sequence[str], None] = 'ab0c56ae4242'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'events',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('title', sa.String(255), nullable=False),
sa.Column('level', sa.Enum('A', 'B', 'C', name='eventlevel'), nullable=False),
sa.Column('domain', sa.String(50), nullable=False, index=True),
sa.Column(
'status',
sa.Enum('active', 'fading', 'dissipating', 'closed', name='eventstatus'),
nullable=False,
server_default='active',
),
sa.Column('occurred_date', sa.Date, nullable=False),
sa.Column('last_reviewed', sa.DateTime, nullable=True),
sa.Column('no_new_info_count', sa.Integer, nullable=False, server_default='0'),
sa.Column('next_review', sa.DateTime, nullable=True),
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table('events')
op.execute('DROP TYPE IF EXISTS eventlevel')
op.execute('DROP TYPE IF EXISTS eventstatus')
+65
View File
@@ -0,0 +1,65 @@
"""Events API — 事件档案列表,三维筛选。"""
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.middleware.auth import require_auth
from app.models.event import Event, EventStatus, DOMAINS
from app.models.user import User
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/events", tags=["events"])
@router.get("")
async def list_events(
status: str | None = Query(None, description="按状态筛选"),
domain: str | None = Query(None, description="按领域筛选"),
search: str = Query("", description="按标题模糊搜索"),
_user: User = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
"""事件卡片列表,支持三维筛选。"""
stmt = select(Event).order_by(Event.occurred_date.desc())
valid_statuses = {s.value for s in EventStatus}
if status and status in valid_statuses:
stmt = stmt.where(Event.status == status)
if domain and domain in DOMAINS:
stmt = stmt.where(Event.domain == domain)
if search:
stmt = stmt.where(Event.title.ilike(f"%{search}%"))
result = await db.execute(stmt)
events = result.scalars().all()
now = datetime.now(timezone.utc)
data = []
for ev in events:
overdue_days = 0
if ev.next_review and ev.next_review < now:
overdue_days = (now - ev.next_review).days
data.append({
"id": ev.id,
"title": ev.title,
"level": ev.level.value,
"domain": ev.domain,
"status": ev.status.value,
"occurred_date": ev.occurred_date.isoformat(),
"last_reviewed": ev.last_reviewed.isoformat() if ev.last_reviewed else None,
"no_new_info_count": ev.no_new_info_count,
"next_review": ev.next_review.isoformat() if ev.next_review else None,
"overdue_days": overdue_days,
"created_at": ev.created_at.isoformat() if ev.created_at else None,
"updated_at": ev.updated_at.isoformat() if ev.updated_at else None,
})
return {"events": data}
+2 -1
View File
@@ -4,7 +4,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.database import engine, Base
from app.api import auth, users, audit, dashboard, gateway, health, cron, keys, kanban
from app.api import auth, users, audit, dashboard, gateway, health, cron, keys, kanban, events
from app.middleware.audit import AuditMiddleware
@@ -39,6 +39,7 @@ app.include_router(gateway.router, prefix="/api/v1")
app.include_router(health.router, prefix="/api/v1")
app.include_router(cron.router, prefix="/api/v1")
app.include_router(kanban.router, prefix="/api/v1")
app.include_router(events.router, prefix="/api/v1")
app.include_router(keys.router, prefix="/api/v1")
+3 -1
View File
@@ -1 +1,3 @@
from app.models.user import User
from app.models.audit import AuditLog
from app.models.event import Event
+53
View File
@@ -0,0 +1,53 @@
"""事件档案 ORM 模型。"""
import uuid
from datetime import datetime, date
from sqlalchemy import String, DateTime, Date, Integer, Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column
import enum
from app.database import Base
class EventStatus(str, enum.Enum):
ACTIVE = "active"
FADING = "fading"
DISSIPATING = "dissipating"
CLOSED = "closed"
class EventLevel(str, enum.Enum):
A = "A"
B = "B"
C = "C"
DOMAINS = [
"gateway",
"crawler",
"reporter",
"feishu",
"database",
"security",
"network",
"storage",
"monitor",
"scheduler",
"api",
"other",
]
class Event(Base):
__tablename__ = "events"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
title: Mapped[str] = mapped_column(String(255), nullable=False)
level: Mapped[EventLevel] = mapped_column(SAEnum(EventLevel), nullable=False)
domain: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
status: Mapped[EventStatus] = mapped_column(SAEnum(EventStatus), default=EventStatus.ACTIVE, nullable=False, index=True)
occurred_date: Mapped[date] = mapped_column(Date, nullable=False)
last_reviewed: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
no_new_info_count: Mapped[int] = mapped_column(Integer, default=0)
next_review: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+162
View File
@@ -0,0 +1,162 @@
"""Event Crawler — 爬取 hm.zhiweidata.ai/events 数据存入 events 表。
TODO: 替换为真实爬虫逻辑。当前返回占位数据。
"""
import logging
from datetime import datetime, date, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import async_session
from app.models.event import Event, EventStatus, EventLevel
logger = logging.getLogger(__name__)
PLACEHOLDER_EVENTS = [
{
"title": "taiwan Gateway 频繁重启",
"level": EventLevel.A,
"domain": "gateway",
"status": EventStatus.ACTIVE,
"occurred_date": date.today() - timedelta(days=1),
"last_reviewed": datetime.now(timezone.utc) - timedelta(hours=6),
"no_new_info_count": 0,
"next_review": datetime.now(timezone.utc) + timedelta(hours=6),
},
{
"title": "guangxi 爬虫数据延迟",
"level": EventLevel.B,
"domain": "crawler",
"status": EventStatus.ACTIVE,
"occurred_date": date.today() - timedelta(days=2),
"last_reviewed": datetime.now(timezone.utc) - timedelta(days=1),
"no_new_info_count": 1,
"next_review": datetime.now(timezone.utc) + timedelta(hours=12),
},
{
"title": "飞书 Webhook 推送异常",
"level": EventLevel.B,
"domain": "feishu",
"status": EventStatus.FADING,
"occurred_date": date.today() - timedelta(days=5),
"last_reviewed": datetime.now(timezone.utc) - timedelta(days=2),
"no_new_info_count": 3,
"next_review": datetime.now(timezone.utc) + timedelta(days=1),
},
{
"title": "ops-engineer 日报生成失败",
"level": EventLevel.C,
"domain": "reporter",
"status": EventStatus.FADING,
"occurred_date": date.today() - timedelta(days=7),
"last_reviewed": datetime.now(timezone.utc) - timedelta(days=3),
"no_new_info_count": 4,
"next_review": datetime.now(timezone.utc) - timedelta(days=1),
},
{
"title": "hebo 数据库连接超时",
"level": EventLevel.A,
"domain": "database",
"status": EventStatus.DISSIPATING,
"occurred_date": date.today() - timedelta(days=14),
"last_reviewed": datetime.now(timezone.utc) - timedelta(days=7),
"no_new_info_count": 6,
"next_review": datetime.now(timezone.utc) - timedelta(days=3),
},
{
"title": "密钥轮换提醒 (taiwan)",
"level": EventLevel.C,
"domain": "security",
"status": EventStatus.DISSIPATING,
"occurred_date": date.today() - timedelta(days=20),
"last_reviewed": datetime.now(timezone.utc) - timedelta(days=10),
"no_new_info_count": 5,
"next_review": datetime.now(timezone.utc) - timedelta(days=5),
},
{
"title": "Gateway 配置同步冲突",
"level": EventLevel.B,
"domain": "gateway",
"status": EventStatus.CLOSED,
"occurred_date": date.today() - timedelta(days=30),
"last_reviewed": datetime.now(timezone.utc) - timedelta(days=25),
"no_new_info_count": 8,
"next_review": None,
},
{
"title": "网络延迟告警 (overseas node)",
"level": EventLevel.B,
"domain": "network",
"status": EventStatus.ACTIVE,
"occurred_date": date.today() - timedelta(days=3),
"last_reviewed": datetime.now(timezone.utc) - timedelta(hours=12),
"no_new_info_count": 2,
"next_review": datetime.now(timezone.utc) + timedelta(hours=18),
},
{
"title": "存储空间不足告警",
"level": EventLevel.C,
"domain": "storage",
"status": EventStatus.ACTIVE,
"occurred_date": date.today() - timedelta(days=4),
"last_reviewed": None,
"no_new_info_count": 0,
"next_review": datetime.now(timezone.utc) + timedelta(days=1),
},
{
"title": "Monitor 心跳超时",
"level": EventLevel.A,
"domain": "monitor",
"status": EventStatus.ACTIVE,
"occurred_date": date.today() - timedelta(hours=8),
"last_reviewed": datetime.now(timezone.utc) - timedelta(hours=4),
"no_new_info_count": 0,
"next_review": datetime.now(timezone.utc) + timedelta(hours=2),
},
{
"title": "Scheduler 任务积压",
"level": EventLevel.B,
"domain": "scheduler",
"status": EventStatus.FADING,
"occurred_date": date.today() - timedelta(days=6),
"last_reviewed": datetime.now(timezone.utc) - timedelta(days=2),
"no_new_info_count": 3,
"next_review": datetime.now(timezone.utc) + timedelta(hours=10),
},
{
"title": "API 响应延迟 (p95 > 5s)",
"level": EventLevel.A,
"domain": "api",
"status": EventStatus.ACTIVE,
"occurred_date": date.today() - timedelta(hours=12),
"last_reviewed": datetime.now(timezone.utc) - timedelta(hours=3),
"no_new_info_count": 0,
"next_review": datetime.now(timezone.utc) + timedelta(hours=4),
},
]
async def seed_placeholder_events() -> list[Event]:
"""向数据库写入占位事件数据(幂等:清空后重写)。"""
async with async_session() as db:
existing = await db.execute(select(Event).limit(1))
if existing.scalar_one_or_none():
logger.info("events 表已有数据,跳过占位种子")
return []
events = []
for data in PLACEHOLDER_EVENTS:
event = Event(**data)
db.add(event)
events.append(event)
await db.commit()
logger.info("写入 %d 条占位事件", len(events))
return events
async def run_crawl():
"""定时爬取入口(TODO: 真实 HTTP 爬取 hm.zhiweidata.ai/events)。"""
logger.info("Event crawler 执行开始")
await seed_placeholder_events()
logger.info("Event crawler 执行结束")
+26
View File
@@ -0,0 +1,26 @@
import client from './client'
export interface EventItem {
id: string
title: string
level: 'A' | 'B' | 'C'
domain: string
status: 'active' | 'fading' | 'dissipating' | 'closed'
occurred_date: string
last_reviewed: string | null
no_new_info_count: number
next_review: string | null
overdue_days: number
created_at: string | null
updated_at: string | null
}
export interface EventsResponse {
events: EventItem[]
}
export const getEvents = (params?: {
status?: string
domain?: string
search?: string
}) => client.get<EventsResponse>('/events', { params })
+266 -2
View File
@@ -1,3 +1,267 @@
export default function EventArchivePage() {
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>事件档案</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>事件归档查询</p></div>
import { useEffect, useState, useCallback } from 'react'
import Badge from '../components/ui/Badge'
import Button from '../components/ui/Button'
import Skeleton from '../components/ui/Skeleton'
import toast from '../components/ui/Toast'
import { getEvents, type EventItem } from '../api/events'
/* ---------- constants ---------- */
const DOMAINS = [
'gateway', 'crawler', 'reporter', 'feishu', 'database', 'security',
'network', 'storage', 'monitor', 'scheduler', 'api', 'other',
]
const STATUS_OPTIONS = [
{ value: '', label: '全部' },
{ value: 'active', label: '活跃期' },
{ value: 'fading', label: '消减期' },
{ value: 'dissipating', label: '消散期' },
{ value: 'closed', label: '已完结' },
] as const
const LEVEL_VARIANTS: Record<string, 'danger' | 'warning' | 'info'> = {
A: 'danger',
B: 'warning',
C: 'info',
}
const STATUS_VARIANTS: Record<string, 'success' | 'warning' | 'honey' | 'outline'> = {
active: 'success',
fading: 'warning',
dissipating: 'honey',
closed: 'outline',
}
const STATUS_LABELS: Record<string, string> = {
active: '活跃期',
fading: '消减期',
dissipating: '消散期',
closed: '已完结',
}
/* ---------- component ---------- */
export default function EventArchivePage() {
/* data */
const [events, setEvents] = useState<EventItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
/* filters */
const [statusFilter, setStatusFilter] = useState('')
const [domainFilter, setDomainFilter] = useState('')
const [searchText, setSearchText] = useState('')
/* ---------- fetch ---------- */
const fetchEvents = useCallback(async () => {
setLoading(true)
setError(null)
try {
const params: Record<string, string> = {}
if (statusFilter) params.status = statusFilter
if (domainFilter) params.domain = domainFilter
if (searchText.trim()) params.search = searchText.trim()
const res = await getEvents(params)
setEvents(res.data.events)
} catch {
setError('加载事件列表失败')
} finally {
setLoading(false)
}
}, [statusFilter, domainFilter, searchText])
useEffect(() => {
fetchEvents()
}, [fetchEvents])
/* ---------- handlers ---------- */
const handleNewEvent = () => {
toast.info('新建事件功能即将上线')
}
const handleCardClick = (event: EventItem) => {
toast.info(`事件: ${event.title}`)
}
/* ---------- render: loading ---------- */
if (loading && events.length === 0) {
return (
<div>
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
事件档案
</h2>
<Skeleton variant="card" count={6} />
</div>
)
}
/* ---------- render: error ---------- */
if (error) {
return (
<div>
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
事件档案
</h2>
<div
className="flex flex-col items-center justify-center py-12 gap-3"
style={{ color: 'var(--text-dim)' }}
>
<span style={{ fontSize: 32, color: 'var(--red)' }}>!</span>
<p>{error}</p>
<Button variant="outline" onClick={fetchEvents}>重试</Button>
</div>
</div>
)
}
/* ---------- render ---------- */
return (
<div>
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
事件档案
</h2>
{/* Filter toolbar */}
<div className="flex items-center gap-3 mb-4 flex-wrap">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="text-[13px] px-3 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border)] bg-transparent"
style={{ color: 'var(--text)' }}
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<select
value={domainFilter}
onChange={(e) => setDomainFilter(e.target.value)}
className="text-[13px] px-3 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border)] bg-transparent"
style={{ color: 'var(--text)' }}
>
<option value="">全部领域</option>
{DOMAINS.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
<input
type="text"
placeholder="搜索事件标题..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="text-[13px] px-3 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border)] bg-transparent"
style={{ color: 'var(--text)', width: 200 }}
/>
<Button size="sm" variant="primary" onClick={handleNewEvent}>
+ 新建事件
</Button>
</div>
{/* Event cards */}
{events.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12" style={{ color: 'var(--text-dim)' }}>
<span style={{ fontSize: 28, marginBottom: 8, fontWeight: 600 }}>0</span>
<p>无匹配事件</p>
</div>
) : (
<div
className="grid gap-4"
style={{
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
}}
>
{events.map((event) => (
<div
key={event.id}
onClick={() => handleCardClick(event)}
className="rounded-[var(--radius)] p-4 cursor-pointer transition-all duration-150"
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--honey-dim)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border)'
}}
>
{/* Title */}
<h3
className="mb-3 truncate"
style={{ color: 'var(--text)', fontSize: 15, fontWeight: 700, lineHeight: 1.4 }}
title={event.title}
>
{event.title}
</h3>
{/* Level badge + Domain tag */}
<div className="flex items-center gap-2 mb-3">
<Badge variant={LEVEL_VARIANTS[event.level]}>
{event.level}
</Badge>
<Badge variant="info">{event.domain}</Badge>
<Badge variant={STATUS_VARIANTS[event.status]}>
{STATUS_LABELS[event.status]}
</Badge>
</div>
{/* Meta */}
<div className="space-y-1.5 text-[12px]" style={{ color: 'var(--text-dim)' }}>
<div className="flex justify-between">
<span>发生日期</span>
<span style={{ color: 'var(--text-secondary)' }}>{event.occurred_date}</span>
</div>
<div className="flex justify-between">
<span>上次复查</span>
<span style={{ color: 'var(--text-secondary)' }}>
{event.last_reviewed ? new Date(event.last_reviewed).toLocaleString('zh-CN') : '未复查'}
</span>
</div>
<div className="flex justify-between">
<span>连续无新知</span>
<span
style={{
color: event.no_new_info_count >= 5 ? 'var(--yellow)' : 'var(--text-secondary)',
fontWeight: event.no_new_info_count >= 5 ? 600 : 400,
}}
>
{event.no_new_info_count} 次
</span>
</div>
<div className="flex justify-between">
<span>下次复查</span>
<span style={{ color: 'var(--text-secondary)' }}>
{event.next_review ? new Date(event.next_review).toLocaleString('zh-CN') : '无'}
</span>
</div>
</div>
{/* Overdue badge */}
{event.overdue_days > 0 && (
<div className="mt-3">
<span
className="inline-flex items-center px-2 py-0.5 rounded text-[11px] font-semibold"
style={{ background: 'rgba(255, 61, 61, 0.15)', color: 'var(--red)' }}
>
超期 {event.overdue_days} 天
</span>
</div>
)}
</div>
))}
</div>
)}
</div>
)
}