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:
@@ -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')
|
||||||
@@ -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
@@ -4,7 +4,7 @@ from fastapi import FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.database import engine, Base
|
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
|
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(health.router, prefix="/api/v1")
|
||||||
app.include_router(cron.router, prefix="/api/v1")
|
app.include_router(cron.router, prefix="/api/v1")
|
||||||
app.include_router(kanban.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")
|
app.include_router(keys.router, prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
|
from app.models.user import User
|
||||||
|
from app.models.audit import AuditLog
|
||||||
|
from app.models.event import Event
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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 执行结束")
|
||||||
@@ -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 })
|
||||||
@@ -1,3 +1,267 @@
|
|||||||
export default function EventArchivePage() {
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>事件档案</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>事件归档查询</p></div>
|
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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user