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
+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>
)
}