搜索功能加入

This commit is contained in:
stardrophere
2026-03-13 18:25:38 +08:00
parent 9440b7f590
commit 6aee65af6c
18 changed files with 1545 additions and 103 deletions
+252 -45
View File
@@ -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)
+10
View File
@@ -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
+1 -1
View File
@@ -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:
+11
View File
@@ -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",
+1
View File
@@ -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",
+8 -6
View File
@@ -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)
} }
} }
+18 -2
View File
@@ -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(),
})
}
+3 -2
View File
@@ -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;
} }
+1 -1
View File
@@ -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>
+1
View File
@@ -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' },
+5
View File
@@ -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'),
},
], ],
}, },
], ],
+15 -1
View File
@@ -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[]
}
+2
View File
@@ -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'
} }
+30 -42
View File
@@ -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);
+704
View File
@@ -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>
+1 -1
View File
@@ -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({