From eaf13e5f51c1e1e23aa8b4493f5d2fcf8106ba7e Mon Sep 17 00:00:00 2001 From: enzo Date: Wed, 22 Jul 2026 17:34:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Event=20archive=20management=20?= =?UTF-8?q?=E2=80=94=20Event=20model,=20migration,=20API,=20frontend=20pag?= =?UTF-8?q?e=20(Phase=208)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../e7f3a9b1c5d0_create_events_table.py | 46 +++ backend/app/api/events.py | 65 +++++ backend/app/main.py | 3 +- backend/app/models/__init__.py | 4 +- backend/app/models/event.py | 53 ++++ backend/app/services/event_crawler.py | 162 +++++++++++ frontend/src/api/events.ts | 26 ++ frontend/src/pages/EventArchivePage.tsx | 268 +++++++++++++++++- 8 files changed, 623 insertions(+), 4 deletions(-) create mode 100644 backend/alembic/versions/e7f3a9b1c5d0_create_events_table.py create mode 100644 backend/app/api/events.py create mode 100644 backend/app/models/event.py create mode 100644 backend/app/services/event_crawler.py create mode 100644 frontend/src/api/events.ts diff --git a/backend/alembic/versions/e7f3a9b1c5d0_create_events_table.py b/backend/alembic/versions/e7f3a9b1c5d0_create_events_table.py new file mode 100644 index 0000000..f1990f3 --- /dev/null +++ b/backend/alembic/versions/e7f3a9b1c5d0_create_events_table.py @@ -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') diff --git a/backend/app/api/events.py b/backend/app/api/events.py new file mode 100644 index 0000000..ecb24b7 --- /dev/null +++ b/backend/app/api/events.py @@ -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} diff --git a/backend/app/main.py b/backend/app/main.py index d05d954..4c0681a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 8b13789..99711d3 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1 +1,3 @@ - +from app.models.user import User +from app.models.audit import AuditLog +from app.models.event import Event diff --git a/backend/app/models/event.py b/backend/app/models/event.py new file mode 100644 index 0000000..4f141a8 --- /dev/null +++ b/backend/app/models/event.py @@ -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) diff --git a/backend/app/services/event_crawler.py b/backend/app/services/event_crawler.py new file mode 100644 index 0000000..392c4e7 --- /dev/null +++ b/backend/app/services/event_crawler.py @@ -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 执行结束") diff --git a/frontend/src/api/events.ts b/frontend/src/api/events.ts new file mode 100644 index 0000000..d53c84b --- /dev/null +++ b/frontend/src/api/events.ts @@ -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('/events', { params }) diff --git a/frontend/src/pages/EventArchivePage.tsx b/frontend/src/pages/EventArchivePage.tsx index e1436a7..53b8632 100644 --- a/frontend/src/pages/EventArchivePage.tsx +++ b/frontend/src/pages/EventArchivePage.tsx @@ -1,3 +1,267 @@ -export default function EventArchivePage() { - return

事件档案

事件归档查询

+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 = { + A: 'danger', + B: 'warning', + C: 'info', +} + +const STATUS_VARIANTS: Record = { + active: 'success', + fading: 'warning', + dissipating: 'honey', + closed: 'outline', +} + +const STATUS_LABELS: Record = { + active: '活跃期', + fading: '消减期', + dissipating: '消散期', + closed: '已完结', +} + +/* ---------- component ---------- */ + +export default function EventArchivePage() { + /* data */ + const [events, setEvents] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 = {} + 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 ( +
+

+ 事件档案 +

+ +
+ ) + } + + /* ---------- render: error ---------- */ + + if (error) { + return ( +
+

+ 事件档案 +

+
+ ! +

{error}

+ +
+
+ ) + } + + /* ---------- render ---------- */ + + return ( +
+

+ 事件档案 +

+ + {/* Filter toolbar */} +
+ + + + + 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 }} + /> + + +
+ + {/* Event cards */} + {events.length === 0 ? ( +
+ 0 +

无匹配事件

+
+ ) : ( +
+ {events.map((event) => ( +
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 */} +

+ {event.title} +

+ + {/* Level badge + Domain tag */} +
+ + {event.level} + + {event.domain} + + {STATUS_LABELS[event.status]} + +
+ + {/* Meta */} +
+
+ 发生日期 + {event.occurred_date} +
+
+ 上次复查 + + {event.last_reviewed ? new Date(event.last_reviewed).toLocaleString('zh-CN') : '未复查'} + +
+
+ 连续无新知 + = 5 ? 'var(--yellow)' : 'var(--text-secondary)', + fontWeight: event.no_new_info_count >= 5 ? 600 : 400, + }} + > + {event.no_new_info_count} 次 + +
+
+ 下次复查 + + {event.next_review ? new Date(event.next_review).toLocaleString('zh-CN') : '无'} + +
+
+ + {/* Overdue badge */} + {event.overdue_days > 0 && ( +
+ + 超期 {event.overdue_days} 天 + +
+ )} +
+ ))} +
+ )} +
+ ) }