mirror of
https://github.com/stardrophere/InsightRadar.git
synced 2026-06-06 00:00:05 +08:00
搜索功能加入
This commit is contained in:
@@ -1,8 +1,12 @@
|
|||||||
# app/api/endpoints/events.py
|
# app/api/endpoints/events.py
|
||||||
|
import json
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
from datetime import timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Dict, List, Tuple
|
from typing import Dict, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from dotenv import load_dotenv
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -19,63 +23,83 @@ from app.models.models import (
|
|||||||
from app.schemas.event_schema import (
|
from app.schemas.event_schema import (
|
||||||
PaginatedUnifiedEventResponse,
|
PaginatedUnifiedEventResponse,
|
||||||
PlatformTrendResponse,
|
PlatformTrendResponse,
|
||||||
|
SearchTimelineResponse,
|
||||||
|
TimelineDataPoint,
|
||||||
UnifiedEventResponse,
|
UnifiedEventResponse,
|
||||||
)
|
)
|
||||||
|
from app.services.fetcher_service import embedder_model
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
SEARCH_EMBEDDING_THRESHOLD = float(os.getenv("SEARCH_EMBEDDING_THRESHOLD", "0.75"))
|
||||||
|
SEARCH_MAX_LIMIT = int(os.getenv("SEARCH_MAX_LIMIT", "30"))
|
||||||
|
SEARCH_DEFAULT_HOURS = int(os.getenv("SEARCH_DEFAULT_HOURS", "168"))
|
||||||
|
SEARCH_MAX_HOURS = int(os.getenv("SEARCH_MAX_HOURS", "168"))
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# 排名轨迹最多返回多少个点,避免长时间跨度下数据过大
|
# 排名轨迹最多返回的点数,避免时间跨度过大时响应体过重。
|
||||||
MAX_RANKING_POINTS = 30
|
MAX_RANKING_POINTS = 30
|
||||||
|
|
||||||
# --- 轻量级接口缓存配置 ---
|
# 统一事件列表接口的短期缓存。
|
||||||
_UNIFIED_EVENTS_CACHE: Dict[str, Tuple[float, PaginatedUnifiedEventResponse]] = {}
|
_UNIFIED_EVENTS_CACHE: Dict[str, Tuple[float, PaginatedUnifiedEventResponse]] = {}
|
||||||
CACHE_TTL_SECONDS = 60
|
CACHE_TTL_SECONDS = 60
|
||||||
# ---------------------------
|
|
||||||
|
|
||||||
|
def _load_vector(raw_embedding: str | None) -> np.ndarray | None:
|
||||||
|
"""将字符串形式的向量安全解析为 numpy 数组。"""
|
||||||
|
if not raw_embedding:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return np.asarray(json.loads(raw_embedding), dtype=np.float32)
|
||||||
|
except (TypeError, ValueError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_aware_datetime(dt: datetime) -> datetime:
|
||||||
|
"""确保 datetime 带时区;SQLite 返回无时区值时按 UTC 解释。"""
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
return dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt
|
||||||
|
|
||||||
|
|
||||||
@router.get("/unified", response_model=PaginatedUnifiedEventResponse)
|
@router.get("/unified", response_model=PaginatedUnifiedEventResponse)
|
||||||
def list_unified_events(
|
def list_unified_events(
|
||||||
min_hot: int = Query(5, ge=0, description="热度阈值,仅返回 hot_score >= 此值的事件"),
|
min_hot: int = Query(5, ge=0, description="最低热度阈值"),
|
||||||
hours: int = Query(48, ge=1, le=720, description="查询最近多少小时的数据"),
|
hours: int = Query(48, ge=1, le=720, description="查询最近多少小时的数据"),
|
||||||
sort_by: str = Query("hot_score", description="排序字段: hot_score | created_at"),
|
sort_by: str = Query("hot_score", description="排序字段:hot_score | created_at"),
|
||||||
skip: int = Query(0, ge=0, description="分页偏移量"),
|
skip: int = Query(0, ge=0, description="分页偏移量"),
|
||||||
limit: int = Query(10, ge=1, le=50, description="每页返回条数"),
|
limit: int = Query(10, ge=1, le=50, description="每页条数"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""分页返回统一事件,附带各平台热搜、排名轨迹和标签。"""
|
"""查询统一事件列表,并附带平台趋势与标签信息。"""
|
||||||
|
|
||||||
# --- 1. 尝试从缓存读取 ---
|
|
||||||
cache_key = f"{min_hot}:{hours}:{sort_by}:{skip}:{limit}"
|
cache_key = f"{min_hot}:{hours}:{sort_by}:{skip}:{limit}"
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
|
|
||||||
if cache_key in _UNIFIED_EVENTS_CACHE:
|
if cache_key in _UNIFIED_EVENTS_CACHE:
|
||||||
expire_time, cached_data = _UNIFIED_EVENTS_CACHE[cache_key]
|
expire_time, cached_data = _UNIFIED_EVENTS_CACHE[cache_key]
|
||||||
if current_time < expire_time:
|
if current_time < expire_time:
|
||||||
return cached_data
|
return cached_data
|
||||||
# -----------------------
|
|
||||||
|
|
||||||
time_limit = utcnow() - timedelta(hours=hours)
|
time_limit = utcnow() - timedelta(hours=hours)
|
||||||
|
|
||||||
# 先查总数,用于前端判断是否还有更多
|
|
||||||
base_query = db.query(UnifiedEvent).filter(
|
base_query = db.query(UnifiedEvent).filter(
|
||||||
UnifiedEvent.hot_score >= min_hot,
|
UnifiedEvent.hot_score >= min_hot,
|
||||||
UnifiedEvent.created_at >= time_limit,
|
UnifiedEvent.created_at >= time_limit,
|
||||||
)
|
)
|
||||||
total = base_query.count()
|
total = base_query.count()
|
||||||
|
|
||||||
# 分页查询
|
|
||||||
if sort_by == "created_at":
|
if sort_by == "created_at":
|
||||||
base_query = base_query.order_by(UnifiedEvent.created_at.desc())
|
base_query = base_query.order_by(UnifiedEvent.created_at.desc())
|
||||||
else:
|
else:
|
||||||
base_query = base_query.order_by(UnifiedEvent.hot_score.desc(), UnifiedEvent.created_at.desc())
|
base_query = base_query.order_by(UnifiedEvent.hot_score.desc(), UnifiedEvent.created_at.desc())
|
||||||
|
|
||||||
events = base_query.offset(skip).limit(limit).all()
|
events = base_query.offset(skip).limit(limit).all()
|
||||||
|
|
||||||
if not events:
|
if not events:
|
||||||
return PaginatedUnifiedEventResponse(total=total, has_more=False, data=[])
|
return PaginatedUnifiedEventResponse(total=total, has_more=False, data=[])
|
||||||
|
|
||||||
event_ids = [ev.id for ev in events]
|
event_ids = [ev.id for ev in events]
|
||||||
|
|
||||||
# 批量查询所有相关的热搜条目(避免 N+1)
|
|
||||||
trend_rows = (
|
trend_rows = (
|
||||||
db.query(TrendingEvent, InfoSource.source_name)
|
db.query(TrendingEvent, InfoSource.source_name)
|
||||||
.join(InfoSource, TrendingEvent.source_id == InfoSource.id)
|
.join(InfoSource, TrendingEvent.source_id == InfoSource.id)
|
||||||
@@ -83,21 +107,16 @@ def list_unified_events(
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
# 按 unified_event_id 分组
|
trend_map: dict[int, list[tuple[TrendingEvent, str]]] = {}
|
||||||
trend_map: dict[int, list[tuple]] = {}
|
|
||||||
trend_ids: list[int] = []
|
trend_ids: list[int] = []
|
||||||
for trend, source_name in trend_rows:
|
for trend, source_name in trend_rows:
|
||||||
trend_map.setdefault(trend.unified_event_id, []).append((trend, source_name))
|
trend_map.setdefault(trend.unified_event_id, []).append((trend, source_name))
|
||||||
trend_ids.append(trend.id)
|
trend_ids.append(trend.id)
|
||||||
|
|
||||||
# 批量查询排名日志(避免逐条查询)
|
|
||||||
ranking_map: dict[int, list[int]] = {}
|
ranking_map: dict[int, list[int]] = {}
|
||||||
if trend_ids:
|
if trend_ids:
|
||||||
ranking_rows = (
|
ranking_rows = (
|
||||||
db.query(
|
db.query(RankingLog.event_id, RankingLog.ranking_position)
|
||||||
RankingLog.event_id,
|
|
||||||
RankingLog.ranking_position,
|
|
||||||
)
|
|
||||||
.filter(
|
.filter(
|
||||||
RankingLog.event_id.in_(trend_ids),
|
RankingLog.event_id.in_(trend_ids),
|
||||||
RankingLog.observed_at >= time_limit,
|
RankingLog.observed_at >= time_limit,
|
||||||
@@ -108,7 +127,6 @@ def list_unified_events(
|
|||||||
for event_id, position in ranking_rows:
|
for event_id, position in ranking_rows:
|
||||||
ranking_map.setdefault(event_id, []).append(position)
|
ranking_map.setdefault(event_id, []).append(position)
|
||||||
|
|
||||||
# 批量查询标签
|
|
||||||
tag_map: dict[int, list[str]] = {}
|
tag_map: dict[int, list[str]] = {}
|
||||||
tag_rows = (
|
tag_rows = (
|
||||||
db.query(ExtractedTopic.target_id, ExtractedTopic.topic_keyword)
|
db.query(ExtractedTopic.target_id, ExtractedTopic.topic_keyword)
|
||||||
@@ -122,14 +140,13 @@ def list_unified_events(
|
|||||||
for target_id, keyword in tag_rows:
|
for target_id, keyword in tag_rows:
|
||||||
tag_map.setdefault(target_id, []).append(keyword)
|
tag_map.setdefault(target_id, []).append(keyword)
|
||||||
|
|
||||||
# 组装响应
|
|
||||||
results: list[UnifiedEventResponse] = []
|
results: list[UnifiedEventResponse] = []
|
||||||
for ev in events:
|
for ev in events:
|
||||||
platform_list: list[PlatformTrendResponse] = []
|
platform_list: list[PlatformTrendResponse] = []
|
||||||
trends_for_ev = trend_map.get(ev.id, [])
|
trends_for_ev = trend_map.get(ev.id, [])
|
||||||
|
|
||||||
for trend, source_name in trends_for_ev:
|
for trend, source_name in trends_for_ev:
|
||||||
history = ranking_map.get(trend.id, [])
|
history = ranking_map.get(trend.id, [])
|
||||||
# 截取尾部,只保留最近的点
|
|
||||||
if len(history) > MAX_RANKING_POINTS:
|
if len(history) > MAX_RANKING_POINTS:
|
||||||
history = history[-MAX_RANKING_POINTS:]
|
history = history[-MAX_RANKING_POINTS:]
|
||||||
|
|
||||||
@@ -144,17 +161,12 @@ def list_unified_events(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# 取所有关联热搜条目中最新的 updated_at,代表"最后一次在平台热搜榜看到"的时间
|
last_active_at = max(t.updated_at for t, _ in trends_for_ev) if trends_for_ev else ev.updated_at
|
||||||
last_active_at = (
|
|
||||||
max(t.updated_at for t, _ in trends_for_ev)
|
|
||||||
if trends_for_ev
|
|
||||||
else ev.updated_at
|
|
||||||
)
|
|
||||||
|
|
||||||
results.append(
|
results.append(
|
||||||
UnifiedEventResponse(
|
UnifiedEventResponse(
|
||||||
event_id=ev.id,
|
event_id=ev.id,
|
||||||
unified_title=ev.unified_title if ev.unified_title else "暂无标题",
|
unified_title=ev.unified_title if ev.unified_title else "Untitled",
|
||||||
summary=ev.ai_comprehensive_summary,
|
summary=ev.ai_comprehensive_summary,
|
||||||
hot_score=ev.hot_score,
|
hot_score=ev.hot_score,
|
||||||
created_at=ev.created_at,
|
created_at=ev.created_at,
|
||||||
@@ -167,13 +179,9 @@ def list_unified_events(
|
|||||||
has_more = (skip + limit) < total
|
has_more = (skip + limit) < total
|
||||||
response = PaginatedUnifiedEventResponse(total=total, has_more=has_more, data=results)
|
response = PaginatedUnifiedEventResponse(total=total, has_more=has_more, data=results)
|
||||||
|
|
||||||
# --- 2. 写入缓存 ---
|
|
||||||
if len(_UNIFIED_EVENTS_CACHE) > 1000:
|
if len(_UNIFIED_EVENTS_CACHE) > 1000:
|
||||||
# 防止内存无限增长
|
|
||||||
_UNIFIED_EVENTS_CACHE.clear()
|
_UNIFIED_EVENTS_CACHE.clear()
|
||||||
|
|
||||||
_UNIFIED_EVENTS_CACHE[cache_key] = (current_time + CACHE_TTL_SECONDS, response)
|
_UNIFIED_EVENTS_CACHE[cache_key] = (current_time + CACHE_TTL_SECONDS, response)
|
||||||
# ------------------
|
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -183,7 +191,7 @@ def get_unified_event(
|
|||||||
event_id: int,
|
event_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""按 ID 查询单个统一事件,用于推荐跳转时的聚光灯展示。"""
|
"""按事件 ID 获取单个统一事件。"""
|
||||||
ev = db.query(UnifiedEvent).filter(UnifiedEvent.id == event_id).first()
|
ev = db.query(UnifiedEvent).filter(UnifiedEvent.id == event_id).first()
|
||||||
if not ev:
|
if not ev:
|
||||||
raise HTTPException(status_code=404, detail="Event not found")
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
@@ -228,6 +236,7 @@ def get_unified_event(
|
|||||||
history = ranking_map.get(trend.id, [])
|
history = ranking_map.get(trend.id, [])
|
||||||
if len(history) > MAX_RANKING_POINTS:
|
if len(history) > MAX_RANKING_POINTS:
|
||||||
history = history[-MAX_RANKING_POINTS:]
|
history = history[-MAX_RANKING_POINTS:]
|
||||||
|
|
||||||
platform_list.append(
|
platform_list.append(
|
||||||
PlatformTrendResponse(
|
PlatformTrendResponse(
|
||||||
source_id=trend.source_id,
|
source_id=trend.source_id,
|
||||||
@@ -239,15 +248,11 @@ def get_unified_event(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
last_active_at = (
|
last_active_at = max(t.updated_at for t, _ in trend_rows) if trend_rows else ev.updated_at
|
||||||
max(t.updated_at for t, _ in trend_rows)
|
|
||||||
if trend_rows
|
|
||||||
else ev.updated_at
|
|
||||||
)
|
|
||||||
|
|
||||||
return UnifiedEventResponse(
|
return UnifiedEventResponse(
|
||||||
event_id=ev.id,
|
event_id=ev.id,
|
||||||
unified_title=ev.unified_title if ev.unified_title else "暂无标题",
|
unified_title=ev.unified_title if ev.unified_title else "Untitled",
|
||||||
summary=ev.ai_comprehensive_summary,
|
summary=ev.ai_comprehensive_summary,
|
||||||
hot_score=ev.hot_score,
|
hot_score=ev.hot_score,
|
||||||
created_at=ev.created_at,
|
created_at=ev.created_at,
|
||||||
@@ -255,3 +260,205 @@ def get_unified_event(
|
|||||||
platforms=platform_list,
|
platforms=platform_list,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search_timeline", response_model=SearchTimelineResponse)
|
||||||
|
def search_events_timeline(
|
||||||
|
keyword: str = Query(..., description="搜索关键词,支持正则表达式"),
|
||||||
|
hours: int = Query(None, ge=1, le=SEARCH_MAX_HOURS, description="查询最近多少小时的数据"),
|
||||||
|
mode: str = Query("hybrid", description="匹配模式:exact | semantic | hybrid"),
|
||||||
|
semantic_threshold: float = Query(None, ge=0.0, le=1.0, description="语义匹配相似度阈值"),
|
||||||
|
utc_offset_minutes: int | None = Query(None, ge=-840, le=840, description="客户端相对 UTC 的分钟偏移,东八区传 480"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
import re
|
||||||
|
|
||||||
|
query_text = (keyword or "").strip()
|
||||||
|
if not query_text:
|
||||||
|
raise HTTPException(status_code=400, detail="keyword cannot be empty")
|
||||||
|
|
||||||
|
if hours is None:
|
||||||
|
hours = SEARCH_DEFAULT_HOURS
|
||||||
|
if hours > SEARCH_MAX_HOURS:
|
||||||
|
hours = SEARCH_MAX_HOURS
|
||||||
|
|
||||||
|
match_mode = (mode or "hybrid").strip().lower()
|
||||||
|
if match_mode not in {"exact", "semantic", "hybrid"}:
|
||||||
|
match_mode = "hybrid"
|
||||||
|
|
||||||
|
use_regex = match_mode in {"exact", "hybrid"}
|
||||||
|
use_semantic = match_mode in {"semantic", "hybrid"}
|
||||||
|
sim_threshold = semantic_threshold if semantic_threshold is not None else SEARCH_EMBEDDING_THRESHOLD
|
||||||
|
|
||||||
|
pattern = None
|
||||||
|
if use_regex:
|
||||||
|
try:
|
||||||
|
pattern = re.compile(query_text, re.IGNORECASE)
|
||||||
|
except re.error:
|
||||||
|
pattern = re.compile(re.escape(query_text), re.IGNORECASE)
|
||||||
|
|
||||||
|
query_vec: np.ndarray | None = None
|
||||||
|
if use_semantic:
|
||||||
|
try:
|
||||||
|
query_encoded = embedder_model.encode([query_text], normalize_embeddings=True, show_progress_bar=False)
|
||||||
|
if len(query_encoded) > 0:
|
||||||
|
query_vec = np.asarray(query_encoded[0], dtype=np.float32)
|
||||||
|
except Exception:
|
||||||
|
query_vec = None
|
||||||
|
|
||||||
|
if match_mode == "semantic" and query_vec is None:
|
||||||
|
use_regex = True
|
||||||
|
if pattern is None:
|
||||||
|
pattern = re.compile(re.escape(query_text), re.IGNORECASE)
|
||||||
|
|
||||||
|
time_limit = utcnow() - timedelta(hours=hours)
|
||||||
|
date_format = "%Y-%m-%d %H:00" if hours <= 48 else "%Y-%m-%d"
|
||||||
|
|
||||||
|
display_timezone = timezone.utc
|
||||||
|
if utc_offset_minutes is not None:
|
||||||
|
display_timezone = timezone(timedelta(minutes=utc_offset_minutes))
|
||||||
|
|
||||||
|
def _bucket_label(dt: datetime) -> str:
|
||||||
|
aware_dt = _ensure_aware_datetime(dt)
|
||||||
|
return aware_dt.astimezone(display_timezone).strftime(date_format)
|
||||||
|
|
||||||
|
all_recent_unified = db.query(UnifiedEvent).filter(UnifiedEvent.created_at >= time_limit).all()
|
||||||
|
all_recent_trends = db.query(TrendingEvent).filter(TrendingEvent.created_at >= time_limit).all()
|
||||||
|
|
||||||
|
matched_event_ids: set[int] = set()
|
||||||
|
matched_trend_points: list[tuple[int, str]] = []
|
||||||
|
|
||||||
|
for ev in all_recent_unified:
|
||||||
|
text_matched = False
|
||||||
|
if use_regex and pattern is not None:
|
||||||
|
text_to_search = f"{ev.unified_title or ''} {ev.ai_comprehensive_summary or ''}"
|
||||||
|
text_matched = bool(pattern.search(text_to_search))
|
||||||
|
|
||||||
|
semantic_matched = False
|
||||||
|
if use_semantic and query_vec is not None:
|
||||||
|
ev_vec = _load_vector(ev.center_embedding)
|
||||||
|
if ev_vec is not None:
|
||||||
|
semantic_matched = float(np.dot(query_vec, ev_vec)) >= sim_threshold
|
||||||
|
|
||||||
|
if text_matched or semantic_matched:
|
||||||
|
matched_event_ids.add(ev.id)
|
||||||
|
|
||||||
|
for trend in all_recent_trends:
|
||||||
|
text_matched = False
|
||||||
|
if use_regex and pattern is not None and trend.current_headline:
|
||||||
|
text_matched = bool(pattern.search(trend.current_headline))
|
||||||
|
|
||||||
|
semantic_matched = False
|
||||||
|
if use_semantic and query_vec is not None:
|
||||||
|
trend_vec = _load_vector(trend.title_embedding)
|
||||||
|
if trend_vec is not None:
|
||||||
|
semantic_matched = float(np.dot(query_vec, trend_vec)) >= sim_threshold
|
||||||
|
|
||||||
|
if (text_matched or semantic_matched) and trend.unified_event_id:
|
||||||
|
matched_event_ids.add(trend.unified_event_id)
|
||||||
|
matched_trend_points.append((trend.unified_event_id, _bucket_label(trend.created_at)))
|
||||||
|
|
||||||
|
matched_unified_events = [ev for ev in all_recent_unified if ev.id in matched_event_ids]
|
||||||
|
matched_unified_events.sort(key=lambda x: x.created_at, reverse=True)
|
||||||
|
|
||||||
|
display_events = matched_unified_events[:100]
|
||||||
|
display_event_ids = [ev.id for ev in display_events]
|
||||||
|
display_event_id_set = set(display_event_ids)
|
||||||
|
|
||||||
|
trend_map: dict[int, list[tuple[TrendingEvent, str]]] = {}
|
||||||
|
trend_ids: list[int] = []
|
||||||
|
if display_event_ids:
|
||||||
|
trend_rows = (
|
||||||
|
db.query(TrendingEvent, InfoSource.source_name)
|
||||||
|
.join(InfoSource, TrendingEvent.source_id == InfoSource.id)
|
||||||
|
.filter(TrendingEvent.unified_event_id.in_(display_event_ids))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for trend, source_name in trend_rows:
|
||||||
|
trend_map.setdefault(trend.unified_event_id, []).append((trend, source_name))
|
||||||
|
trend_ids.append(trend.id)
|
||||||
|
|
||||||
|
ranking_map: dict[int, list[int]] = {}
|
||||||
|
if trend_ids:
|
||||||
|
ranking_rows = (
|
||||||
|
db.query(RankingLog.event_id, RankingLog.ranking_position)
|
||||||
|
.filter(
|
||||||
|
RankingLog.event_id.in_(trend_ids),
|
||||||
|
RankingLog.observed_at >= time_limit,
|
||||||
|
)
|
||||||
|
.order_by(RankingLog.event_id, RankingLog.observed_at.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for event_id, position in ranking_rows:
|
||||||
|
ranking_map.setdefault(event_id, []).append(position)
|
||||||
|
|
||||||
|
timeline_event_map: dict[str, set[int]] = {}
|
||||||
|
for ev in display_events:
|
||||||
|
timeline_event_map.setdefault(_bucket_label(ev.created_at), set()).add(ev.id)
|
||||||
|
|
||||||
|
for event_id, time_label in matched_trend_points:
|
||||||
|
if event_id in display_event_id_set:
|
||||||
|
timeline_event_map.setdefault(time_label, set()).add(event_id)
|
||||||
|
|
||||||
|
timeline_data = [
|
||||||
|
TimelineDataPoint(time_label=time_label, count=len(event_ids), event_ids=sorted(event_ids))
|
||||||
|
for time_label, event_ids in sorted(timeline_event_map.items())
|
||||||
|
]
|
||||||
|
|
||||||
|
results: list[UnifiedEventResponse] = []
|
||||||
|
if display_event_ids:
|
||||||
|
tag_map: dict[int, list[str]] = {}
|
||||||
|
tag_rows = (
|
||||||
|
db.query(ExtractedTopic.target_id, ExtractedTopic.topic_keyword)
|
||||||
|
.filter(
|
||||||
|
ExtractedTopic.target_type == TargetType.EVENT,
|
||||||
|
ExtractedTopic.target_id.in_(display_event_ids),
|
||||||
|
)
|
||||||
|
.order_by(ExtractedTopic.relevance_score.desc(), ExtractedTopic.created_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for target_id, kw in tag_rows:
|
||||||
|
tag_map.setdefault(target_id, []).append(kw)
|
||||||
|
|
||||||
|
for ev in display_events:
|
||||||
|
trends_for_ev = trend_map.get(ev.id, [])
|
||||||
|
platform_list: list[PlatformTrendResponse] = []
|
||||||
|
seen_platforms = set()
|
||||||
|
|
||||||
|
for trend, source_name in trends_for_ev:
|
||||||
|
uniq_key = f"{source_name}_{trend.current_headline}"
|
||||||
|
if uniq_key in seen_platforms:
|
||||||
|
continue
|
||||||
|
seen_platforms.add(uniq_key)
|
||||||
|
|
||||||
|
history = ranking_map.get(trend.id, [])
|
||||||
|
if len(history) > MAX_RANKING_POINTS:
|
||||||
|
history = history[-MAX_RANKING_POINTS:]
|
||||||
|
|
||||||
|
platform_list.append(
|
||||||
|
PlatformTrendResponse(
|
||||||
|
source_id=trend.source_id,
|
||||||
|
platform_name=source_name,
|
||||||
|
headline=trend.current_headline,
|
||||||
|
url=trend.event_url,
|
||||||
|
current_ranking=trend.current_ranking,
|
||||||
|
ranking_history=history,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
last_active_at = max(t.updated_at for t, _ in trends_for_ev) if trends_for_ev else ev.updated_at
|
||||||
|
|
||||||
|
results.append(
|
||||||
|
UnifiedEventResponse(
|
||||||
|
event_id=ev.id,
|
||||||
|
unified_title=ev.unified_title if ev.unified_title else "Untitled",
|
||||||
|
summary=ev.ai_comprehensive_summary,
|
||||||
|
hot_score=ev.hot_score,
|
||||||
|
created_at=ev.created_at,
|
||||||
|
last_active_at=last_active_at,
|
||||||
|
platforms=platform_list,
|
||||||
|
tags=tag_map.get(ev.id, []),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return SearchTimelineResponse(keyword=query_text, timeline=timeline_data, events=results)
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ class UnifiedEventResponse(BaseModel):
|
|||||||
tags: List[str] = Field(default_factory=list)
|
tags: List[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class TimelineDataPoint(BaseModel):
|
||||||
|
time_label: str
|
||||||
|
count: int
|
||||||
|
event_ids: List[int] = Field(default_factory=list)
|
||||||
|
|
||||||
|
class SearchTimelineResponse(BaseModel):
|
||||||
|
keyword: str
|
||||||
|
timeline: List[TimelineDataPoint]
|
||||||
|
events: List[UnifiedEventResponse]
|
||||||
|
|
||||||
class PaginatedUnifiedEventResponse(BaseModel):
|
class PaginatedUnifiedEventResponse(BaseModel):
|
||||||
"""分页包装:避免一次性返回全量数据"""
|
"""分页包装:避免一次性返回全量数据"""
|
||||||
total: int
|
total: int
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ def normalize_topic_keywords(topic_candidates: list[dict[str, Any]]) -> list[dic
|
|||||||
):
|
):
|
||||||
cluster["score"] = item["score"]
|
cluster["score"] = item["score"]
|
||||||
|
|
||||||
# Prefer shorter tag as canonical keyword.
|
# 优先选择更短的标签作为规范关键词,减少同义长短词分裂。
|
||||||
if len(item["keyword"]) < len(cluster["keyword"]):
|
if len(item["keyword"]) < len(cluster["keyword"]):
|
||||||
cluster["keyword"] = item["keyword"]
|
cluster["keyword"] = item["keyword"]
|
||||||
else:
|
else:
|
||||||
|
|||||||
Generated
+11
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"apexcharts": "^5.10.3",
|
"apexcharts": "^5.10.3",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.29",
|
"vue": "^3.5.29",
|
||||||
"vue-router": "^5.0.3",
|
"vue-router": "^5.0.3",
|
||||||
@@ -2869,6 +2870,16 @@
|
|||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/date-fns": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"apexcharts": "^5.10.3",
|
"apexcharts": "^5.10.3",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.29",
|
"vue": "^3.5.29",
|
||||||
"vue-router": "^5.0.3",
|
"vue-router": "^5.0.3",
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* 通用 HTTP 客户端:自动注入 Bearer Token,统一处理错误
|
* 通用 HTTP 客户端:自动注入 Bearer Token,并统一处理错误返回。
|
||||||
*/
|
*/
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { pinia } from '@/stores'
|
import { pinia } from '@/stores'
|
||||||
import { fetchApi } from '@/config/apiBase'
|
import { fetchApi } from '@/config/apiBase'
|
||||||
|
|
||||||
// 后端返回的错误消息中英映射
|
// 后端错误消息中英文映射。
|
||||||
const MESSAGE_MAP: Record<string, string> = {
|
const MESSAGE_MAP: Record<string, string> = {
|
||||||
'You can only operate your own resources': '只能操作自己的资源',
|
'You can only operate your own resources': '只能操作你自己的资源',
|
||||||
'Preference keyword already exists for this user': '该关键词已订阅',
|
'Preference keyword already exists for this user': '该关键词已经订阅过了',
|
||||||
'Keyword cannot be empty': '关键词不能为空',
|
'Keyword cannot be empty': '关键词不能为空',
|
||||||
'This delivery time already exists': '该推送时间已存在',
|
'This delivery time already exists': '该推送时间已存在',
|
||||||
'This channel type already exists for the user': '该渠道类型已存在',
|
'This channel type already exists for the user': '该推送渠道类型已存在',
|
||||||
'Schedule not found': '推送时间不存在',
|
'Schedule not found': '推送时间不存在',
|
||||||
'Push endpoint not found': '推送渠道不存在',
|
'Push endpoint not found': '推送渠道不存在',
|
||||||
'Preference not found': '偏好不存在',
|
'Preference not found': '偏好不存在',
|
||||||
@@ -101,7 +101,9 @@ export async function apiDelete(path: string): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const data = JSON.parse(raw) as Record<string, unknown>
|
const data = JSON.parse(raw) as Record<string, unknown>
|
||||||
if (typeof data.detail === 'string') detail = localizeMessage(data.detail)
|
if (typeof data.detail === 'string') detail = localizeMessage(data.detail)
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
/* 忽略非 JSON 错误体解析失败 */
|
||||||
|
}
|
||||||
throw new Error(detail)
|
throw new Error(detail)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { apiGet } from './client'
|
import { apiGet } from './client'
|
||||||
import type { PaginatedEvents, UnifiedEvent, HeadlineRevision, SystemStats } from '@/types/event'
|
import type { PaginatedEvents, UnifiedEvent, HeadlineRevision, SystemStats, SearchTimelineResponse } from '@/types/event'
|
||||||
|
|
||||||
/** 按 ID 查询单个统一事件(用于推荐跳转聚光灯展示) */
|
/** 按 ID 查询单个统一事件(用于推荐页跳转后聚焦展示) */
|
||||||
export function fetchEventById(eventId: number): Promise<UnifiedEvent> {
|
export function fetchEventById(eventId: number): Promise<UnifiedEvent> {
|
||||||
return apiGet<UnifiedEvent>(`/events/unified/${eventId}`)
|
return apiGet<UnifiedEvent>(`/events/unified/${eventId}`)
|
||||||
}
|
}
|
||||||
@@ -29,3 +29,19 @@ export function fetchHeadlineRevisions(params?: {
|
|||||||
export function fetchSystemStats(): Promise<SystemStats> {
|
export function fetchSystemStats(): Promise<SystemStats> {
|
||||||
return apiGet<SystemStats>('/system/stats')
|
return apiGet<SystemStats>('/system/stats')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按关键词查询热度时间线 */
|
||||||
|
export function searchEventsTimeline(
|
||||||
|
keyword: string,
|
||||||
|
hours: number = 168,
|
||||||
|
mode: 'exact' | 'semantic' | 'hybrid' = 'hybrid'
|
||||||
|
): Promise<SearchTimelineResponse> {
|
||||||
|
// JS 的 getTimezoneOffset: 本地 - UTC(东八区是 -480),这里转成 UTC+ 偏移分钟。
|
||||||
|
const utcOffsetMinutes = -new Date().getTimezoneOffset()
|
||||||
|
return apiGet<SearchTimelineResponse>('/events/search_timeline', {
|
||||||
|
keyword,
|
||||||
|
hours: hours.toString(),
|
||||||
|
mode,
|
||||||
|
utc_offset_minutes: utcOffsetMinutes.toString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* color palette from <https://github.com/vuejs/theme> */
|
/* 颜色变量基于 Vue 官方主题并按项目需求调整 */
|
||||||
:root {
|
:root {
|
||||||
--vt-c-white: #ffffff;
|
--vt-c-white: #ffffff;
|
||||||
--vt-c-white-soft: #f8f8f8;
|
--vt-c-white-soft: #f8f8f8;
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* semantic color variables for this project */
|
/* 项目语义化颜色变量 */
|
||||||
:root {
|
:root {
|
||||||
--color-background: var(--vt-c-white);
|
--color-background: var(--vt-c-white);
|
||||||
--color-background-soft: var(--vt-c-white-soft);
|
--color-background-soft: var(--vt-c-white-soft);
|
||||||
@@ -81,3 +81,4 @@ body {
|
|||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ function handleToggle(event: MouseEvent) {
|
|||||||
Math.max(y, innerHeight - y)
|
Math.max(y, innerHeight - y)
|
||||||
)
|
)
|
||||||
|
|
||||||
// @ts-ignore: TypeScript 类型可能较旧,忽略 startViewTransition 报错
|
// @ts-expect-error: TypeScript 类型可能较旧,忽略 startViewTransition 报错
|
||||||
const transition = document.startViewTransition(() => {
|
const transition = document.startViewTransition(() => {
|
||||||
themeStore.toggleTheme()
|
themeStore.toggleTheme()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,479 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import VueApexCharts from 'vue3-apexcharts'
|
||||||
|
import type { UnifiedEvent } from '@/types/event'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
event: UnifiedEvent
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 当前鼠标悬停的平台行标识
|
||||||
|
const hoveredPlatformKey = ref<string | null>(null)
|
||||||
|
|
||||||
|
function formatRelativeTime(dateStr: string) {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
if (!dateStr.endsWith('Z') && !dateStr.includes('+')) {
|
||||||
|
dateStr += 'Z'
|
||||||
|
}
|
||||||
|
const now = Date.now()
|
||||||
|
const target = new Date(dateStr).getTime()
|
||||||
|
const diff = now - target
|
||||||
|
const minutes = Math.floor(diff / 60000)
|
||||||
|
if (minutes < 1) return '刚刚'
|
||||||
|
if (minutes < 60) return `${minutes} 分钟前`
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
if (hours < 24) return `${hours} 小时前`
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
return `${days} 天前`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHotLevel(score: number): { label: string; color: string; bg: string } {
|
||||||
|
if (score >= 10) return { label: '全网沸腾', color: '#ef4444', bg: 'rgba(239,68,68,0.15)' }
|
||||||
|
if (score >= 5) return { label: '高度关注', color: '#f97316', bg: 'rgba(249,115,22,0.15)' }
|
||||||
|
if (score >= 3) return { label: '上升中', color: '#3b82f6', bg: 'rgba(59,130,246,0.15)' }
|
||||||
|
return { label: '一般关注', color: '#6b7280', bg: 'rgba(107,114,128,0.15)' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformIconMap: Record<string, string> = {
|
||||||
|
微博热搜: 'fa-brands fa-weibo',
|
||||||
|
微博: 'fa-brands fa-weibo',
|
||||||
|
知乎热榜: 'fa-brands fa-zhihu',
|
||||||
|
知乎: 'fa-brands fa-zhihu',
|
||||||
|
百度热搜: 'fa-solid fa-b',
|
||||||
|
今日头条: 'fa-solid fa-newspaper',
|
||||||
|
抖音热榜: 'fa-brands fa-tiktok',
|
||||||
|
抖音: 'fa-brands fa-tiktok',
|
||||||
|
'B站热搜': 'fa-brands fa-bilibili',
|
||||||
|
'bilibili 热搜': 'fa-brands fa-bilibili',
|
||||||
|
华尔街见闻: 'fa-solid fa-chart-line',
|
||||||
|
澎湃新闻: 'fa-solid fa-water',
|
||||||
|
财联社热门: 'fa-solid fa-coins',
|
||||||
|
凤凰网: 'fa-solid fa-feather',
|
||||||
|
贴吧: 'fa-solid fa-comments',
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformColorMap: Record<string, string> = {
|
||||||
|
微博热搜: '#e6162d',
|
||||||
|
微博: '#e6162d',
|
||||||
|
知乎热榜: '#0066ff',
|
||||||
|
知乎: '#0066ff',
|
||||||
|
百度热搜: '#306cff',
|
||||||
|
今日头条: '#ff0000',
|
||||||
|
抖音热榜: '#000000',
|
||||||
|
抖音: '#000000',
|
||||||
|
'B站热搜': '#fb7299',
|
||||||
|
'bilibili 热搜': '#fb7299',
|
||||||
|
华尔街见闻: '#d4a853',
|
||||||
|
澎湃新闻: '#1e6cff',
|
||||||
|
财联社热门: '#c41230',
|
||||||
|
凤凰网: '#f8b500',
|
||||||
|
贴吧: '#4e6ef2',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlatformIcon(name: string): string {
|
||||||
|
return platformIconMap[name] || 'fa-solid fa-globe'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlatformColor(name: string): string {
|
||||||
|
return platformColorMap[name] || 'var(--text-secondary)'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRankingTrend(p: { current_ranking: number | null; ranking_history: number[] }) {
|
||||||
|
const rank = p.current_ranking
|
||||||
|
if (!rank) return { icon: 'fa-solid fa-minus', color: 'var(--text-secondary)', text: '' }
|
||||||
|
|
||||||
|
const h = p.ranking_history
|
||||||
|
if (h.length < 2) return { icon: 'fa-solid fa-minus', color: '#f97316', text: `TOP ${rank}` }
|
||||||
|
|
||||||
|
const prev = h[h.length - 2]
|
||||||
|
const curr = h[h.length - 1]
|
||||||
|
if (prev === undefined || curr === undefined) return { icon: 'fa-solid fa-minus', color: '#f97316', text: `TOP ${rank}` }
|
||||||
|
const diff = prev - curr
|
||||||
|
|
||||||
|
if (diff > 0) {
|
||||||
|
return { icon: 'fa-solid fa-arrow-trend-up', color: '#ef4444', text: `TOP ${rank} ↑${diff}` }
|
||||||
|
}
|
||||||
|
if (diff < 0) {
|
||||||
|
return { icon: 'fa-solid fa-arrow-trend-down', color: '#10b981', text: `TOP ${rank} ↓${Math.abs(diff)}` }
|
||||||
|
}
|
||||||
|
return { icon: 'fa-solid fa-equals', color: '#f97316', text: `TOP ${rank}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRankingChartOptions(history: number[], platformColor: string) {
|
||||||
|
return {
|
||||||
|
series: [{ name: '排名', data: history }],
|
||||||
|
chart: {
|
||||||
|
type: 'area' as const,
|
||||||
|
height: 56,
|
||||||
|
sparkline: { enabled: true },
|
||||||
|
animations: { enabled: true, easing: 'easeinout' as const, speed: 400 },
|
||||||
|
},
|
||||||
|
stroke: { curve: 'smooth' as const, width: 2 },
|
||||||
|
fill: {
|
||||||
|
type: 'gradient',
|
||||||
|
gradient: {
|
||||||
|
shadeIntensity: 1,
|
||||||
|
opacityFrom: 0.4,
|
||||||
|
opacityTo: 0.05,
|
||||||
|
stops: [0, 90, 100],
|
||||||
|
colorStops: [
|
||||||
|
{ offset: 0, color: platformColor, opacity: 0.3 },
|
||||||
|
{ offset: 100, color: platformColor, opacity: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yaxis: { reversed: true },
|
||||||
|
colors: [platformColor],
|
||||||
|
tooltip: {
|
||||||
|
theme: 'dark',
|
||||||
|
fixed: { enabled: false },
|
||||||
|
x: { show: false },
|
||||||
|
y: { title: { formatter: () => '第' }, formatter: (val: number) => `${val} 名` },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function platformKey(eventId: number, index: number, prefix: string = ''): string {
|
||||||
|
return prefix ? `${prefix}-${eventId}-${index}` : `${eventId}-${index}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToDashboard() {
|
||||||
|
router.push({
|
||||||
|
name: 'dashboard',
|
||||||
|
query: { event: props.event.event_id }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article
|
||||||
|
class="event-card"
|
||||||
|
:class="{ 'is-hot': event.hot_score >= 50 }"
|
||||||
|
>
|
||||||
|
<div class="card-top">
|
||||||
|
<div class="tags-row">
|
||||||
|
<span
|
||||||
|
class="hot-badge"
|
||||||
|
:style="{ color: getHotLevel(event.hot_score).color, background: getHotLevel(event.hot_score).bg }"
|
||||||
|
>
|
||||||
|
{{ getHotLevel(event.hot_score).label }} ({{ event.hot_score }})
|
||||||
|
</span>
|
||||||
|
<span v-for="tag in event.tags.slice(0, 3)" :key="tag" class="topic-tag">{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="event-time-range" title="左侧为首次发现时间,右侧为最后活跃时间">
|
||||||
|
<span>{{ formatRelativeTime(event.created_at) }}</span>
|
||||||
|
<template v-if="formatRelativeTime(event.created_at) !== formatRelativeTime(event.last_active_at)">
|
||||||
|
<i class="fa-solid fa-arrow-right time-arrow"></i>
|
||||||
|
<span class="active-time">{{ formatRelativeTime(event.last_active_at) }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="card-title" @click="goToDashboard" style="cursor: pointer;">
|
||||||
|
{{ event.unified_title }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div v-if="event.summary" class="ai-summary">
|
||||||
|
<i class="fa-solid fa-wand-magic-sparkles summary-icon"></i>
|
||||||
|
<p>
|
||||||
|
<span class="summary-label">AI 全局洞察:</span>
|
||||||
|
{{ event.summary }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="event.platforms.length > 0" class="platforms-list">
|
||||||
|
<div
|
||||||
|
v-for="(p, pIdx) in event.platforms"
|
||||||
|
:key="pIdx"
|
||||||
|
class="platform-block"
|
||||||
|
@mouseenter="hoveredPlatformKey = platformKey(event.event_id, pIdx)"
|
||||||
|
@mouseleave="hoveredPlatformKey = null"
|
||||||
|
>
|
||||||
|
<div class="platform-row">
|
||||||
|
<div class="platform-info">
|
||||||
|
<i
|
||||||
|
:class="getPlatformIcon(p.platform_name)"
|
||||||
|
:style="{ color: getPlatformColor(p.platform_name) }"
|
||||||
|
></i>
|
||||||
|
<a
|
||||||
|
v-if="p.url"
|
||||||
|
:href="p.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="platform-headline platform-link"
|
||||||
|
>
|
||||||
|
{{ p.platform_name }}:{{ p.headline }}
|
||||||
|
</a>
|
||||||
|
<span v-else class="platform-headline">{{ p.platform_name }}:{{ p.headline }}</span>
|
||||||
|
</div>
|
||||||
|
<span v-if="p.current_ranking" class="ranking-badge" :style="{ color: getRankingTrend(p).color }">
|
||||||
|
<i :class="getRankingTrend(p).icon"></i> {{ getRankingTrend(p).text }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<transition name="chart-expand">
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
hoveredPlatformKey === platformKey(event.event_id, pIdx) &&
|
||||||
|
p.ranking_history && p.ranking_history.length > 1
|
||||||
|
"
|
||||||
|
class="inline-chart"
|
||||||
|
>
|
||||||
|
<p class="chart-label">
|
||||||
|
<i
|
||||||
|
:class="getPlatformIcon(p.platform_name)"
|
||||||
|
:style="{ color: getPlatformColor(p.platform_name) }"
|
||||||
|
></i>
|
||||||
|
{{ p.platform_name }}「{{ p.headline.length > 16 ? p.headline.slice(0, 16) + '...' : p.headline }}」排名轨迹
|
||||||
|
</p>
|
||||||
|
<VueApexCharts
|
||||||
|
type="area"
|
||||||
|
height="56"
|
||||||
|
:options="getRankingChartOptions(p.ranking_history, getPlatformColor(p.platform_name))"
|
||||||
|
:series="getRankingChartOptions(p.ranking_history, getPlatformColor(p.platform_name)).series"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.event-card {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
backdrop-filter: var(--backdrop-blur);
|
||||||
|
-webkit-backdrop-filter: var(--backdrop-blur);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card:hover {
|
||||||
|
border-color: var(--brand-primary-alpha);
|
||||||
|
box-shadow: var(--shadow-xl);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card.is-hot {
|
||||||
|
border-left: 4px solid #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hot-badge {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-tag {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-time-range {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-arrow {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-time {
|
||||||
|
color: var(--brand-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text-color);
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title:hover {
|
||||||
|
color: var(--brand-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: linear-gradient(145deg, var(--brand-primary-alpha), transparent);
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.1);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: inset 0 2px 4px rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-icon {
|
||||||
|
color: var(--brand-primary);
|
||||||
|
margin-top: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-summary p {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-label {
|
||||||
|
font-weight: 600;
|
||||||
|
background: linear-gradient(to right, #a78bfa, #818cf8);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platforms-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-block {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-block:hover .platform-row {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-color: var(--border-subtle);
|
||||||
|
transform: scale(1.01);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-info i {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-headline {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-link {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-link:hover {
|
||||||
|
color: var(--brand-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-badge {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-left: 8px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-chart {
|
||||||
|
padding: 8px 12px 10px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-label i {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-expand-enter-active {
|
||||||
|
transition: all 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-expand-leave-active {
|
||||||
|
transition: all 0.15s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-expand-enter-from,
|
||||||
|
.chart-expand-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
max-height: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-expand-enter-to,
|
||||||
|
.chart-expand-leave-from {
|
||||||
|
opacity: 1;
|
||||||
|
max-height: 120px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
|
<!-- 图标来源:<https://github.com/Templarian/MaterialDesign>,遵循 Apache 2.0 许可 -->
|
||||||
<template>
|
<template>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -17,3 +17,4 @@
|
|||||||
></path>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const avatarUrl = computed(
|
|||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ name: '全局热点池', icon: 'fa-solid fa-fire', route: '/' },
|
{ name: '全局热点池', icon: 'fa-solid fa-fire', route: '/' },
|
||||||
|
{ name: '事件追溯分析', icon: 'fa-solid fa-chart-line', route: '/search' },
|
||||||
{ name: '公关修改追踪', icon: 'fa-solid fa-mask', route: '/revisions' },
|
{ name: '公关修改追踪', icon: 'fa-solid fa-mask', route: '/revisions' },
|
||||||
{ name: '我的泛订阅', icon: 'fa-solid fa-rss', route: '/topics' },
|
{ name: '我的泛订阅', icon: 'fa-solid fa-rss', route: '/topics' },
|
||||||
{ name: 'AI 简报设置', icon: 'fa-solid fa-paper-plane', route: '/delivery' },
|
{ name: 'AI 简报设置', icon: 'fa-solid fa-paper-plane', route: '/delivery' },
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ const router = createRouter({
|
|||||||
name: 'delivery',
|
name: 'delivery',
|
||||||
component: () => import('@/views/DeliveryView.vue'),
|
component: () => import('@/views/DeliveryView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'search',
|
||||||
|
name: 'search',
|
||||||
|
component: () => import('@/views/SearchView.vue'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export interface UnifiedEvent {
|
|||||||
tags: string[]
|
tags: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 分页包装 */
|
/** 分页响应 */
|
||||||
export interface PaginatedEvents {
|
export interface PaginatedEvents {
|
||||||
total: number
|
total: number
|
||||||
has_more: boolean
|
has_more: boolean
|
||||||
@@ -48,3 +48,17 @@ export interface SystemStats {
|
|||||||
error_tasks_today: number
|
error_tasks_today: number
|
||||||
last_sync_at: string | null
|
last_sync_at: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 搜索时间点数据 */
|
||||||
|
export interface TimelineDataPoint {
|
||||||
|
time_label: string
|
||||||
|
count: number
|
||||||
|
event_ids: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 时间线搜索结果 */
|
||||||
|
export interface SearchTimelineResponse {
|
||||||
|
keyword: string
|
||||||
|
timeline: TimelineDataPoint[]
|
||||||
|
events: UnifiedEvent[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,10 +36,12 @@ function showSuccess(msg: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getChannelLabel(_type: string): string {
|
function getChannelLabel(_type: string): string {
|
||||||
|
void _type
|
||||||
return '邮箱'
|
return '邮箱'
|
||||||
}
|
}
|
||||||
|
|
||||||
function getChannelIcon(_type: string): string {
|
function getChannelIcon(_type: string): string {
|
||||||
|
void _type
|
||||||
return 'fa-solid fa-envelope'
|
return 'fa-solid fa-envelope'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { computed, onMounted, ref } from 'vue'
|
|||||||
import { fetchHeadlineRevisions } from '@/api/events'
|
import { fetchHeadlineRevisions } from '@/api/events'
|
||||||
import type { HeadlineRevision } from '@/types/event'
|
import type { HeadlineRevision } from '@/types/event'
|
||||||
|
|
||||||
/** 按事件分组后的修改链条 */
|
/** 按事件分组后的标题修改链 */
|
||||||
interface RevisionChain {
|
interface RevisionChain {
|
||||||
event_id: number
|
event_id: number
|
||||||
source_name: string | null
|
source_name: string | null
|
||||||
/** 标题演变链:从最早的 previous 到最终 revised,已去重 */
|
/** 标题演变链:从最早 previous 到最新 revised(已去重) */
|
||||||
titles: string[]
|
titles: string[]
|
||||||
/** 每次修改对应的时间(与 titles[i+1] 对应) */
|
/** 每一步标题对应的修改时间(与 titles[i+1] 对应) */
|
||||||
change_times: string[]
|
change_times: string[]
|
||||||
first_at: string
|
first_at: string
|
||||||
last_at: string
|
last_at: string
|
||||||
@@ -23,7 +23,7 @@ const loading = ref(true)
|
|||||||
const error = ref('')
|
const error = ref('')
|
||||||
const hoursRange = ref(48)
|
const hoursRange = ref(48)
|
||||||
|
|
||||||
// 平台名到图标的映射(与首页保持一致,避免同一平台在不同页面图标不一致)
|
// 平台名到图标的映射(与首页保持一致,避免同一平台图标不统一)
|
||||||
const platformIconMap: Record<string, string> = {
|
const platformIconMap: Record<string, string> = {
|
||||||
微博热搜: 'fa-brands fa-weibo',
|
微博热搜: 'fa-brands fa-weibo',
|
||||||
微博: 'fa-brands fa-weibo',
|
微博: 'fa-brands fa-weibo',
|
||||||
@@ -33,7 +33,7 @@ const platformIconMap: Record<string, string> = {
|
|||||||
今日头条: 'fa-solid fa-newspaper',
|
今日头条: 'fa-solid fa-newspaper',
|
||||||
抖音热榜: 'fa-brands fa-tiktok',
|
抖音热榜: 'fa-brands fa-tiktok',
|
||||||
抖音: 'fa-brands fa-tiktok',
|
抖音: 'fa-brands fa-tiktok',
|
||||||
B站热搜: 'fa-brands fa-bilibili',
|
'B站热搜': 'fa-brands fa-bilibili',
|
||||||
'bilibili 热搜': 'fa-brands fa-bilibili',
|
'bilibili 热搜': 'fa-brands fa-bilibili',
|
||||||
华尔街见闻: 'fa-solid fa-chart-line',
|
华尔街见闻: 'fa-solid fa-chart-line',
|
||||||
澎湃新闻: 'fa-solid fa-water',
|
澎湃新闻: 'fa-solid fa-water',
|
||||||
@@ -46,7 +46,7 @@ function getPlatformIcon(name: string): string {
|
|||||||
return platformIconMap[name] || 'fa-solid fa-globe'
|
return platformIconMap[name] || 'fa-solid fa-globe'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 格式化时间 */
|
/** 安全解析时间:兼容后端返回未携带时区标记的字符串 */
|
||||||
function safeParseTime(dateStr: string): number {
|
function safeParseTime(dateStr: string): number {
|
||||||
if (!dateStr.endsWith('Z') && !dateStr.includes('+')) {
|
if (!dateStr.endsWith('Z') && !dateStr.includes('+')) {
|
||||||
dateStr += 'Z'
|
dateStr += 'Z'
|
||||||
@@ -54,6 +54,7 @@ function safeParseTime(dateStr: string): number {
|
|||||||
return new Date(dateStr).getTime()
|
return new Date(dateStr).getTime()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 格式化为相对时间 */
|
||||||
function formatTime(dateStr: string): string {
|
function formatTime(dateStr: string): string {
|
||||||
const d = new Date(safeParseTime(dateStr))
|
const d = new Date(safeParseTime(dateStr))
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
@@ -67,8 +68,8 @@ function formatTime(dateStr: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将原始修改记录按 event_id 分组,并在每组内拼接完整的标题演变链。
|
* 将修改记录按 event_id 分组,拼接成完整标题演变链。
|
||||||
* 规则:同组内按 created_at 升序排列,然后依次将 previous/revised 串成链条。
|
* 规则:组内按 created_at 升序,依次把 previous/revised 串成链,并去掉重复节点。
|
||||||
*/
|
*/
|
||||||
const revisionChains = computed<RevisionChain[]>(() => {
|
const revisionChains = computed<RevisionChain[]>(() => {
|
||||||
// 按 event_id 分组
|
// 按 event_id 分组
|
||||||
@@ -81,17 +82,20 @@ const revisionChains = computed<RevisionChain[]>(() => {
|
|||||||
|
|
||||||
const chains: RevisionChain[] = []
|
const chains: RevisionChain[] = []
|
||||||
for (const [event_id, items] of groups) {
|
for (const [event_id, items] of groups) {
|
||||||
const first = items[0]
|
if (items.length === 0) continue
|
||||||
const last = items[items.length - 1]
|
|
||||||
if (!first || !last) continue
|
|
||||||
// 组内按时间升序
|
// 组内按时间升序
|
||||||
items.sort((a, b) => safeParseTime(a.created_at) - safeParseTime(b.created_at))
|
items.sort((a, b) => safeParseTime(a.created_at) - safeParseTime(b.created_at))
|
||||||
|
|
||||||
// 拼接标题链,避免重复(相邻记录的 revised 与下一条 previous 通常相同)
|
const first = items[0]
|
||||||
|
const last = items[items.length - 1]
|
||||||
|
|
||||||
|
// 拼接标题链,避免相邻记录重复
|
||||||
const titles: string[] = [first.previous_headline]
|
const titles: string[] = [first.previous_headline]
|
||||||
const change_times: string[] = []
|
const change_times: string[] = []
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
// 若链条末尾与本条 previous 不同,说明有断层,仍然追加
|
// 如果链尾与当前 previous 不一致,说明链条中间有断层,补入 previous
|
||||||
if (titles[titles.length - 1] !== item.previous_headline) {
|
if (titles[titles.length - 1] !== item.previous_headline) {
|
||||||
titles.push(item.previous_headline)
|
titles.push(item.previous_headline)
|
||||||
change_times.push(item.created_at)
|
change_times.push(item.created_at)
|
||||||
@@ -112,12 +116,12 @@ const revisionChains = computed<RevisionChain[]>(() => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 最终按最新修改时间降序
|
// 按最新修改时间降序
|
||||||
chains.sort((a, b) => safeParseTime(b.last_at) - safeParseTime(a.last_at))
|
chains.sort((a, b) => safeParseTime(b.last_at) - safeParseTime(a.last_at))
|
||||||
return chains
|
return chains
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 加载数据 */
|
/** 加载修改记录 */
|
||||||
async function loadRevisions() {
|
async function loadRevisions() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@@ -130,7 +134,7 @@ async function loadRevisions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 切换时间范围 */
|
/** 切换时间范围并重载 */
|
||||||
function changeRange(hours: number) {
|
function changeRange(hours: number) {
|
||||||
hoursRange.value = hours
|
hoursRange.value = hours
|
||||||
loadRevisions()
|
loadRevisions()
|
||||||
@@ -148,14 +152,13 @@ onMounted(loadRevisions)
|
|||||||
公关修改追踪
|
公关修改追踪
|
||||||
</h1>
|
</h1>
|
||||||
<p class="page-desc">
|
<p class="page-desc">
|
||||||
实时监控各平台热搜标题被暗改的记录。当爬虫检测到标题变更时会自动记录修改前后的差异。
|
实时监控各平台热搜标题被悄悄修改的记录。当爬虫检测到标题变化时,系统会自动保留修改前后的差异轨迹。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 时间范围选择 -->
|
|
||||||
<div class="filter-bar">
|
<div class="filter-bar">
|
||||||
<span class="filter-label">查看范围:</span>
|
<span class="filter-label">查看范围:</span>
|
||||||
<div class="filter-tabs">
|
<div class="filter-tabs">
|
||||||
<button
|
<button
|
||||||
v-for="opt in [{ label: '24小时', value: 24 }, { label: '48小时', value: 48 }, { label: '7天', value: 168 }]"
|
v-for="opt in [{ label: '24小时', value: 24 }, { label: '48小时', value: 48 }, { label: '7天', value: 168 }]"
|
||||||
@@ -170,26 +173,22 @@ onMounted(loadRevisions)
|
|||||||
<span class="result-count">共 {{ revisionChains.length }} 个事件 · {{ revisions.length }} 次修改</span>
|
<span class="result-count">共 {{ revisionChains.length }} 个事件 · {{ revisions.length }} 次修改</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 加载状态 -->
|
|
||||||
<div v-if="loading" class="loading-state">
|
<div v-if="loading" class="loading-state">
|
||||||
<i class="fa-solid fa-spinner fa-spin"></i>
|
<i class="fa-solid fa-spinner fa-spin"></i>
|
||||||
<span>加载中...</span>
|
<span>加载中...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 错误状态 -->
|
|
||||||
<div v-else-if="error" class="error-state">
|
<div v-else-if="error" class="error-state">
|
||||||
<i class="fa-solid fa-circle-exclamation"></i>
|
<i class="fa-solid fa-circle-exclamation"></i>
|
||||||
<span>{{ error }}</span>
|
<span>{{ error }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 空状态 -->
|
|
||||||
<div v-else-if="revisions.length === 0" class="empty-state">
|
<div v-else-if="revisions.length === 0" class="empty-state">
|
||||||
<i class="fa-solid fa-shield-check"></i>
|
<i class="fa-solid fa-shield-check"></i>
|
||||||
<p>该时段内未检测到标题修改</p>
|
<p>该时段内未检测到标题修改</p>
|
||||||
<p class="empty-hint">这是个好消息!说明各平台暂无异常公关操作</p>
|
<p class="empty-hint">这是个好消息,说明当前没有明显的异常公关操作。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 修改记录列表(按事件分组,展示完整标题演变链) -->
|
|
||||||
<div v-else class="revision-list">
|
<div v-else class="revision-list">
|
||||||
<div v-for="chain in revisionChains" :key="chain.event_id" class="revision-card">
|
<div v-for="chain in revisionChains" :key="chain.event_id" class="revision-card">
|
||||||
<div class="revision-header">
|
<div class="revision-header">
|
||||||
@@ -218,10 +217,8 @@ onMounted(loadRevisions)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 标题演变链 -->
|
|
||||||
<div class="chain-area">
|
<div class="chain-area">
|
||||||
<template v-for="(title, idx) in chain.titles" :key="idx">
|
<template v-for="(title, idx) in chain.titles" :key="idx">
|
||||||
<!-- 标题节点 -->
|
|
||||||
<div
|
<div
|
||||||
class="chain-title"
|
class="chain-title"
|
||||||
:class="{
|
:class="{
|
||||||
@@ -238,7 +235,6 @@ onMounted(loadRevisions)
|
|||||||
{{ formatTime(chain.change_times[idx]) }}
|
{{ formatTime(chain.change_times[idx]) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- 箭头分隔(最后一个标题后不需要) -->
|
|
||||||
<div v-if="idx < chain.titles.length - 1" class="chain-arrow">
|
<div v-if="idx < chain.titles.length - 1" class="chain-arrow">
|
||||||
<i class="fa-solid fa-arrow-down"></i>
|
<i class="fa-solid fa-arrow-down"></i>
|
||||||
</div>
|
</div>
|
||||||
@@ -275,9 +271,7 @@ onMounted(loadRevisions)
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================
|
/* 过滤栏 */
|
||||||
过滤栏
|
|
||||||
========================================== */
|
|
||||||
.filter-bar {
|
.filter-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -328,9 +322,7 @@ onMounted(loadRevisions)
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================
|
/* 状态区 */
|
||||||
状态
|
|
||||||
========================================== */
|
|
||||||
.loading-state,
|
.loading-state,
|
||||||
.error-state {
|
.error-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -370,9 +362,7 @@ onMounted(loadRevisions)
|
|||||||
color: var(--text-placeholder);
|
color: var(--text-placeholder);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================
|
/* 修改记录卡片 */
|
||||||
修改记录卡片
|
|
||||||
========================================== */
|
|
||||||
.revision-list {
|
.revision-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -451,9 +441,7 @@ onMounted(loadRevisions)
|
|||||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================
|
/* 标题演变链 */
|
||||||
标题演变链
|
|
||||||
========================================== */
|
|
||||||
.chain-area {
|
.chain-area {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -469,7 +457,7 @@ onMounted(loadRevisions)
|
|||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 原始标题 —— 红色删除线风格 */
|
/* 原始标题:红色删除线 */
|
||||||
.chain-title--original {
|
.chain-title--original {
|
||||||
background: rgba(239, 68, 68, 0.05);
|
background: rgba(239, 68, 68, 0.05);
|
||||||
border-color: rgba(239, 68, 68, 0.12);
|
border-color: rgba(239, 68, 68, 0.12);
|
||||||
@@ -486,7 +474,7 @@ onMounted(loadRevisions)
|
|||||||
text-decoration-color: var(--status-error);
|
text-decoration-color: var(--status-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 中间过渡版本 —— 橙/琥珀色风格 */
|
/* 中间版本:橙色提示 */
|
||||||
.chain-title--middle {
|
.chain-title--middle {
|
||||||
background: rgba(245, 158, 11, 0.05);
|
background: rgba(245, 158, 11, 0.05);
|
||||||
border-color: rgba(245, 158, 11, 0.15);
|
border-color: rgba(245, 158, 11, 0.15);
|
||||||
@@ -501,7 +489,7 @@ onMounted(loadRevisions)
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 当前最新版本 —— 绿色高亮风格 */
|
/* 当前版本:绿色高亮 */
|
||||||
.chain-title--current {
|
.chain-title--current {
|
||||||
background: rgba(16, 185, 129, 0.05);
|
background: rgba(16, 185, 129, 0.05);
|
||||||
border-color: rgba(16, 185, 129, 0.12);
|
border-color: rgba(16, 185, 129, 0.12);
|
||||||
|
|||||||
@@ -0,0 +1,704 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import VueApexCharts from 'vue3-apexcharts'
|
||||||
|
import { searchEventsTimeline } from '@/api/events'
|
||||||
|
import type { SearchTimelineResponse } from '@/types/event'
|
||||||
|
import UnifiedEventCard from '@/components/UnifiedEventCard.vue'
|
||||||
|
|
||||||
|
const keyword = ref('')
|
||||||
|
const searchResult = ref<SearchTimelineResponse | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const hours = ref(2)
|
||||||
|
const searchMode = ref<'exact' | 'semantic' | 'hybrid'>('hybrid')
|
||||||
|
const selectedTimeLabel = ref<string | null>(null)
|
||||||
|
|
||||||
|
const filteredEvents = computed(() => {
|
||||||
|
if (!searchResult.value) return []
|
||||||
|
if (!selectedTimeLabel.value) return searchResult.value.events
|
||||||
|
|
||||||
|
const selectedPoint = searchResult.value.timeline.find(p => p.time_label === selectedTimeLabel.value)
|
||||||
|
if (!selectedPoint) return []
|
||||||
|
|
||||||
|
const pointEventIds = selectedPoint.event_ids ?? []
|
||||||
|
if (pointEventIds.length > 0) {
|
||||||
|
const relatedIds = new Set(pointEventIds)
|
||||||
|
return searchResult.value.events.filter(event => relatedIds.has(event.event_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 向后兼容:旧版后端未返回 event_ids 时,退化为时间范围过滤。
|
||||||
|
return searchResult.value.events.filter(event => {
|
||||||
|
const createdStr = event.created_at.replace('T', ' ')
|
||||||
|
const activeStr = event.last_active_at.replace('T', ' ')
|
||||||
|
const len = selectedTimeLabel.value!.length
|
||||||
|
const start = createdStr.substring(0, len)
|
||||||
|
const end = activeStr.substring(0, len)
|
||||||
|
const target = selectedTimeLabel.value!
|
||||||
|
return (start <= target && target <= end) || createdStr.startsWith(target) || activeStr.startsWith(target)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 热度时间线图表配置。
|
||||||
|
const chartOptions = ref({
|
||||||
|
chart: {
|
||||||
|
type: 'area',
|
||||||
|
height: 350,
|
||||||
|
background: 'transparent',
|
||||||
|
toolbar: {
|
||||||
|
show: true
|
||||||
|
},
|
||||||
|
animations: {
|
||||||
|
enabled: true,
|
||||||
|
easing: 'easeinout',
|
||||||
|
speed: 800,
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
markerClick: function(event: any, chartContext: any, { dataPointIndex }: any) {
|
||||||
|
if (searchResult.value && searchResult.value.timeline[dataPointIndex]) {
|
||||||
|
const clickedTime = searchResult.value.timeline[dataPointIndex].time_label
|
||||||
|
if (selectedTimeLabel.value === clickedTime) {
|
||||||
|
selectedTimeLabel.value = null
|
||||||
|
} else {
|
||||||
|
selectedTimeLabel.value = clickedTime
|
||||||
|
// 点击时间点后平滑滚动到事件列表。
|
||||||
|
setTimeout(() => {
|
||||||
|
document.getElementById('events-section-anchor')?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
mode: 'dark' // 默认使用暗色图表主题,保证深浅主题下对比度都清晰。
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: false
|
||||||
|
},
|
||||||
|
stroke: {
|
||||||
|
curve: 'smooth',
|
||||||
|
width: 3
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
type: 'category',
|
||||||
|
categories: [] as string[],
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
colors: '#9ca3af'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisBorder: {
|
||||||
|
show: false
|
||||||
|
},
|
||||||
|
axisTicks: {
|
||||||
|
show: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
title: {
|
||||||
|
text: '热点数量',
|
||||||
|
style: {
|
||||||
|
color: '#9ca3af',
|
||||||
|
fontWeight: 500
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
style: {
|
||||||
|
colors: '#9ca3af'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
type: 'gradient',
|
||||||
|
gradient: {
|
||||||
|
shadeIntensity: 1,
|
||||||
|
opacityFrom: 0.6,
|
||||||
|
opacityTo: 0.1,
|
||||||
|
stops: [0, 90, 100]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
borderColor: 'rgba(156, 163, 175, 0.1)',
|
||||||
|
strokeDashArray: 4,
|
||||||
|
},
|
||||||
|
colors: ['#6366f1'],
|
||||||
|
tooltip: {
|
||||||
|
theme: 'dark'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const series = ref([{
|
||||||
|
name: '热点分布',
|
||||||
|
data: [] as number[]
|
||||||
|
}])
|
||||||
|
|
||||||
|
async function handleSearch() {
|
||||||
|
if (!keyword.value.trim()) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
searchResult.value = null
|
||||||
|
selectedTimeLabel.value = null
|
||||||
|
try {
|
||||||
|
const res = await searchEventsTimeline(keyword.value.trim(), hours.value, searchMode.value)
|
||||||
|
searchResult.value = res
|
||||||
|
|
||||||
|
// 查询成功后同步刷新图表横轴与序列。
|
||||||
|
chartOptions.value = {
|
||||||
|
...chartOptions.value,
|
||||||
|
xaxis: {
|
||||||
|
...chartOptions.value.xaxis,
|
||||||
|
categories: res.timeline.map(p => p.time_label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
series.value = [{
|
||||||
|
name: '热点数量',
|
||||||
|
data: res.timeline.map(p => p.count)
|
||||||
|
}]
|
||||||
|
} catch (error) {
|
||||||
|
console.error('搜索失败', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="search-view">
|
||||||
|
<div class="content-wrapper">
|
||||||
|
<header class="page-header">
|
||||||
|
<div class="header-title-row">
|
||||||
|
<h1 class="page-title">
|
||||||
|
<i class="fa-solid fa-chart-line" style="color: var(--brand-primary)"></i>
|
||||||
|
事件追踪分析
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p class="page-desc">基于语义匹配与正则检索,分析关键词在时间维度上的热度变化与关联聚合事件。</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="top-panels">
|
||||||
|
<div class="search-box glass-panel">
|
||||||
|
<h2 class="panel-title"><i class="fa-solid fa-magnifying-glass-chart"></i> 关键词搜索</h2>
|
||||||
|
<div class="search-controls">
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<i class="fa-solid fa-magnifying-glass search-icon"></i>
|
||||||
|
<input
|
||||||
|
v-model="keyword"
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入关键词"
|
||||||
|
class="search-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="time-select-wrapper">
|
||||||
|
<i class="fa-regular fa-clock select-icon"></i>
|
||||||
|
<select v-model="hours" class="time-select">
|
||||||
|
<option :value="2">最近 2 小时</option>
|
||||||
|
<option :value="12">最近 12 小时</option>
|
||||||
|
<option :value="24">最近 24 小时</option>
|
||||||
|
<option :value="48">最近 48 小时</option>
|
||||||
|
<option :value="168">最近 7 天</option>
|
||||||
|
<option :value="360">最近 15 天</option>
|
||||||
|
</select>
|
||||||
|
<i class="fa-solid fa-chevron-down select-arrow"></i>
|
||||||
|
</div>
|
||||||
|
<div class="time-select-wrapper">
|
||||||
|
<i class="fa-solid fa-filter select-icon"></i>
|
||||||
|
<select v-model="searchMode" class="time-select">
|
||||||
|
<option value="hybrid">混合匹配</option>
|
||||||
|
<option value="exact">关键词匹配</option>
|
||||||
|
<option value="semantic">语义匹配</option>
|
||||||
|
</select>
|
||||||
|
<i class="fa-solid fa-chevron-down select-arrow"></i>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" @click="handleSearch" :disabled="loading || !keyword.trim()">
|
||||||
|
<i class="fa-solid fa-bolt" v-if="!loading"></i>
|
||||||
|
<i class="fa-solid fa-spinner fa-spin" v-else></i>
|
||||||
|
追踪计算
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tips-box glass-panel">
|
||||||
|
<h2 class="panel-title"><i class="fa-regular fa-lightbulb"></i> 搜索建议</h2>
|
||||||
|
<div class="tips-content">
|
||||||
|
<button class="tip-tag" @click="keyword='新能源汽车'; hours=168; handleSearch()">
|
||||||
|
<i class="fa-solid fa-rocket"></i> 新能源汽车
|
||||||
|
</button>
|
||||||
|
<button class="tip-tag" @click="keyword='苹果公司'; hours=168; handleSearch()">
|
||||||
|
<i class="fa-brands fa-apple"></i> 苹果产业链
|
||||||
|
</button>
|
||||||
|
<button class="tip-tag regex-tag" @click="keyword='AI|LLM'; hours=168; handleSearch()">
|
||||||
|
<i class="fa-solid fa-code-branch"></i> AI / 大模型
|
||||||
|
</button>
|
||||||
|
<button class="tip-tag regex-tag" @click="keyword='美国关税'; hours=168; handleSearch()">
|
||||||
|
<i class="fa-solid fa-flag-usa"></i> 美国关税
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-state glass-panel">
|
||||||
|
<div class="spinner-wrapper">
|
||||||
|
<i class="fa-solid fa-circle-notch fa-spin"></i>
|
||||||
|
</div>
|
||||||
|
<p>引擎正在计算热度模型并提取时间节点,请稍候...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="searchResult" class="results-container">
|
||||||
|
<section class="chart-section glass-panel">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">
|
||||||
|
<i class="fa-solid fa-wave-square"></i> 时间热度脉络
|
||||||
|
</h2>
|
||||||
|
<span class="meta-info">共 {{ searchResult.timeline.length }} 个时间节点 · 覆盖 {{ searchResult.events.length }} 个聚合事件</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-container" v-if="searchResult.timeline.length > 0">
|
||||||
|
<VueApexCharts
|
||||||
|
type="area"
|
||||||
|
height="350"
|
||||||
|
:options="chartOptions"
|
||||||
|
:series="series"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<i class="fa-regular fa-folder-open"></i>
|
||||||
|
<p>该时间范围暂无热度节点</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="events-section-anchor" class="events-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">
|
||||||
|
<i class="fa-solid fa-layer-group"></i> 关联深度聚合事件
|
||||||
|
<span v-if="selectedTimeLabel" class="time-filter-badge">
|
||||||
|
筛选: {{ selectedTimeLabel }}
|
||||||
|
<i class="fa-solid fa-xmark" @click="selectedTimeLabel = null"></i>
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<span class="meta-info">共检索到 {{ filteredEvents.length }} 个聚合事件</span>
|
||||||
|
</div>
|
||||||
|
<div class="events-grid" v-if="filteredEvents.length > 0">
|
||||||
|
<UnifiedEventCard
|
||||||
|
v-for="event in filteredEvents"
|
||||||
|
:key="event.event_id"
|
||||||
|
:event="event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-state glass-panel">
|
||||||
|
<i class="fa-solid fa-satellite-dish"></i>
|
||||||
|
<p v-if="selectedTimeLabel">该时间点未找到匹配事件,请尝试点击其他节点</p>
|
||||||
|
<p v-else>暂无关联聚合事件,可尝试扩大时间范围或更换关键词</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.search-view {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-color);
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-desc {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 毛玻璃面板 */
|
||||||
|
.glass-panel {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
backdrop-filter: var(--backdrop-blur);
|
||||||
|
-webkit-backdrop-filter: var(--backdrop-blur);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: 24px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-panel:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
border-color: rgba(99, 102, 241, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-panels {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
flex: 2;
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips-box {
|
||||||
|
flex: 1;
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title i {
|
||||||
|
color: var(--brand-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips-content {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip-tag {
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
font-weight: 500;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip-tag i {
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip-tag:hover {
|
||||||
|
background: var(--brand-primary-alpha);
|
||||||
|
color: var(--brand-primary);
|
||||||
|
border-color: var(--brand-primary);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip-tag.regex-tag {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 250px;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 16px;
|
||||||
|
color: var(--text-placeholder);
|
||||||
|
font-size: 16px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 16px 14px 44px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background-color: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--brand-primary);
|
||||||
|
box-shadow: 0 0 0 3px var(--brand-primary-alpha);
|
||||||
|
background-color: var(--bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-select-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 14px;
|
||||||
|
color: var(--text-placeholder);
|
||||||
|
font-size: 15px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-arrow {
|
||||||
|
position: absolute;
|
||||||
|
right: 14px;
|
||||||
|
color: var(--text-placeholder);
|
||||||
|
font-size: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 36px 14px 40px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background-color: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--brand-primary);
|
||||||
|
box-shadow: 0 0 0 3px var(--brand-primary-alpha);
|
||||||
|
background-color: var(--bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-select-wrapper:hover .time-select {
|
||||||
|
border-color: var(--brand-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-select-wrapper:hover .select-icon,
|
||||||
|
.time-select-wrapper:hover .select-arrow {
|
||||||
|
color: var(--brand-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 28px;
|
||||||
|
background-color: var(--brand-primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background-color: var(--brand-primary-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 16px rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:active:not(:disabled) {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
animation: fadeIn 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-color);
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title i {
|
||||||
|
color: var(--brand-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-filter-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-left: 12px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
color: var(--brand-primary);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-filter-badge i {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--brand-primary) !important;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-filter-badge i:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-info {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-section {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
margin-top: 16px;
|
||||||
|
margin-left: -10px; /* 视觉上抵消 apexcharts 的默认左侧留白。 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.events-section {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.events-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
/* 与 DashboardView 保持一致,列表按纵向堆叠展示。 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-wrapper {
|
||||||
|
font-size: 32px;
|
||||||
|
color: var(--brand-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state p {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state i {
|
||||||
|
font-size: 36px;
|
||||||
|
opacity: 0.4;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.top-panels {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.search-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,7 +2,7 @@ import { fileURLToPath, URL } from 'node:url'
|
|||||||
|
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
// import vueDevTools from 'vite-plugin-vue-devtools'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
|||||||
Reference in New Issue
Block a user