From 6aee65af6cae5bf94bc1aed173f894417bfffcb4 Mon Sep 17 00:00:00 2001 From: stardrophere <1925008984@qq.com> Date: Fri, 13 Mar 2026 18:25:38 +0800 Subject: [PATCH] =?UTF-8?q?=E6=90=9C=E7=B4=A2=E5=8A=9F=E8=83=BD=E5=8A=A0?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/endpoints/events.py | 299 ++++++-- backend/app/schemas/event_schema.py | 10 + backend/app/services/summary_service.py | 2 +- frontend/package-lock.json | 11 + frontend/package.json | 1 + frontend/src/api/client.ts | 14 +- frontend/src/api/events.ts | 20 +- frontend/src/assets/base.css | 5 +- frontend/src/components/ThemeToggle.vue | 2 +- frontend/src/components/UnifiedEventCard.vue | 479 ++++++++++++ frontend/src/components/icons/IconTooling.vue | 3 +- frontend/src/layouts/DashboardLayout.vue | 1 + frontend/src/router/index.ts | 5 + frontend/src/types/event.ts | 16 +- frontend/src/views/DeliveryView.vue | 2 + frontend/src/views/RevisionsView.vue | 72 +- frontend/src/views/SearchView.vue | 704 ++++++++++++++++++ frontend/vite.config.ts | 2 +- 18 files changed, 1545 insertions(+), 103 deletions(-) create mode 100644 frontend/src/components/UnifiedEventCard.vue create mode 100644 frontend/src/views/SearchView.vue diff --git a/backend/app/api/endpoints/events.py b/backend/app/api/endpoints/events.py index 0533ef8..aff6e64 100644 --- a/backend/app/api/endpoints/events.py +++ b/backend/app/api/endpoints/events.py @@ -1,8 +1,12 @@ -# app/api/endpoints/events.py +# app/api/endpoints/events.py +import json +import os import time -from datetime import timedelta -from typing import Dict, List, Tuple +from datetime import datetime, timedelta, timezone +from typing import Dict, Tuple +import numpy as np +from dotenv import load_dotenv from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session @@ -19,63 +23,83 @@ from app.models.models import ( from app.schemas.event_schema import ( PaginatedUnifiedEventResponse, PlatformTrendResponse, + SearchTimelineResponse, + TimelineDataPoint, 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() -# 排名轨迹最多返回多少个点,避免长时间跨度下数据过大 +# 排名轨迹最多返回的点数,避免时间跨度过大时响应体过重。 MAX_RANKING_POINTS = 30 -# --- 轻量级接口缓存配置 --- +# 统一事件列表接口的短期缓存。 _UNIFIED_EVENTS_CACHE: Dict[str, Tuple[float, PaginatedUnifiedEventResponse]] = {} 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) 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="查询最近多少小时的数据"), - 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="分页偏移量"), - limit: int = Query(10, ge=1, le=50, description="每页返回条数"), + limit: int = Query(10, ge=1, le=50, description="每页条数"), db: Session = Depends(get_db), ): - """分页返回统一事件,附带各平台热搜、排名轨迹和标签。""" - - # --- 1. 尝试从缓存读取 --- + """查询统一事件列表,并附带平台趋势与标签信息。""" + cache_key = f"{min_hot}:{hours}:{sort_by}:{skip}:{limit}" current_time = time.time() - if cache_key in _UNIFIED_EVENTS_CACHE: expire_time, cached_data = _UNIFIED_EVENTS_CACHE[cache_key] if current_time < expire_time: return cached_data - # ----------------------- time_limit = utcnow() - timedelta(hours=hours) - # 先查总数,用于前端判断是否还有更多 base_query = db.query(UnifiedEvent).filter( UnifiedEvent.hot_score >= min_hot, UnifiedEvent.created_at >= time_limit, ) total = base_query.count() - # 分页查询 if sort_by == "created_at": base_query = base_query.order_by(UnifiedEvent.created_at.desc()) else: base_query = base_query.order_by(UnifiedEvent.hot_score.desc(), UnifiedEvent.created_at.desc()) events = base_query.offset(skip).limit(limit).all() - if not events: return PaginatedUnifiedEventResponse(total=total, has_more=False, data=[]) event_ids = [ev.id for ev in events] - # 批量查询所有相关的热搜条目(避免 N+1) trend_rows = ( db.query(TrendingEvent, InfoSource.source_name) .join(InfoSource, TrendingEvent.source_id == InfoSource.id) @@ -83,21 +107,16 @@ def list_unified_events( .all() ) - # 按 unified_event_id 分组 - trend_map: dict[int, list[tuple]] = {} + trend_map: dict[int, list[tuple[TrendingEvent, str]]] = {} trend_ids: list[int] = [] 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, - ) + db.query(RankingLog.event_id, RankingLog.ranking_position) .filter( RankingLog.event_id.in_(trend_ids), RankingLog.observed_at >= time_limit, @@ -108,7 +127,6 @@ def list_unified_events( for event_id, position in ranking_rows: ranking_map.setdefault(event_id, []).append(position) - # 批量查询标签 tag_map: dict[int, list[str]] = {} tag_rows = ( db.query(ExtractedTopic.target_id, ExtractedTopic.topic_keyword) @@ -122,14 +140,13 @@ def list_unified_events( for target_id, keyword in tag_rows: tag_map.setdefault(target_id, []).append(keyword) - # 组装响应 results: list[UnifiedEventResponse] = [] for ev in events: platform_list: list[PlatformTrendResponse] = [] trends_for_ev = trend_map.get(ev.id, []) + for trend, source_name in trends_for_ev: history = ranking_map.get(trend.id, []) - # 截取尾部,只保留最近的点 if len(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( UnifiedEventResponse( 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, hot_score=ev.hot_score, created_at=ev.created_at, @@ -167,13 +179,9 @@ def list_unified_events( has_more = (skip + limit) < total response = PaginatedUnifiedEventResponse(total=total, has_more=has_more, data=results) - # --- 2. 写入缓存 --- if len(_UNIFIED_EVENTS_CACHE) > 1000: - # 防止内存无限增长 _UNIFIED_EVENTS_CACHE.clear() - _UNIFIED_EVENTS_CACHE[cache_key] = (current_time + CACHE_TTL_SECONDS, response) - # ------------------ return response @@ -183,7 +191,7 @@ def get_unified_event( event_id: int, db: Session = Depends(get_db), ): - """按 ID 查询单个统一事件,用于推荐跳转时的聚光灯展示。""" + """按事件 ID 获取单个统一事件。""" ev = db.query(UnifiedEvent).filter(UnifiedEvent.id == event_id).first() if not ev: raise HTTPException(status_code=404, detail="Event not found") @@ -228,6 +236,7 @@ def get_unified_event( 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, @@ -239,15 +248,11 @@ def get_unified_event( ) ) - last_active_at = ( - max(t.updated_at for t, _ in trend_rows) - if trend_rows - else ev.updated_at - ) + last_active_at = max(t.updated_at for t, _ in trend_rows) if trend_rows else ev.updated_at return UnifiedEventResponse( 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, hot_score=ev.hot_score, created_at=ev.created_at, @@ -255,3 +260,205 @@ def get_unified_event( platforms=platform_list, 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) diff --git a/backend/app/schemas/event_schema.py b/backend/app/schemas/event_schema.py index 6b5b15e..5ea4268 100644 --- a/backend/app/schemas/event_schema.py +++ b/backend/app/schemas/event_schema.py @@ -24,6 +24,16 @@ class UnifiedEventResponse(BaseModel): 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): """分页包装:避免一次性返回全量数据""" total: int diff --git a/backend/app/services/summary_service.py b/backend/app/services/summary_service.py index eaa49a7..e83aed9 100644 --- a/backend/app/services/summary_service.py +++ b/backend/app/services/summary_service.py @@ -135,7 +135,7 @@ def normalize_topic_keywords(topic_candidates: list[dict[str, Any]]) -> list[dic ): cluster["score"] = item["score"] - # Prefer shorter tag as canonical keyword. + # 优先选择更短的标签作为规范关键词,减少同义长短词分裂。 if len(item["keyword"]) < len(cluster["keyword"]): cluster["keyword"] = item["keyword"] else: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4ea0512..b36115a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "apexcharts": "^5.10.3", + "date-fns": "^4.1.0", "pinia": "^3.0.4", "vue": "^3.5.29", "vue-router": "^5.0.3", @@ -2869,6 +2870,16 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "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": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index f1b772e..9162486 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "apexcharts": "^5.10.3", + "date-fns": "^4.1.0", "pinia": "^3.0.4", "vue": "^3.5.29", "vue-router": "^5.0.3", diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 32bda97..c3cb54c 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,17 +1,17 @@ /** - * 通用 HTTP 客户端:自动注入 Bearer Token,统一处理错误 + * 通用 HTTP 客户端:自动注入 Bearer Token,并统一处理错误返回。 */ import { useAuthStore } from '@/stores/auth' import { pinia } from '@/stores' import { fetchApi } from '@/config/apiBase' -// 后端返回的错误消息中英映射 +// 后端错误消息中英文映射。 const MESSAGE_MAP: Record = { - 'You can only operate your own resources': '只能操作自己的资源', - 'Preference keyword already exists for this user': '该关键词已订阅', + 'You can only operate your own resources': '只能操作你自己的资源', + 'Preference keyword already exists for this user': '该关键词已经订阅过了', 'Keyword cannot be empty': '关键词不能为空', 'This delivery time already exists': '该推送时间已存在', - 'This channel type already exists for the user': '该渠道类型已存在', + 'This channel type already exists for the user': '该推送渠道类型已存在', 'Schedule not found': '推送时间不存在', 'Push endpoint not found': '推送渠道不存在', 'Preference not found': '偏好不存在', @@ -101,7 +101,9 @@ export async function apiDelete(path: string): Promise { try { const data = JSON.parse(raw) as Record if (typeof data.detail === 'string') detail = localizeMessage(data.detail) - } catch { /* ignore */ } + } catch { + /* 忽略非 JSON 错误体解析失败 */ + } throw new Error(detail) } } diff --git a/frontend/src/api/events.ts b/frontend/src/api/events.ts index ecb37c4..c21b4ef 100644 --- a/frontend/src/api/events.ts +++ b/frontend/src/api/events.ts @@ -1,7 +1,7 @@ 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 { return apiGet(`/events/unified/${eventId}`) } @@ -29,3 +29,19 @@ export function fetchHeadlineRevisions(params?: { export function fetchSystemStats(): Promise { return apiGet('/system/stats') } + +/** 按关键词查询热度时间线 */ +export function searchEventsTimeline( + keyword: string, + hours: number = 168, + mode: 'exact' | 'semantic' | 'hybrid' = 'hybrid' +): Promise { + // JS 的 getTimezoneOffset: 本地 - UTC(东八区是 -480),这里转成 UTC+ 偏移分钟。 + const utcOffsetMinutes = -new Date().getTimezoneOffset() + return apiGet('/events/search_timeline', { + keyword, + hours: hours.toString(), + mode, + utc_offset_minutes: utcOffsetMinutes.toString(), + }) +} diff --git a/frontend/src/assets/base.css b/frontend/src/assets/base.css index 0635b51..3ffe1e4 100644 --- a/frontend/src/assets/base.css +++ b/frontend/src/assets/base.css @@ -1,4 +1,4 @@ -/* color palette from */ +/* 颜色变量基于 Vue 官方主题并按项目需求调整 */ :root { --vt-c-white: #ffffff; --vt-c-white-soft: #f8f8f8; @@ -21,7 +21,7 @@ --vt-c-text-dark-2: rgba(235, 235, 235, 0.64); } -/* semantic color variables for this project */ +/* 项目语义化颜色变量 */ :root { --color-background: var(--vt-c-white); --color-background-soft: var(--vt-c-white-soft); @@ -81,3 +81,4 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } + diff --git a/frontend/src/components/ThemeToggle.vue b/frontend/src/components/ThemeToggle.vue index c98fe05..ec9ab2f 100644 --- a/frontend/src/components/ThemeToggle.vue +++ b/frontend/src/components/ThemeToggle.vue @@ -26,7 +26,7 @@ function handleToggle(event: MouseEvent) { Math.max(y, innerHeight - y) ) - // @ts-ignore: TypeScript 类型可能较旧,忽略 startViewTransition 报错 + // @ts-expect-error: TypeScript 类型可能较旧,忽略 startViewTransition 报错 const transition = document.startViewTransition(() => { themeStore.toggleTheme() }) diff --git a/frontend/src/components/UnifiedEventCard.vue b/frontend/src/components/UnifiedEventCard.vue new file mode 100644 index 0000000..781005c --- /dev/null +++ b/frontend/src/components/UnifiedEventCard.vue @@ -0,0 +1,479 @@ + + + + + diff --git a/frontend/src/components/icons/IconTooling.vue b/frontend/src/components/icons/IconTooling.vue index 660598d..ca73601 100644 --- a/frontend/src/components/icons/IconTooling.vue +++ b/frontend/src/components/icons/IconTooling.vue @@ -1,4 +1,4 @@ - + + diff --git a/frontend/src/layouts/DashboardLayout.vue b/frontend/src/layouts/DashboardLayout.vue index e10237d..c56e52c 100644 --- a/frontend/src/layouts/DashboardLayout.vue +++ b/frontend/src/layouts/DashboardLayout.vue @@ -20,6 +20,7 @@ const avatarUrl = computed( const navItems = [ { 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-rss', route: '/topics' }, { name: 'AI 简报设置', icon: 'fa-solid fa-paper-plane', route: '/delivery' }, diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 3d719bf..f437c6d 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -49,6 +49,11 @@ const router = createRouter({ name: 'delivery', component: () => import('@/views/DeliveryView.vue'), }, + { + path: 'search', + name: 'search', + component: () => import('@/views/SearchView.vue'), + }, ], }, ], diff --git a/frontend/src/types/event.ts b/frontend/src/types/event.ts index ffafb88..181cce5 100644 --- a/frontend/src/types/event.ts +++ b/frontend/src/types/event.ts @@ -20,7 +20,7 @@ export interface UnifiedEvent { tags: string[] } -/** 分页包装 */ +/** 分页响应 */ export interface PaginatedEvents { total: number has_more: boolean @@ -48,3 +48,17 @@ export interface SystemStats { error_tasks_today: number 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[] +} diff --git a/frontend/src/views/DeliveryView.vue b/frontend/src/views/DeliveryView.vue index 7088402..429c6ab 100644 --- a/frontend/src/views/DeliveryView.vue +++ b/frontend/src/views/DeliveryView.vue @@ -36,10 +36,12 @@ function showSuccess(msg: string) { } function getChannelLabel(_type: string): string { + void _type return '邮箱' } function getChannelIcon(_type: string): string { + void _type return 'fa-solid fa-envelope' } diff --git a/frontend/src/views/RevisionsView.vue b/frontend/src/views/RevisionsView.vue index 0263cc6..25d3736 100644 --- a/frontend/src/views/RevisionsView.vue +++ b/frontend/src/views/RevisionsView.vue @@ -4,13 +4,13 @@ import { computed, onMounted, ref } from 'vue' import { fetchHeadlineRevisions } from '@/api/events' import type { HeadlineRevision } from '@/types/event' -/** 按事件分组后的修改链条 */ +/** 按事件分组后的标题修改链 */ interface RevisionChain { event_id: number source_name: string | null - /** 标题演变链:从最早的 previous 到最终 revised,已去重 */ + /** 标题演变链:从最早 previous 到最新 revised(已去重) */ titles: string[] - /** 每次修改对应的时间(与 titles[i+1] 对应) */ + /** 每一步标题对应的修改时间(与 titles[i+1] 对应) */ change_times: string[] first_at: string last_at: string @@ -23,7 +23,7 @@ const loading = ref(true) const error = ref('') const hoursRange = ref(48) -// 平台名到图标的映射(与首页保持一致,避免同一平台在不同页面图标不一致) +// 平台名到图标的映射(与首页保持一致,避免同一平台图标不统一) const platformIconMap: Record = { 微博热搜: 'fa-brands fa-weibo', 微博: 'fa-brands fa-weibo', @@ -33,7 +33,7 @@ const platformIconMap: Record = { 今日头条: 'fa-solid fa-newspaper', 抖音热榜: 'fa-brands fa-tiktok', 抖音: 'fa-brands fa-tiktok', - B站热搜: 'fa-brands fa-bilibili', + 'B站热搜': 'fa-brands fa-bilibili', 'bilibili 热搜': 'fa-brands fa-bilibili', 华尔街见闻: 'fa-solid fa-chart-line', 澎湃新闻: 'fa-solid fa-water', @@ -46,7 +46,7 @@ function getPlatformIcon(name: string): string { return platformIconMap[name] || 'fa-solid fa-globe' } -/** 格式化时间 */ +/** 安全解析时间:兼容后端返回未携带时区标记的字符串 */ function safeParseTime(dateStr: string): number { if (!dateStr.endsWith('Z') && !dateStr.includes('+')) { dateStr += 'Z' @@ -54,6 +54,7 @@ function safeParseTime(dateStr: string): number { return new Date(dateStr).getTime() } +/** 格式化为相对时间 */ function formatTime(dateStr: string): string { const d = new Date(safeParseTime(dateStr)) const now = Date.now() @@ -67,8 +68,8 @@ function formatTime(dateStr: string): string { } /** - * 将原始修改记录按 event_id 分组,并在每组内拼接完整的标题演变链。 - * 规则:同组内按 created_at 升序排列,然后依次将 previous/revised 串成链条。 + * 将修改记录按 event_id 分组,拼接成完整标题演变链。 + * 规则:组内按 created_at 升序,依次把 previous/revised 串成链,并去掉重复节点。 */ const revisionChains = computed(() => { // 按 event_id 分组 @@ -81,17 +82,20 @@ const revisionChains = computed(() => { const chains: RevisionChain[] = [] for (const [event_id, items] of groups) { - const first = items[0] - const last = items[items.length - 1] - if (!first || !last) continue + if (items.length === 0) continue + // 组内按时间升序 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 change_times: string[] = [] + for (const item of items) { - // 若链条末尾与本条 previous 不同,说明有断层,仍然追加 + // 如果链尾与当前 previous 不一致,说明链条中间有断层,补入 previous if (titles[titles.length - 1] !== item.previous_headline) { titles.push(item.previous_headline) change_times.push(item.created_at) @@ -112,12 +116,12 @@ const revisionChains = computed(() => { }) } - // 最终按最新修改时间降序 + // 按最新修改时间降序 chains.sort((a, b) => safeParseTime(b.last_at) - safeParseTime(a.last_at)) return chains }) -/** 加载数据 */ +/** 加载修改记录 */ async function loadRevisions() { loading.value = true error.value = '' @@ -130,7 +134,7 @@ async function loadRevisions() { } } -/** 切换时间范围 */ +/** 切换时间范围并重载 */ function changeRange(hours: number) { hoursRange.value = hours loadRevisions() @@ -148,14 +152,13 @@ onMounted(loadRevisions) 公关修改追踪

- 实时监控各平台热搜标题被暗改的记录。当爬虫检测到标题变更时会自动记录修改前后的差异。 + 实时监控各平台热搜标题被悄悄修改的记录。当爬虫检测到标题变化时,系统会自动保留修改前后的差异轨迹。

-
- 查看范围: + 查看范围:
-
加载中...
-
{{ error }}
-

该时段内未检测到标题修改

-

这是个好消息!说明各平台暂无异常公关操作

+

这是个好消息,说明当前没有明显的异常公关操作。

-
@@ -218,10 +217,8 @@ onMounted(loadRevisions)
-