big update

This commit is contained in:
stardrophere
2026-03-11 20:52:58 +08:00
parent 8ed819a580
commit 966bcfbba4
44 changed files with 7124 additions and 650 deletions
+45 -2
View File
@@ -1,5 +1,12 @@
# app/api/dependencies.py
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from app.core.security import decode_access_token
from app.database import SessionLocal
from app.models.models import AppUser
bearer_scheme = HTTPBearer(auto_error=False)
def get_db():
"""
@@ -10,4 +17,40 @@ def get_db():
try:
yield db
finally:
db.close()
db.close()
def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
db: Session = Depends(get_db),
) -> AppUser:
"""
从 Bearer Token 中解析并返回当前登录用户。
要求:
1. 必须携带 Authorization: Bearer <token>
2. token 验签通过且未过期
3. 用户在数据库中存在
"""
if credentials is None or credentials.scheme.lower() != "bearer":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication credentials were not provided",
)
token = credentials.credentials
try:
user_id, email = decode_access_token(token)
except ValueError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
)
user = db.query(AppUser).filter(AppUser.id == user_id).first()
if not user or user.email != email:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token user",
)
return user
+51 -3
View File
@@ -1,5 +1,6 @@
import math
import os
from datetime import timedelta
from datetime import timedelta, timezone
from typing import Tuple
from fastapi import APIRouter, Depends, HTTPException, status
@@ -30,8 +31,18 @@ from app.utils.email_utils import send_html_email
router = APIRouter()
REGISTER_CODE_EXPIRE_MINUTES = int(os.getenv("REGISTER_CODE_EXPIRE_MINUTES", "10"))
LOGIN_CODE_EXPIRE_MINUTES = int(os.getenv("LOGIN_CODE_EXPIRE_MINUTES", "10"))
DEFAULT_REGISTER_CODE_EXPIRE_MINUTES = 10
DEFAULT_LOGIN_CODE_EXPIRE_MINUTES = 10
DEFAULT_CODE_SEND_COOLDOWN_SECONDS = 60
REGISTER_CODE_EXPIRE_MINUTES = int(
os.getenv("REGISTER_CODE_EXPIRE_MINUTES", str(DEFAULT_REGISTER_CODE_EXPIRE_MINUTES))
)
LOGIN_CODE_EXPIRE_MINUTES = int(
os.getenv("LOGIN_CODE_EXPIRE_MINUTES", str(DEFAULT_LOGIN_CODE_EXPIRE_MINUTES))
)
CODE_SEND_COOLDOWN_SECONDS = int(
os.getenv("CODE_SEND_COOLDOWN_SECONDS", str(DEFAULT_CODE_SEND_COOLDOWN_SECONDS))
)
def _normalize_email(email: str) -> str:
@@ -78,6 +89,41 @@ def _create_code_record(
return code_record, code
def _enforce_code_send_cooldown(db: Session, email: str, purpose: VerificationPurpose) -> None:
"""
防抖:限制同一邮箱同一用途验证码的发送频率,避免用户短时间连续点击。
"""
if CODE_SEND_COOLDOWN_SECONDS <= 0:
return
latest_record = (
db.query(EmailVerificationCode)
.filter(
EmailVerificationCode.email == email,
EmailVerificationCode.purpose == purpose,
)
.order_by(EmailVerificationCode.created_at.desc())
.first()
)
if not latest_record:
return
now = utcnow()
record_time = latest_record.created_at
if record_time.tzinfo is None:
record_time = record_time.replace(tzinfo=timezone.utc)
elapsed_seconds = (now - record_time).total_seconds()
if elapsed_seconds >= CODE_SEND_COOLDOWN_SECONDS:
return
retry_after_seconds = max(1, math.ceil(CODE_SEND_COOLDOWN_SECONDS - elapsed_seconds))
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Please wait {retry_after_seconds}s before requesting another verification code",
headers={"Retry-After": str(retry_after_seconds)},
)
def _build_auth_response(user: AppUser) -> AuthTokenResponse:
token, expires_in = create_access_token(user_id=user.id, email=user.email)
return AuthTokenResponse(
@@ -95,6 +141,7 @@ async def send_register_code(payload: RegisterCodeSendRequest, db: Session = Dep
if existing_user:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email is already registered")
_enforce_code_send_cooldown(db, email, VerificationPurpose.REGISTER)
_invalidate_unused_codes(db, email, VerificationPurpose.REGISTER)
code_record, code = _create_code_record(
db,
@@ -128,6 +175,7 @@ async def send_login_code(payload: LoginCodeSendRequest, db: Session = Depends(g
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Email is not registered")
_enforce_code_send_cooldown(db, email, VerificationPurpose.LOGIN)
_invalidate_unused_codes(db, email, VerificationPurpose.LOGIN)
code_record, code = _create_code_record(
db,
+353
View File
@@ -0,0 +1,353 @@
# 推送设置 API:管理用户的推送时间表和推送渠道
from datetime import time as dt_time
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.api.dependencies import get_current_user, get_db
from app.models.models import AppUser, UserDeliverySchedule, UserPushEndpoint
from app.schemas.delivery_schema import (
DeliveryScheduleCreate,
DeliveryScheduleResponse,
DeliveryScheduleUpdate,
PushEndpointCreate,
PushEndpointResponse,
PushEndpointUpdate,
UserDeliveryConfigResponse,
)
router = APIRouter()
# 两条推送时间之间的最小间隔(分钟)
MIN_SCHEDULE_GAP_MINUTES = 30
def _ensure_self_access(path_user_id: int, current_user: AppUser) -> None:
"""校验路径 user_id 是否为当前登录用户本人。"""
if path_user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You can only operate your own resources",
)
def _parse_time(time_str: str) -> dt_time:
"""将 HH:MM 字符串解析为 time 对象"""
try:
parts = time_str.split(":")
return dt_time(hour=int(parts[0]), minute=int(parts[1]))
except (ValueError, IndexError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid time format, expected HH:MM",
)
def _format_time(t: dt_time) -> str:
"""将 time 对象格式化为 HH:MM 字符串"""
return t.strftime("%H:%M")
def _time_to_minutes(t: dt_time) -> int:
return t.hour * 60 + t.minute
def _check_min_gap(
db: Session,
user_id: int,
new_time: dt_time,
exclude_id: int | None = None,
) -> None:
"""
校验新时间与用户已有的所有推送时间之间是否满足最小间隔要求(30 分钟)。
不满足时直接抛出 400 异常。
"""
query = db.query(UserDeliverySchedule).filter(
UserDeliverySchedule.user_id == user_id
)
if exclude_id is not None:
query = query.filter(UserDeliverySchedule.id != exclude_id)
existing = query.all()
new_minutes = _time_to_minutes(new_time)
for s in existing:
old_minutes = _time_to_minutes(s.delivery_time)
diff = abs(new_minutes - old_minutes)
circular_diff = min(diff, 1440 - diff)
if circular_diff < MIN_SCHEDULE_GAP_MINUTES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"推送时间间隔不能少于 {MIN_SCHEDULE_GAP_MINUTES} 分钟,"
f"与已有的 {_format_time(s.delivery_time)} 冲突",
)
# ==========================================
# 聚合查询:一次性返回用户全部推送配置
# ==========================================
@router.get(
"/users/{user_id}/delivery-config",
response_model=UserDeliveryConfigResponse,
)
def get_delivery_config(
user_id: int,
db: Session = Depends(get_db),
current_user: AppUser = Depends(get_current_user),
):
"""获取用户的完整推送配置(时间表 + 渠道)。"""
_ensure_self_access(user_id, current_user)
schedules = (
db.query(UserDeliverySchedule)
.filter(UserDeliverySchedule.user_id == user_id)
.order_by(UserDeliverySchedule.delivery_time.asc())
.all()
)
endpoints = (
db.query(UserPushEndpoint)
.filter(UserPushEndpoint.user_id == user_id)
.order_by(UserPushEndpoint.priority_level.asc())
.all()
)
# 手动转换 time 字段为字符串
schedule_list = [
DeliveryScheduleResponse(
id=s.id,
user_id=s.user_id,
delivery_time=_format_time(s.delivery_time),
is_active=s.is_active,
created_at=s.created_at,
)
for s in schedules
]
return UserDeliveryConfigResponse(schedules=schedule_list, endpoints=endpoints)
# ==========================================
# 推送时间表 CRUD
# ==========================================
@router.post(
"/users/{user_id}/delivery-schedules",
response_model=DeliveryScheduleResponse,
status_code=status.HTTP_201_CREATED,
)
def create_delivery_schedule(
user_id: int,
payload: DeliveryScheduleCreate,
db: Session = Depends(get_db),
current_user: AppUser = Depends(get_current_user),
):
"""新增一条推送时间。"""
_ensure_self_access(user_id, current_user)
parsed_time = _parse_time(payload.delivery_time)
_check_min_gap(db, user_id, parsed_time)
db_obj = UserDeliverySchedule(
user_id=user_id,
delivery_time=parsed_time,
is_active=payload.is_active,
)
db.add(db_obj)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="This delivery time already exists",
)
db.refresh(db_obj)
return DeliveryScheduleResponse(
id=db_obj.id,
user_id=db_obj.user_id,
delivery_time=_format_time(db_obj.delivery_time),
is_active=db_obj.is_active,
created_at=db_obj.created_at,
)
@router.patch(
"/users/{user_id}/delivery-schedules/{schedule_id}",
response_model=DeliveryScheduleResponse,
)
def update_delivery_schedule(
user_id: int,
schedule_id: int,
payload: DeliveryScheduleUpdate,
db: Session = Depends(get_db),
current_user: AppUser = Depends(get_current_user),
):
"""更新一条推送时间。"""
_ensure_self_access(user_id, current_user)
db_obj = (
db.query(UserDeliverySchedule)
.filter(
UserDeliverySchedule.id == schedule_id,
UserDeliverySchedule.user_id == user_id,
)
.first()
)
if not db_obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Schedule not found")
if payload.delivery_time is not None:
new_time = _parse_time(payload.delivery_time)
_check_min_gap(db, user_id, new_time, exclude_id=schedule_id)
db_obj.delivery_time = new_time
if payload.is_active is not None:
db_obj.is_active = payload.is_active
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="This delivery time already exists",
)
db.refresh(db_obj)
return DeliveryScheduleResponse(
id=db_obj.id,
user_id=db_obj.user_id,
delivery_time=_format_time(db_obj.delivery_time),
is_active=db_obj.is_active,
created_at=db_obj.created_at,
)
@router.delete(
"/users/{user_id}/delivery-schedules/{schedule_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
def delete_delivery_schedule(
user_id: int,
schedule_id: int,
db: Session = Depends(get_db),
current_user: AppUser = Depends(get_current_user),
):
"""删除一条推送时间。"""
_ensure_self_access(user_id, current_user)
db_obj = (
db.query(UserDeliverySchedule)
.filter(
UserDeliverySchedule.id == schedule_id,
UserDeliverySchedule.user_id == user_id,
)
.first()
)
if not db_obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Schedule not found")
db.delete(db_obj)
db.commit()
return None
# ==========================================
# 推送渠道 CRUD
# ==========================================
@router.post(
"/users/{user_id}/push-endpoints",
response_model=PushEndpointResponse,
status_code=status.HTTP_201_CREATED,
)
def create_push_endpoint(
user_id: int,
payload: PushEndpointCreate,
db: Session = Depends(get_db),
current_user: AppUser = Depends(get_current_user),
):
"""新增一个推送渠道。"""
_ensure_self_access(user_id, current_user)
db_obj = UserPushEndpoint(
user_id=user_id,
channel_type=payload.channel_type.upper().strip(),
channel_account=payload.channel_account.strip(),
is_active=payload.is_active,
priority_level=payload.priority_level,
)
db.add(db_obj)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="This channel type already exists for the user",
)
db.refresh(db_obj)
return db_obj
@router.patch(
"/users/{user_id}/push-endpoints/{endpoint_id}",
response_model=PushEndpointResponse,
)
def update_push_endpoint(
user_id: int,
endpoint_id: int,
payload: PushEndpointUpdate,
db: Session = Depends(get_db),
current_user: AppUser = Depends(get_current_user),
):
"""更新一个推送渠道配置。"""
_ensure_self_access(user_id, current_user)
db_obj = (
db.query(UserPushEndpoint)
.filter(
UserPushEndpoint.id == endpoint_id,
UserPushEndpoint.user_id == user_id,
)
.first()
)
if not db_obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Push endpoint not found")
if payload.channel_account is not None:
db_obj.channel_account = payload.channel_account.strip()
if payload.is_active is not None:
db_obj.is_active = payload.is_active
if payload.priority_level is not None:
db_obj.priority_level = payload.priority_level
db.commit()
db.refresh(db_obj)
return db_obj
@router.delete(
"/users/{user_id}/push-endpoints/{endpoint_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
def delete_push_endpoint(
user_id: int,
endpoint_id: int,
db: Session = Depends(get_db),
current_user: AppUser = Depends(get_current_user),
):
"""删除一个推送渠道。"""
_ensure_self_access(user_id, current_user)
db_obj = (
db.query(UserPushEndpoint)
.filter(
UserPushEndpoint.id == endpoint_id,
UserPushEndpoint.user_id == user_id,
)
.first()
)
if not db_obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Push endpoint not found")
db.delete(db_obj)
db.commit()
return None
+192 -46
View File
@@ -1,69 +1,215 @@
# app/api/endpoints/events.py
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from datetime import timedelta
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.api.dependencies import get_db
from app.models.models import UnifiedEvent, TrendingEvent, InfoSource, RankingLog, utcnow
# 导入你上传的 Schema
from app.schemas.event_schema import UnifiedEventResponse, PlatformTrendResponse
from app.models.models import (
ExtractedTopic,
InfoSource,
RankingLog,
TargetType,
TrendingEvent,
UnifiedEvent,
utcnow,
)
from app.schemas.event_schema import (
PaginatedUnifiedEventResponse,
PlatformTrendResponse,
UnifiedEventResponse,
)
router = APIRouter()
# 排名轨迹最多返回多少个点,避免长时间跨度下数据过大
MAX_RANKING_POINTS = 30
@router.get("/unified", response_model=List[UnifiedEventResponse])
@router.get("/unified", response_model=PaginatedUnifiedEventResponse)
def list_unified_events(
min_hot: int = Query(5, description="热度过滤阈值"),
hours: int = Query(24, description="查询过去 X 小时的数据"),
db: Session = Depends(get_db)
min_hot: int = Query(5, ge=0, description="热度阈值,仅返回 hot_score >= 此值的事件"),
hours: int = Query(24, ge=1, le=720, description="查询最近多少小时的数据"),
skip: int = Query(0, ge=0, description="分页偏移量"),
limit: int = Query(10, ge=1, le=50, description="每页返回条数"),
db: Session = Depends(get_db),
):
"""
获取聚合大事件列表,完全适配前端 template.html 所需的数据结构
"""
# 计算时间水位线
"""分页返回统一事件,附带各平台热搜、排名轨迹和标签。"""
time_limit = utcnow() - timedelta(hours=hours)
# 1. 查询大事件(按热度降序,且满足时间范围)
events = db.query(UnifiedEvent).filter(
# 先查总数,用于前端判断是否还有更多
base_query = db.query(UnifiedEvent).filter(
UnifiedEvent.hot_score >= min_hot,
UnifiedEvent.created_at >= time_limit
).order_by(UnifiedEvent.hot_score.desc()).all()
UnifiedEvent.created_at >= time_limit,
)
total = base_query.count()
results = []
# 分页查询
events = (
base_query
.order_by(UnifiedEvent.hot_score.desc())
.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)
.filter(TrendingEvent.unified_event_id.in_(event_ids))
.all()
)
# 按 unified_event_id 分组
trend_map: dict[int, list[tuple]] = {}
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,
)
.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)
# 批量查询标签
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_(event_ids),
)
.order_by(ExtractedTopic.relevance_score.desc(), ExtractedTopic.created_at.desc())
.all()
)
for target_id, keyword in tag_rows:
tag_map.setdefault(target_id, []).append(keyword)
# 组装响应
results: list[UnifiedEventResponse] = []
for ev in events:
# 2. 联表查询:获取该大事件下关联的所有平台及其具体热搜信息
trends = db.query(TrendingEvent, InfoSource.source_name).join(
InfoSource, TrendingEvent.source_id == InfoSource.id
).filter(TrendingEvent.unified_event_id == ev.id).all()
platform_list: list[PlatformTrendResponse] = []
for trend, source_name in trend_map.get(ev.id, []):
history = ranking_map.get(trend.id, [])
# 截取尾部,只保留最近的点
if len(history) > MAX_RANKING_POINTS:
history = history[-MAX_RANKING_POINTS:]
platform_list = []
for trend, s_name in trends:
# 3. 获取排名历史轨迹 (用于前端渲染)
# 这里的排序顺序 asc 保证了数组从旧到新
logs = db.query(RankingLog.ranking_position).filter(
RankingLog.event_id == trend.id,
RankingLog.observed_at >= time_limit
).order_by(RankingLog.observed_at.asc()).all()
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,
)
)
# 组装符合 PlatformTrendResponse 结构的字典
platform_list.append(PlatformTrendResponse(
results.append(
UnifiedEventResponse(
event_id=ev.id,
unified_title=ev.unified_title if ev.unified_title else "暂无标题",
summary=ev.ai_comprehensive_summary,
hot_score=ev.hot_score,
created_at=ev.created_at,
platforms=platform_list,
tags=tag_map.get(ev.id, []),
)
)
has_more = (skip + limit) < total
return PaginatedUnifiedEventResponse(total=total, has_more=has_more, data=results)
@router.get("/unified/{event_id}", response_model=UnifiedEventResponse)
def get_unified_event(
event_id: int,
db: Session = Depends(get_db),
):
"""按 ID 查询单个统一事件,用于推荐跳转时的聚光灯展示。"""
ev = db.query(UnifiedEvent).filter(UnifiedEvent.id == event_id).first()
if not ev:
raise HTTPException(status_code=404, detail="Event not found")
time_limit = utcnow() - timedelta(hours=720)
trend_rows = (
db.query(TrendingEvent, InfoSource.source_name)
.join(InfoSource, TrendingEvent.source_id == InfoSource.id)
.filter(TrendingEvent.unified_event_id == event_id)
.all()
)
trend_ids = [t.id for t, _ in trend_rows]
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 eid, pos in ranking_rows:
ranking_map.setdefault(eid, []).append(pos)
tag_rows = (
db.query(ExtractedTopic.topic_keyword)
.filter(
ExtractedTopic.target_type == TargetType.EVENT,
ExtractedTopic.target_id == event_id,
)
.order_by(ExtractedTopic.relevance_score.desc())
.all()
)
tags = [row[0] for row in tag_rows]
platform_list: list[PlatformTrendResponse] = []
for trend, source_name in trend_rows:
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=s_name,
platform_name=source_name,
headline=trend.current_headline,
url=trend.event_url,
current_ranking=trend.current_ranking,
ranking_history=[log[0] for log in logs]
))
ranking_history=history,
)
)
# 4. 组装符合 UnifiedEventResponse 结构的字典
results.append(UnifiedEventResponse(
event_id=ev.id,
unified_title=ev.unified_title if ev.unified_title else "暂无标题",
summary=ev.ai_comprehensive_summary,
hot_score=ev.hot_score,
created_at=ev.created_at,
platforms=platform_list
))
return results
return UnifiedEventResponse(
event_id=ev.id,
unified_title=ev.unified_title if ev.unified_title else "暂无标题",
summary=ev.ai_comprehensive_summary,
hot_score=ev.hot_score,
created_at=ev.created_at,
platforms=platform_list,
tags=tags,
)
+158
View File
@@ -0,0 +1,158 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.api.dependencies import get_current_user, get_db
from app.models.models import AppUser, UserTopicPreference
from app.schemas.preference_schema import (
MatchedEventResponse,
UserPreferenceRecommendationResponse,
UserTopicPreferenceCreate,
UserTopicPreferenceResponse,
)
from app.services.matching_service import recommend_events_for_user
router = APIRouter()
def _ensure_self_access(path_user_id: int, current_user: AppUser) -> None:
"""校验路径 user_id 是否为当前登录用户本人。"""
if path_user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You can only operate your own resources",
)
@router.get(
"/users/{user_id}/preferences",
response_model=List[UserTopicPreferenceResponse],
)
def list_user_preferences(
user_id: int,
db: Session = Depends(get_db),
current_user: AppUser = Depends(get_current_user),
):
"""获取用户已设置的兴趣关键词。"""
_ensure_self_access(user_id, current_user)
preferences = (
db.query(UserTopicPreference)
.filter(UserTopicPreference.user_id == user_id)
.order_by(UserTopicPreference.created_at.desc())
.all()
)
return preferences
@router.post(
"/users/{user_id}/preferences",
response_model=UserTopicPreferenceResponse,
status_code=status.HTTP_201_CREATED,
)
def create_user_preference(
user_id: int,
payload: UserTopicPreferenceCreate,
db: Session = Depends(get_db),
current_user: AppUser = Depends(get_current_user),
):
"""新增一个用户兴趣关键词。"""
_ensure_self_access(user_id, current_user)
keyword = payload.interested_keyword.strip()
if not keyword:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Keyword cannot be empty")
db_obj = UserTopicPreference(
user_id=user_id,
interested_keyword=keyword,
)
db.add(db_obj)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Preference keyword already exists for this user",
)
db.refresh(db_obj)
return db_obj
@router.delete(
"/users/{user_id}/preferences/{preference_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
def delete_user_preference(
user_id: int,
preference_id: int,
db: Session = Depends(get_db),
current_user: AppUser = Depends(get_current_user),
):
"""删除一个用户兴趣关键词。"""
_ensure_self_access(user_id, current_user)
preference = (
db.query(UserTopicPreference)
.filter(
UserTopicPreference.id == preference_id,
UserTopicPreference.user_id == user_id,
)
.first()
)
if not preference:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Preference not found")
db.delete(preference)
db.commit()
return None
@router.get(
"/users/{user_id}/recommended-events",
response_model=UserPreferenceRecommendationResponse,
)
def recommend_events(
user_id: int,
min_hot: int = Query(3, ge=1, description="最小热度阈值"),
hours: int = Query(72, ge=1, le=24 * 30, description="仅匹配最近多少小时的事件"),
limit: int = Query(20, ge=1, le=50, description="最多返回多少条推荐"),
semantic_threshold: float = Query(0.78, ge=0.0, le=1.0, description="语义匹配相似度阈值"),
db: Session = Depends(get_db),
current_user: AppUser = Depends(get_current_user),
):
"""基于用户兴趣词推荐事件(精确匹配 + 语义匹配)。"""
_ensure_self_access(user_id, current_user)
matched = recommend_events_for_user(
db,
user_id=user_id,
min_hot=min_hot,
hours=hours,
limit=limit,
semantic_threshold=semantic_threshold,
)
result_data: list[MatchedEventResponse] = []
for item in matched:
result_data.append(
MatchedEventResponse(
event_id=item.event.id,
unified_title=item.event.unified_title,
summary=item.event.ai_comprehensive_summary,
hot_score=item.event.hot_score,
created_at=item.event.created_at,
tags=item.tags,
match_score=item.match_score,
exact_hits=item.exact_hits,
semantic_hits=item.semantic_hits,
)
)
return UserPreferenceRecommendationResponse(
user_id=user_id,
total=len(result_data),
data=result_data,
)
+75
View File
@@ -0,0 +1,75 @@
# 公关修改追踪 API:查询热搜标题被偷偷修改的历史记录
from datetime import timedelta
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.dependencies import get_db
from app.models.models import HeadlineRevision, InfoSource, TrendingEvent, utcnow
from pydantic import BaseModel, ConfigDict
from datetime import datetime
router = APIRouter()
class HeadlineRevisionResponse(BaseModel):
"""标题修改记录响应体"""
id: int
event_id: int
previous_headline: str
revised_headline: str
source_name: Optional[str] = None
platform_icon: Optional[str] = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
@router.get("/headline-revisions", response_model=List[HeadlineRevisionResponse])
def list_headline_revisions(
hours: int = Query(48, ge=1, le=720, description="查询最近多少小时内的修改记录"),
limit: int = Query(50, ge=1, le=500, description="最多返回条数"),
db: Session = Depends(get_db),
):
"""
获取最近的标题修改记录列表。
用于公关监测:发现哪些平台偷偷改了热搜标题。
"""
time_limit = utcnow() - timedelta(hours=hours)
rows = (
db.query(HeadlineRevision, InfoSource.source_name)
.join(TrendingEvent, HeadlineRevision.event_id == TrendingEvent.id)
.join(InfoSource, TrendingEvent.source_id == InfoSource.id)
.filter(HeadlineRevision.created_at >= time_limit)
.order_by(HeadlineRevision.created_at.desc())
.limit(limit)
.all()
)
# 平台名到图标的简单映射
icon_map = {
"微博热搜": "weibo",
"知乎热榜": "zhihu",
"百度热搜": "baidu",
"今日头条": "toutiao",
"抖音热榜": "douyin",
"B站热搜": "bilibili",
}
results: list[HeadlineRevisionResponse] = []
for revision, source_name in rows:
results.append(
HeadlineRevisionResponse(
id=revision.id,
event_id=revision.event_id,
previous_headline=revision.previous_headline,
revised_headline=revision.revised_headline,
source_name=source_name,
platform_icon=icon_map.get(source_name, "newspaper"),
created_at=revision.created_at,
)
)
return results
+65
View File
@@ -0,0 +1,65 @@
# 系统状态监控 API:返回爬虫集群运行概况
from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.api.dependencies import get_db
from app.models.models import DataSyncTask, InfoSource, TaskStatus, utcnow
router = APIRouter()
class SystemStatsResponse(BaseModel):
"""系统运行状态汇总"""
active_sources: int
total_sources: int
items_today: int
success_tasks_today: int
error_tasks_today: int
last_sync_at: Optional[datetime] = None
@router.get("/system/stats", response_model=SystemStatsResponse)
def get_system_stats(db: Session = Depends(get_db)):
"""获取爬虫集群的当日运行状态。"""
today_start = utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
# 信息源统计
total_sources = db.query(func.count(InfoSource.id)).scalar() or 0
active_sources = (
db.query(func.count(InfoSource.id))
.filter(InfoSource.is_enabled.is_(True))
.scalar() or 0
)
# 今日任务统计
today_tasks = (
db.query(DataSyncTask)
.filter(DataSyncTask.created_at >= today_start)
.all()
)
items_today = sum(t.items_fetched for t in today_tasks)
success_count = sum(1 for t in today_tasks if t.task_status == TaskStatus.SUCCESS)
error_count = sum(1 for t in today_tasks if t.task_status == TaskStatus.ERROR)
# 最后一次同步时间
last_task = (
db.query(DataSyncTask)
.filter(DataSyncTask.task_status == TaskStatus.SUCCESS)
.order_by(DataSyncTask.created_at.desc())
.first()
)
return SystemStatsResponse(
active_sources=active_sources,
total_sources=total_sources,
items_today=items_today,
success_tasks_today=success_count,
error_tasks_today=error_count,
last_sync_at=last_task.created_at if last_task else None,
)
+15 -1
View File
@@ -1,6 +1,6 @@
# app/api/router.py
from fastapi import APIRouter
from app.api.endpoints import auth, sources, events
from app.api.endpoints import auth, delivery, events, preferences, revisions, sources, stats
api_router = APIRouter()
@@ -9,4 +9,18 @@ api_router.include_router(sources.router, prefix="/sources", tags=["信息源管
# 注册大事件相关的路由
api_router.include_router(events.router, prefix="/events", tags=["Unified Events"])
# 认证
api_router.include_router(auth.router, prefix="/auth", tags=["Auth"])
# 用户偏好(关键词订阅)
api_router.include_router(preferences.router, tags=["User Preferences"])
# 推送设置(时间表 + 渠道)
api_router.include_router(delivery.router, tags=["Delivery Settings"])
# 公关修改追踪
api_router.include_router(revisions.router, prefix="/events", tags=["Headline Revisions"])
# 系统状态监控
api_router.include_router(stats.router, tags=["System Stats"])
+61 -2
View File
@@ -8,9 +8,15 @@ import time
from typing import Tuple
PASSWORD_HASH_ITERATIONS = int(os.getenv("PASSWORD_HASH_ITERATIONS", "120000"))
DEFAULT_PASSWORD_HASH_ITERATIONS = 120000
PASSWORD_HASH_ITERATIONS = int(
os.getenv("PASSWORD_HASH_ITERATIONS", str(DEFAULT_PASSWORD_HASH_ITERATIONS))
)
AUTH_SECRET_KEY = os.getenv("AUTH_SECRET_KEY", "change-this-secret-in-env")
AUTH_TOKEN_EXPIRE_MINUTES = int(os.getenv("AUTH_TOKEN_EXPIRE_MINUTES", "10080"))
DEFAULT_AUTH_TOKEN_EXPIRE_MINUTES = 10080
AUTH_TOKEN_EXPIRE_MINUTES = int(
os.getenv("AUTH_TOKEN_EXPIRE_MINUTES", str(DEFAULT_AUTH_TOKEN_EXPIRE_MINUTES))
)
def hash_password(password: str) -> str:
@@ -61,6 +67,11 @@ def _urlsafe_b64encode(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("utf-8").rstrip("=")
def _urlsafe_b64decode(raw: str) -> bytes:
padding = "=" * (-len(raw) % 4)
return base64.urlsafe_b64decode(raw + padding)
def create_access_token(user_id: int, email: str) -> Tuple[str, int]:
expires_in = AUTH_TOKEN_EXPIRE_MINUTES * 60
payload = {
@@ -77,3 +88,51 @@ def create_access_token(user_id: int, email: str) -> Tuple[str, int]:
).digest()
token = f"{encoded_payload}.{_urlsafe_b64encode(signature)}"
return token, expires_in
def decode_access_token(token: str) -> Tuple[int, str]:
"""
解码并校验访问令牌,返回 (user_id, email)。
校验项包括:结构、签名、过期时间、字段完整性。
"""
try:
encoded_payload, encoded_signature = token.split(".", 1)
except ValueError as exc:
raise ValueError("Invalid token format") from exc
try:
provided_signature = _urlsafe_b64decode(encoded_signature)
except Exception as exc:
raise ValueError("Invalid token signature encoding") from exc
expected_signature = hmac.new(
AUTH_SECRET_KEY.encode("utf-8"),
encoded_payload.encode("utf-8"),
hashlib.sha256,
).digest()
if not hmac.compare_digest(provided_signature, expected_signature):
raise ValueError("Invalid token signature")
try:
payload_bytes = _urlsafe_b64decode(encoded_payload)
payload = json.loads(payload_bytes.decode("utf-8"))
except Exception as exc:
raise ValueError("Invalid token payload") from exc
sub = payload.get("sub")
email = payload.get("email")
exp = payload.get("exp")
if not sub or not email or exp is None:
raise ValueError("Token payload missing required fields")
try:
user_id = int(sub)
exp_ts = int(exp)
except (TypeError, ValueError) as exc:
raise ValueError("Invalid token payload types") from exc
if time.time() >= exp_ts:
raise ValueError("Token expired")
return user_id, str(email)
+46
View File
@@ -0,0 +1,46 @@
import requests
import json
# 请将此处的 URL 替换为您实际的 API 基础域名
api_url = "http://10.252.130.135:8000/api/v1/sources/"
# 请求头
headers = {
"Content-Type": "application/json",
# "Authorization": "Bearer YOUR_TOKEN" # 如果接口需要鉴权,请取消注释并填入 Token
}
# 解析后的数据源列表
sources_data = [
{"name": "今日头条", "url": "toutiao"},
{"name": "百度热搜", "url": "baidu"},
{"name": "华尔街见闻", "url": "wallstreetcn-hot"},
{"name": "澎湃新闻", "url": "thepaper"},
{"name": "bilibili 热搜", "url": "bilibili-hot-search"},
{"name": "财联社热门", "url": "cls-hot"},
{"name": "凤凰网", "url": "ifeng"},
{"name": "贴吧", "url": "tieba"},
{"name": "微博", "url": "weibo"},
{"name": "抖音", "url": "douyin"},
{"name": "知乎", "url": "zhihu"}
]
# 遍历数据并发送 POST 请求
for item in sources_data:
payload = {
"source_name": item["name"],
"source_type": "HOT_TREND",
"home_url": item["url"],
"is_enabled": True
}
try:
response = requests.post(api_url, headers=headers, data=json.dumps(payload))
if response.status_code in (200, 201):
print(f"✅ 成功创建: {item['name']}")
else:
print(f"❌ 创建失败: {item['name']} - 状态码: {response.status_code} - 详情: {response.text}")
except Exception as e:
print(f"⚠️ 请求异常: {item['name']} - 错误: {e}")
print("执行完毕!")
+37 -3
View File
@@ -1,12 +1,24 @@
# app/main.py
import logging
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from dotenv import load_dotenv
# 统一配置日志格式和级别,确保 delivery_service 等的 INFO 日志可见
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
# 降低 APScheduler 运行心跳日志,避免每分钟刷屏
logging.getLogger("apscheduler").setLevel(logging.WARNING)
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from app.services.fetcher_service import fetch_and_save_trending_data
from app.services.summary_service import generate_unified_summaries
from app.services.delivery_service import check_and_deliver
from app.database import engine
from app.models.models import Base
@@ -47,14 +59,24 @@ async def lifespan(app: FastAPI):
id='ai_summary_job',
replace_existing=True
)
# 推送调度:每分钟检查是否有用户需要接收邮件推送
scheduler.add_job(
check_and_deliver,
'interval',
minutes=1,
id='delivery_check_job',
replace_existing=True,
)
scheduler.start()
print(f"定时抓取任务已启动,每 {CRAWL_INTERVAL} 分钟执行一次")
print(f"AI 摘要生成任务已启动,每 {SUMMARY_INTERVAL} 分钟执行一次")
print("邮件推送调度已启动,每分钟检查一次")
# 为了测试方便,启动时立即执行一次
await fetch_and_save_trending_data()
# await fetch_and_save_trending_data()
await generate_unified_summaries()
# await generate_unified_summaries()
yield # 此时 FastAPI 开始接受请求
@@ -67,7 +89,19 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="AI 新闻聚合引擎 API", lifespan=lifespan)
# ==========================================
# 2. 挂载路由总线
# 2. CORS 中间件:允许前端开发服务器跨域请求
# ==========================================
app.add_middleware(
CORSMiddleware,
# allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
allow_origins=["*"],
# allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ==========================================
# 3. 挂载路由总线
# ==========================================
# 版本控制
app.include_router(api_router, prefix="/api/v1")
@@ -0,0 +1,264 @@
# 推送邮件 HTML 模板
# 用于生成定时推送给用户的热点摘要邮件
# 邮件客户端不支持 Font Awesome,改用 Emoji 代替平台图标
PLATFORM_EMOJI: dict[str, str] = {
"微博热搜": "🔴",
"微博": "🔴",
"知乎热榜": "🔵",
"知乎": "🔵",
"百度热搜": "🔍",
"今日头条": "📰",
"抖音热榜": "🎵",
"抖音": "🎵",
"bilibili 热搜": "📺",
"B站热搜": "📺",
"华尔街见闻": "📈",
"澎湃新闻": "🌊",
"财联社热门": "💰",
"凤凰网": "🦅",
"贴吧": "💬",
}
DIGEST_HTML_TEMPLATE = """\
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body{{margin:0;padding:0;background:#0d1117;color:#e6edf3;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;}}
.container{{max-width:640px;margin:0 auto;padding:32px 16px;}}
.header{{text-align:center;padding:10px 0 30px;margin-bottom:24px;border-bottom:1px solid rgba(255,255,255,0.06);}}
.header h1{{font-size:26px;font-weight:800;margin:0 0 10px;color:#ffffff;text-shadow:0 0 16px rgba(139,92,246,0.5);letter-spacing:0.5px;}}
.header p{{font-size:14px;color:#8b949e;margin:0;}}
.mode-badge{{display:inline-block;margin-top:12px;padding:4px 14px;border-radius:20px;font-size:12px;font-weight:600;letter-spacing:0.5px;}}
.mode-default{{background:rgba(59,130,246,0.15);color:#7dd3fc;border:1px solid rgba(59,130,246,0.3);}}
.mode-keyword{{background:rgba(168,85,247,0.15);color:#e879f9;border:1px solid rgba(168,85,247,0.3);}}
.event-card{{background:#161b22;border:1px solid #30363d;border-radius:16px;padding:20px;margin-bottom:20px;box-shadow:0 4px 12px rgba(0,0,0,0.2);}}
.event-card.is-hot{{border-left:4px solid #f85149;background:linear-gradient(90deg, rgba(248,81,73,0.03) 0%, transparent 100%), #161b22;}}
.event-title{{font-size:18px;font-weight:700;margin:0 0 14px;color:#ffffff;line-height:1.5;}}
.event-meta{{margin-bottom:12px;}}
.badge{{display:inline-block;padding:3px 10px;border-radius:6px;font-size:12px;font-weight:600;margin-right:6px;margin-bottom:6px;}}
.badge-hot{{background:rgba(248,81,73,0.15);color:#ff7b72;border:1px solid rgba(248,81,73,0.3);}}
.badge-warm{{background:rgba(210,153,34,0.15);color:#d29922;border:1px solid rgba(210,153,34,0.3);}}
.badge-normal{{background:rgba(56,139,253,0.15);color:#58a6ff;border:1px solid rgba(56,139,253,0.3);}}
.badge-tag{{background:rgba(139,148,158,0.15);color:#8b949e;border:1px solid rgba(139,148,158,0.2);}}
.summary{{font-size:14px;line-height:1.6;color:#c9d1d9;padding:12px 16px;background:rgba(139,92,246,0.06);border-radius:0 8px 8px 0;border-left:3px solid #a78bfa;margin-bottom:16px;}}
.summary strong{{color:#a78bfa;font-weight:600;}}
.platforms-list{{margin:0;padding:0;list-style:none;background:rgba(255,255,255,0.02);border-radius:10px;padding:12px;}}
.platform-item{{padding:8px 0;border-bottom:1px solid rgba(255,255,255,0.05);}}
.platform-item:last-child{{border-bottom:none;padding-bottom:0;}}
.platform-item:first-child{{padding-top:0;}}
.platform-source{{font-size:12px;color:#8b949e;margin-bottom:4px;display:flex;align-items:center;}}
.platform-rank{{display:inline-block;padding:2px 6px;border-radius:4px;background:rgba(210,153,34,0.15);color:#d29922;font-size:10px;font-weight:700;margin-left:6px;}}
.platform-link{{font-size:14px;color:#79c0ff;text-decoration:none;line-height:1.5;display:block;transition:color 0.2s;}}
.platform-link:hover{{text-decoration:underline;color:#a5d6ff;}}
.platform-text{{font-size:14px;color:#e6edf3;line-height:1.5;}}
/* 匹配信息底部栏 */
.match-info{{font-size:12px;color:#8b949e;margin-top:16px;padding-top:12px;border-top:1px dashed #30363d;}}
.hit{{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;margin-right:4px;margin-top:4px;}}
.hit-exact{{background:rgba(46,160,67,0.15);color:#3fb950;}}
.hit-semantic{{background:rgba(163,113,247,0.15);color:#d2a8ff;}}
/* 页脚 */
.footer{{text-align:center;padding:30px 0 10px;margin-top:20px;font-size:12px;color:#484f58;}}
.footer a{{color:#79c0ff;text-decoration:none;}}
.footer a:hover{{text-decoration:underline;}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>InsightRadar · 热点快报</h1>
<p>{delivery_time} · 为你精选了 {event_count} 条事件</p>
<span class="mode-badge {mode_badge_class}">{mode_label}</span>
</div>
{event_cards_html}
<div class="footer">
<p>此邮件由 InsightRadar 自动推送。</p>
<p>如需调整推送设置,请登录 <a href="{app_url}">InsightRadar 控制台</a></p>
</div>
</div>
</body>
</html>
"""
EVENT_CARD_TEMPLATE = """\
<div class="event-card{hot_class}">
<div class="event-meta">
<span class="badge {badge_class}">{hot_label} {hot_score}</span>
{tags_html}
</div>
<div class="event-title">{title}</div>
{summary_html}
{platforms_html}
{match_html}
</div>
"""
def _hot_level(score: int) -> tuple[str, str, str]:
"""返回 (label, badge_class, hot_class)"""
if score >= 50:
return "全网沸腾", "badge-hot", " is-hot"
if score >= 20:
return "高度关注", "badge-warm", ""
if score >= 10:
return "上升中", "badge-normal", ""
return "一般关注", "badge-tag", ""
def _get_event_summary(ev) -> str:
"""
兼容 ORM 字段名(ai_comprehensive_summary)和 schema 字段名(summary)。
"""
return (
getattr(ev, "summary", None)
or getattr(ev, "ai_comprehensive_summary", None)
or ""
)
def _build_platforms_html(platform_list: list[dict]) -> str:
"""
将平台数据列表渲染为 HTML。
每条包含:emoji 图标 + 来源名 + 排名徽章 + 可点击标题链接。
"""
if not platform_list:
return ""
rows = []
seen_sources: set[str] = set()
for p in platform_list[:8]:
source_name = p.get("source_name", "未知")
# 同一来源只显示第一条(通常是排名最靠前的那条)
if source_name in seen_sources:
continue
seen_sources.add(source_name)
headline = p.get("headline", "")
url = p.get("url", "")
ranking = p.get("ranking")
emoji = PLATFORM_EMOJI.get(source_name, "🔗")
rank_html = ""
if ranking:
rank_html = f'<span class="platform-rank">TOP {ranking}</span>'
if url:
title_html = (
f'<a href="{url}" class="platform-link">{headline}</a>'
)
else:
title_html = f'<span class="platform-text">{headline}</span>'
rows.append(
f'<li class="platform-item">'
f'<div class="platform-source">{emoji} {source_name}{rank_html}</div>'
f'{title_html}'
f'</li>'
)
if not rows:
return ""
return '<div class="platforms-list-wrapper"><ul class="platforms-list">' + "".join(rows) + "</ul></div>"
def build_digest_html(
items: list,
delivery_time_str: str,
platforms_map: dict[int, list[dict]] | None = None,
app_url: str = "http://localhost:5173",
is_default_push: bool = False,
) -> str:
"""
根据事件列表生成推送邮件 HTML 正文。
items 元素可以是 MatchedEventResult 或 _DefaultEventItem
二者均有 .event / .tags / .exact_hits / .semantic_hits / .match_score 属性。
platforms_map: event_id → [{source_name, headline, url, ranking}]
"""
if platforms_map is None:
platforms_map = {}
mode_label = "全网热点推送" if is_default_push else "个性化关键词匹配"
mode_badge_class = "mode-default" if is_default_push else "mode-keyword"
cards = []
for item in items:
ev = item.event
hot_label, badge_class, hot_class = _hot_level(ev.hot_score)
tags_html = "".join(
f'<span class="badge badge-tag">{t}</span>'
for t in item.tags[:4]
)
summary_text = _get_event_summary(ev)
summary_html = ""
if summary_text:
summary_html = (
f'<div class="summary"><strong>AI 洞察:</strong>{summary_text}</div>'
)
platform_list = platforms_map.get(ev.id, [])
platforms_html = _build_platforms_html(platform_list)
match_parts = []
# 仅个性化模式才显示匹配信息
if not getattr(item, "is_default", False):
for h in item.exact_hits[:3]:
match_parts.append(f'<span class="hit hit-exact">精确 {h}</span>')
for s in item.semantic_hits[:2]:
sim_pct = int(s.get("similarity", 0) * 100)
match_parts.append(
f'<span class="hit hit-semantic">语义 {s.get("topic_keyword", "")} {sim_pct}%</span>'
)
match_html = ""
if match_parts:
match_html = (
f'<div class="match-info">匹配度 {item.match_score:.0f} · '
+ " ".join(match_parts)
+ "</div>"
)
cards.append(
EVENT_CARD_TEMPLATE.format(
hot_class=hot_class,
badge_class=badge_class,
hot_label=hot_label,
hot_score=ev.hot_score,
tags_html=tags_html,
title=ev.unified_title,
summary_html=summary_html,
platforms_html=platforms_html,
match_html=match_html,
)
)
return DIGEST_HTML_TEMPLATE.format(
delivery_time=delivery_time_str,
event_count=len(items),
event_cards_html="\n".join(cards),
app_url=app_url,
mode_label=mode_label,
mode_badge_class=mode_badge_class,
)
+21 -8
View File
@@ -1,14 +1,27 @@
SUMMARY_SYSTEM_PROMPT = "你是一个输出严格 JSON 格式的后台引擎。"
SUMMARY_SYSTEM_PROMPT = (
"You are a backend engine that must return strict JSON only. "
"Do not include markdown, explanation, or extra keys."
)
SUMMARY_USER_PROMPT_TEMPLATE = """
你是一个专业的新闻聚合编辑。请根据以下同一个大事件在不同平台的热搜标题,
为该事件生成一个客观、吸睛的【统一大标题】,以及一段【多平台视角的综合摘要】。
You are a professional cross-platform news editor.
Based on the following headlines about the same event from different platforms,
return:
1) a neutral unified title
2) a cross-platform comprehensive summary
3) topic tags
要求:
1. 摘要结构类似:"该事件在多平台发酵。微博侧重讨论...,知乎硬核解析...,科技媒体关注..."
2. 提炼出各平台的讨论侧重点,不要简单罗列标题。
3. 必须以严格的 JSON 格式返回,只包含 "unified_title""ai_comprehensive_summary" 两个字段,不要有多余的说明。
Rules:
1. Return strict JSON with exactly these keys:
- "unified_title": string
- "ai_comprehensive_summary": string
- "topic_keywords": array of 3 to 8 objects
2. Each item in "topic_keywords" must be:
{{"keyword": string, "relevance_score": number}}
3. relevance_score must be in [0, 100].
4. keyword should be concise (max 12 chars preferred).
5. The language should follow the dominant language in the input.
各平台热搜标题数据:
Cross-platform headline data:
{platform_data_text}
"""
+72
View File
@@ -0,0 +1,72 @@
# 推送设置相关的请求/响应模型
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, ConfigDict, Field
# ==========================================
# 推送时间表 (UserDeliverySchedule)
# ==========================================
class DeliveryScheduleCreate(BaseModel):
"""新增推送时间请求体,时间格式 HH:MM"""
delivery_time: str = Field(..., pattern=r"^\d{2}:\d{2}$", description="每天推送的时间,格式 HH:MM")
is_active: bool = Field(default=True, description="是否启用此时段")
class DeliveryScheduleUpdate(BaseModel):
"""更新推送时间请求体"""
delivery_time: Optional[str] = Field(None, pattern=r"^\d{2}:\d{2}$")
is_active: Optional[bool] = None
class DeliveryScheduleResponse(BaseModel):
"""推送时间响应体"""
id: int
user_id: int
delivery_time: str
is_active: bool
created_at: datetime
model_config = ConfigDict(from_attributes=True)
# ==========================================
# 推送渠道端点 (UserPushEndpoint)
# ==========================================
class PushEndpointCreate(BaseModel):
"""新增推送渠道请求体"""
channel_type: str = Field(..., max_length=50, description="渠道类型,如 EMAIL / WECHAT_BOT / TELEGRAM")
channel_account: str = Field(..., max_length=255, description="具体接收账号(邮箱地址/Webhook等)")
is_active: bool = Field(default=True, description="是否启用")
priority_level: int = Field(default=1, ge=1, le=10, description="优先级,1最高")
class PushEndpointUpdate(BaseModel):
"""更新推送渠道请求体"""
channel_account: Optional[str] = Field(None, max_length=255)
is_active: Optional[bool] = None
priority_level: Optional[int] = Field(None, ge=1, le=10)
class PushEndpointResponse(BaseModel):
"""推送渠道响应体"""
id: int
user_id: int
channel_type: str
channel_account: str
is_active: bool
priority_level: int
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
# ==========================================
# 推送设置聚合响应(一次性返回全部推送配置)
# ==========================================
class UserDeliveryConfigResponse(BaseModel):
"""用户的完整推送配置(时间表 + 渠道列表)"""
schedules: List[DeliveryScheduleResponse] = Field(default_factory=list)
endpoints: List[PushEndpointResponse] = Field(default_factory=list)
+19 -12
View File
@@ -1,23 +1,30 @@
# app/schemas/event_schema.py
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime
class PlatformTrendResponse(BaseModel):
source_id: int
platform_name: str # 平台名称,如 "微博热搜"
headline: str # 平台对应的具体热搜标题
url: Optional[str] # 跳转链接
current_ranking: Optional[int] # 当前排名
ranking_history: List[int] # 排名历史轨迹,如 [50, 45, 20, 5, 1],供 ApexCharts 渲染
platform_name: str
headline: str
url: Optional[str]
current_ranking: Optional[int]
ranking_history: List[int]
class UnifiedEventResponse(BaseModel):
event_id: int
unified_title: str # AI 生成的统一大标题
summary: Optional[str] # AI 生成的摘要
hot_score: int # 总热度值
created_at: datetime # 事件发现时间
platforms: List[PlatformTrendResponse] # 挂载的各个平台子热搜
# tags: List[str] = [] # 如果后续打通了 ExtractedTopic,可以在这里返回标签
unified_title: str
summary: Optional[str]
hot_score: int
created_at: datetime
platforms: List[PlatformTrendResponse]
tags: List[str] = Field(default_factory=list)
class PaginatedUnifiedEventResponse(BaseModel):
"""分页包装:避免一次性返回全量数据"""
total: int
has_more: bool
data: List[UnifiedEventResponse]
+46
View File
@@ -0,0 +1,46 @@
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, ConfigDict, Field
class UserTopicPreferenceCreate(BaseModel):
"""新增用户兴趣词请求体。"""
interested_keyword: str = Field(..., min_length=1, max_length=100, description="用户感兴趣的关键词")
class UserTopicPreferenceResponse(BaseModel):
"""用户兴趣词响应体。"""
id: int
user_id: int
interested_keyword: str
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class EventMatchSemanticHit(BaseModel):
"""语义命中的明细。"""
preference_keyword: str
topic_keyword: str
similarity: float
class MatchedEventResponse(BaseModel):
"""推荐事件响应体。"""
event_id: int
unified_title: str
summary: Optional[str]
hot_score: int
created_at: datetime
tags: List[str] = Field(default_factory=list)
match_score: float
exact_hits: List[str] = Field(default_factory=list)
semantic_hits: List[EventMatchSemanticHit] = Field(default_factory=list)
class UserPreferenceRecommendationResponse(BaseModel):
"""用户兴趣推荐结果。"""
user_id: int
total: int
data: List[MatchedEventResponse] = Field(default_factory=list)
+454
View File
@@ -0,0 +1,454 @@
# 定时推送调度服务
# 由 APScheduler 每分钟调用,检查当前时刻是否有用户需要接收推送,
# 如匹配则生成摘要邮件并发送,同时写入 DeliveryHistory 防重复。
import logging
from logging.handlers import TimedRotatingFileHandler
from dataclasses import dataclass, field
from datetime import datetime, time as dt_time, timedelta, timezone, tzinfo
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from sqlalchemy.orm import Session
from app.database import SessionLocal
from app.models.models import (
AppUser,
DeliveryHistory,
ExtractedTopic,
InfoSource,
TargetType,
TaskStatus,
TrendingEvent,
UnifiedEvent,
UserDeliverySchedule,
UserPushEndpoint,
UserTopicPreference,
utcnow,
)
from app.prompts.digest_email_template import build_digest_html
from app.services.matching_service import recommend_events_for_user
from app.utils.email_utils import send_html_email
logger = logging.getLogger("delivery_service")
# delivery_service 日志单独写文件,不再输出到控制台
_delivery_log_dir = Path(__file__).resolve().parents[2] / "logs"
_delivery_log_dir.mkdir(parents=True, exist_ok=True)
_delivery_log_file = _delivery_log_dir / "delivery_check.log"
if not logger.handlers:
_file_handler = TimedRotatingFileHandler(
filename=str(_delivery_log_file),
when="midnight",
interval=1,
backupCount=14,
encoding="utf-8",
)
_file_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s"))
logger.addHandler(_file_handler)
logger.setLevel(logging.INFO)
logger.propagate = False
# 推送时间窗口:实际执行时刻与设定时间的最大容差(分钟)
DELIVERY_WINDOW_MINUTES = 2
# 同一用户两次推送之间的最小间隔(分钟)
MIN_PUSH_INTERVAL_MINUTES = 30
# 单次推送最多携带的事件数
MAX_EVENTS_PER_PUSH = 12
# 默认模式热度阈值(无关键词或无匹配时使用)
DEFAULT_MODE_HOT_THRESHOLD = 3
# 默认模式查询时间窗口(小时)
DEFAULT_MODE_HOURS = 48
# 用户时区无效时的兜底时区
DEFAULT_FALLBACK_TIMEZONE = "Asia/Shanghai"
# ==========================================
# 默认热点事件容器(无关键词时使用)
# ==========================================
@dataclass
class _DefaultEventItem:
"""
无关键词订阅或关键词无匹配时的默认热点包装器,
接口与 MatchedEventResult 保持一致,方便统一传给模板。
"""
event: UnifiedEvent
match_score: float = 0.0
exact_hits: list[str] = field(default_factory=list)
semantic_hits: list[dict[str, Any]] = field(default_factory=list)
tags: list[str] = field(default_factory=list)
is_default: bool = True
# ==========================================
# 时区工具
# ==========================================
def _time_to_minutes(t: dt_time) -> int:
return t.hour * 60 + t.minute
def _is_within_window(schedule_time: dt_time, current_time: dt_time, window: int = DELIVERY_WINDOW_MINUTES) -> bool:
"""判断 schedule_time 是否在 current_time ± window 分钟范围内(跨午夜安全)。"""
s = _time_to_minutes(schedule_time)
c = _time_to_minutes(current_time)
diff = abs(s - c)
return min(diff, 1440 - diff) <= window
def _resolve_user_timezone(user_timezone: str | None) -> tzinfo:
"""解析用户时区,异常时回退到默认时区。"""
tz_name = (user_timezone or "").strip() or DEFAULT_FALLBACK_TIMEZONE
try:
return ZoneInfo(tz_name)
except ZoneInfoNotFoundError:
logger.warning(
"用户时区无效,已回退默认时区。timezone=%s fallback=%s",
tz_name, DEFAULT_FALLBACK_TIMEZONE,
)
try:
return ZoneInfo(DEFAULT_FALLBACK_TIMEZONE)
except ZoneInfoNotFoundError:
logger.warning("系统缺少时区数据库,最终回退为 UTC。建议安装 tzdata 包。")
return timezone.utc
def _user_local_time(now_utc: datetime, user_timezone: str | None) -> dt_time:
"""把 UTC 当前时刻转换为用户本地时间(仅取 HH:MM)。"""
local_dt = now_utc.astimezone(_resolve_user_timezone(user_timezone))
return local_dt.time().replace(second=0, microsecond=0)
def _ensure_aware(dt: datetime) -> datetime:
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
# ==========================================
# 数据库查询辅助
# ==========================================
def _should_skip_by_interval(db: Session, user_id: int) -> bool:
"""检查用户是否仍在 30 分钟冷却期内。"""
row = (
db.query(DeliveryHistory.created_at)
.filter(
DeliveryHistory.user_id == user_id,
DeliveryHistory.status == TaskStatus.SUCCESS,
)
.order_by(DeliveryHistory.created_at.desc())
.first()
)
if row is None:
return False
last_time = _ensure_aware(row[0])
elapsed = (utcnow() - last_time).total_seconds() / 60.0
return elapsed < MIN_PUSH_INTERVAL_MINUTES
def _get_user_email_endpoints(db: Session, user_id: int) -> list[UserPushEndpoint]:
"""获取用户已启用的邮件类型推送渠道,按优先级排序。"""
return (
db.query(UserPushEndpoint)
.filter(
UserPushEndpoint.user_id == user_id,
UserPushEndpoint.channel_type == "EMAIL",
UserPushEndpoint.is_active == True,
)
.order_by(UserPushEndpoint.priority_level.asc())
.all()
)
def _get_already_pushed_event_ids(db: Session, user_id: int) -> set[int]:
"""获取已经推送过的事件 ID 集合,避免重复轰炸。"""
rows = (
db.query(DeliveryHistory.target_id)
.filter(
DeliveryHistory.user_id == user_id,
DeliveryHistory.target_type == TargetType.EVENT,
DeliveryHistory.status == TaskStatus.SUCCESS,
)
.all()
)
return {r[0] for r in rows}
def _load_event_platforms(db: Session, event_ids: list[int]) -> dict[int, list[dict]]:
"""
批量加载事件的平台来源数据。
返回:event_id → [{source_name, headline, url, ranking, icon_url}, ...]
按排名升序排列(rank 1 最靠前)。
"""
if not event_ids:
return {}
rows = (
db.query(
TrendingEvent.unified_event_id,
TrendingEvent.current_headline,
TrendingEvent.event_url,
TrendingEvent.current_ranking,
TrendingEvent.icon_url,
InfoSource.source_name,
)
.join(InfoSource, TrendingEvent.source_id == InfoSource.id)
.filter(TrendingEvent.unified_event_id.in_(event_ids))
.order_by(
TrendingEvent.unified_event_id,
TrendingEvent.current_ranking.asc().nulls_last(),
)
.all()
)
result: dict[int, list[dict]] = {}
for event_id, headline, url, ranking, icon_url, source_name in rows:
result.setdefault(event_id, []).append({
"source_name": source_name or "未知",
"headline": headline or "",
"url": url or "",
"ranking": ranking,
"icon_url": icon_url or "",
})
return result
def _load_event_tags(db: Session, event_ids: list[int]) -> dict[int, list[str]]:
"""批量加载事件的标签,返回 event_id → [tag, ...]。"""
if not event_ids:
return {}
rows = (
db.query(ExtractedTopic.target_id, ExtractedTopic.topic_keyword)
.filter(
ExtractedTopic.target_type == TargetType.EVENT,
ExtractedTopic.target_id.in_(event_ids),
)
.all()
)
tags_map: dict[int, list[str]] = {}
for eid, kw in rows:
if kw:
tags_map.setdefault(eid, []).append(kw)
return tags_map
def _user_has_keywords(db: Session, user_id: int) -> bool:
"""判断用户是否配置了关键词订阅。"""
return (
db.query(UserTopicPreference.id)
.filter(UserTopicPreference.user_id == user_id)
.first()
) is not None
def _get_default_hot_events(
db: Session,
pushed_ids: set[int],
) -> list[_DefaultEventItem]:
"""
默认模式:获取热度 >= DEFAULT_MODE_HOT_THRESHOLD 的近期热点,
排除已推送过的,封装成与 MatchedEventResult 接口相同的对象。
"""
time_limit = utcnow() - timedelta(hours=DEFAULT_MODE_HOURS)
events = (
db.query(UnifiedEvent)
.filter(
UnifiedEvent.hot_score >= DEFAULT_MODE_HOT_THRESHOLD,
UnifiedEvent.created_at >= time_limit,
)
.order_by(UnifiedEvent.hot_score.desc())
.limit(MAX_EVENTS_PER_PUSH * 2)
.all()
)
event_ids = [e.id for e in events if e.id not in pushed_ids]
tags_map = _load_event_tags(db, event_ids)
result: list[_DefaultEventItem] = []
for ev in events:
if ev.id in pushed_ids:
continue
result.append(_DefaultEventItem(
event=ev,
tags=list(dict.fromkeys(tags_map.get(ev.id, [])))[:6],
))
if len(result) >= MAX_EVENTS_PER_PUSH:
break
return result
def _record_delivery(
db: Session,
user_id: int,
event_ids: list[int],
status: TaskStatus,
) -> None:
"""批量写入推送历史记录。"""
for eid in event_ids:
record = DeliveryHistory(
user_id=user_id,
target_type=TargetType.EVENT,
target_id=eid,
status=status,
)
db.add(record)
db.commit()
# ==========================================
# 推送准备
# ==========================================
@dataclass
class _PendingPush:
"""暂存需要发送邮件的信息,便于在 async 上下文中发送。"""
user_id: int
email_targets: list[str]
subject: str
html_body: str
event_ids: list[int]
def _prepare_user_push(db: Session, user: AppUser, schedule: UserDeliverySchedule) -> _PendingPush | None:
"""
同步准备单个用户的推送数据(DB 操作),不实际发送邮件。
推送优先级:
1. 有关键词 且 有匹配 → 发送匹配事件
2. 有关键词 但 无匹配 → 发送默认热点(热度 >= 3)
3. 无关键词 → 发送默认热点(热度 >= 3)
"""
user_id = user.id
if _should_skip_by_interval(db, user_id):
logger.info(f"用户 {user_id} 仍在 {MIN_PUSH_INTERVAL_MINUTES} 分钟冷却期内,跳过")
return None
email_endpoints = _get_user_email_endpoints(db, user_id)
if not email_endpoints:
logger.info(f"用户 {user_id} 无可用邮件渠道,跳过")
return None
pushed_ids = _get_already_pushed_event_ids(db, user_id)
# ——— 决策:匹配模式 or 默认模式 ———
items: list = []
is_default = False
has_keywords = _user_has_keywords(db, user_id)
if has_keywords:
matched = recommend_events_for_user(
db,
user_id=user_id,
min_hot=1,
hours=72,
limit=MAX_EVENTS_PER_PUSH * 2,
)
fresh_matched = [m for m in matched if m.event.id not in pushed_ids]
if fresh_matched:
items = fresh_matched[:MAX_EVENTS_PER_PUSH]
logger.info(f"用户 {user_id} 关键词匹配,推送 {len(items)} 条事件")
else:
logger.info(f"用户 {user_id} 关键词无匹配结果,切换为默认热点模式")
is_default = True
else:
logger.info(f"用户 {user_id} 未配置关键词,使用默认热点模式")
is_default = True
if is_default:
items = _get_default_hot_events(db, pushed_ids)
if not items:
logger.info(f"用户 {user_id} 默认热点无可推送内容,跳过")
return None
# 批量加载平台数据(来源名、标题、URL、排名)
event_ids = [item.event.id for item in items]
platforms_map = _load_event_platforms(db, event_ids)
time_str = schedule.delivery_time.strftime("%H:%M")
html_body = build_digest_html(
items=items,
delivery_time_str=time_str,
platforms_map=platforms_map,
is_default_push=is_default,
)
subject_suffix = "全网热点快报" if is_default else "个性化简报"
return _PendingPush(
user_id=user_id,
email_targets=[ep.channel_account for ep in email_endpoints],
subject=f"InsightRadar {subject_suffix} · {time_str}",
html_body=html_body,
event_ids=event_ids,
)
# ==========================================
# 调度主入口
# ==========================================
async def check_and_deliver() -> None:
"""
定时推送主入口,由 APScheduler 每分钟调用。
流程:
1. 获取当前 UTC 时间
2. 查询所有启用的推送计划
3. 对每个计划,按用户本地时区判断是否在推送窗口
4. 同步准备推送数据 → 异步发送邮件 → 记录结果
"""
now = datetime.now(timezone.utc)
current_utc = now.time().replace(second=0, microsecond=0)
logger.debug(f"推送调度检查 @ UTC {current_utc.strftime('%H:%M')}")
db: Session = SessionLocal()
try:
active_schedules = (
db.query(UserDeliverySchedule)
.filter(UserDeliverySchedule.is_active == True)
.all()
)
for schedule in active_schedules:
user = db.query(AppUser).filter(AppUser.id == schedule.user_id).first()
if not user:
continue
# 用户本地时间对比(核心时区修正)
user_current = _user_local_time(now, user.timezone)
if not _is_within_window(schedule.delivery_time, user_current):
continue
try:
pending = _prepare_user_push(db, user, schedule)
if pending is None:
continue
# 异步按优先级尝试各邮件渠道
sent = False
for target_email in pending.email_targets:
try:
success = await send_html_email(
to_email=target_email,
subject=pending.subject,
html_content=pending.html_body,
)
if success:
sent = True
logger.info(f"用户 {pending.user_id} 邮件发送成功 → {target_email}")
break
else:
logger.warning(f"用户 {pending.user_id} 渠道 {target_email} 发送失败,尝试下一个")
except Exception as e:
logger.error(f"用户 {pending.user_id} 发送至 {target_email} 异常: {e}")
_record_delivery(
db,
user_id=pending.user_id,
event_ids=pending.event_ids,
status=TaskStatus.SUCCESS if sent else TaskStatus.ERROR,
)
except Exception as e:
logger.error(f"推送用户 {schedule.user_id} 时异常: {e}", exc_info=True)
except Exception as e:
logger.error(f"推送调度主循环异常: {e}", exc_info=True)
finally:
db.close()
+238
View File
@@ -0,0 +1,238 @@
import os
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any
import numpy as np
from sqlalchemy.orm import Session
from app.models.models import ExtractedTopic, TargetType, UnifiedEvent, UserTopicPreference, utcnow
from app.services.fetcher_service import embedder_model
# 语义匹配阈值:用户关键词和事件标签向量相似度达到该值才计入语义命中
DEFAULT_PREFERENCE_SEMANTIC_THRESHOLD = 0.78
PREFERENCE_SEMANTIC_THRESHOLD = float(
os.getenv("PREFERENCE_SEMANTIC_THRESHOLD", str(DEFAULT_PREFERENCE_SEMANTIC_THRESHOLD))
)
# 推荐列表最大返回条数
DEFAULT_PREFERENCE_RECOMMEND_MAX_LIMIT = 50
PREFERENCE_RECOMMEND_MAX_LIMIT = int(
os.getenv("PREFERENCE_RECOMMEND_MAX_LIMIT", str(DEFAULT_PREFERENCE_RECOMMEND_MAX_LIMIT))
)
@dataclass
class MatchedEventResult:
"""用户兴趣匹配后的事件结果。"""
event: UnifiedEvent
match_score: float
exact_hits: list[str]
semantic_hits: list[dict[str, Any]]
tags: list[str]
def _normalize_text(text: str) -> str:
"""统一小写与首尾空白,便于做稳定匹配。"""
return text.strip().casefold()
def _build_keyword_embedding_map(keywords: list[str]) -> dict[str, np.ndarray]:
"""
批量生成关键词向量,并返回原词到向量的映射。
这里要求向量已归一化,后续可直接用点积表示余弦相似度。
"""
if not keywords:
return {}
vectors = embedder_model.encode(keywords, normalize_embeddings=True)
result: dict[str, np.ndarray] = {}
for keyword, vec in zip(keywords, vectors):
result[keyword] = np.asarray(vec, dtype=np.float32)
return result
def _ensure_aware(dt: datetime) -> datetime:
"""SQLite 读出的 datetime 不带时区信息,统一补上 UTC 后才能和 utcnow() 做减法。"""
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
def _calc_freshness_bonus(event: UnifiedEvent) -> float:
"""根据事件新鲜度给一个小额加分,避免旧热点长期占据推荐位。"""
age_hours = max((utcnow() - _ensure_aware(event.created_at)).total_seconds() / 3600.0, 0.0)
if age_hours <= 6:
return 12.0
if age_hours <= 24:
return 8.0
if age_hours <= 72:
return 4.0
return 0.0
def recommend_events_for_user(
db: Session,
*,
user_id: int,
min_hot: int = 3,
hours: int = 72,
limit: int = 20,
semantic_threshold: float | None = None,
) -> list[MatchedEventResult]:
"""
用户兴趣推荐主流程:
1) 精确匹配:用户词 == EVENT 标签
2) 语义匹配:用户词向量 vs EVENT 标签向量(超过阈值)
3) 打分融合:匹配分 + 标签相关度 + 热度 + 新鲜度
"""
final_limit = max(1, min(limit, PREFERENCE_RECOMMEND_MAX_LIMIT))
similarity_threshold = (
semantic_threshold
if semantic_threshold is not None
else PREFERENCE_SEMANTIC_THRESHOLD
)
# 读取用户兴趣词
preferences = (
db.query(UserTopicPreference)
.filter(UserTopicPreference.user_id == user_id)
.all()
)
if not preferences:
return []
preference_keywords = [pref.interested_keyword.strip() for pref in preferences if pref.interested_keyword.strip()]
if not preference_keywords:
return []
# 读取候选事件(先做时间和热度过滤,避免全表扫描)
time_limit = utcnow() - timedelta(hours=hours)
events = (
db.query(UnifiedEvent)
.filter(
UnifiedEvent.hot_score >= min_hot,
UnifiedEvent.created_at >= time_limit,
)
.order_by(UnifiedEvent.hot_score.desc(), UnifiedEvent.created_at.desc())
.all()
)
if not events:
return []
event_id_list = [event.id for event in events]
topic_rows = (
db.query(
ExtractedTopic.target_id,
ExtractedTopic.topic_keyword,
ExtractedTopic.relevance_score,
)
.filter(
ExtractedTopic.target_type == TargetType.EVENT,
ExtractedTopic.target_id.in_(event_id_list),
)
.all()
)
if not topic_rows:
return []
# 组织事件标签映射:event_id -> [(tag, relevance_score), ...]
event_topics: dict[int, list[tuple[str, float | None]]] = {}
for event_id, topic_keyword, relevance_score in topic_rows:
if not topic_keyword:
continue
event_topics.setdefault(event_id, []).append((topic_keyword, relevance_score))
# 如果某事件没有标签,就不参与推荐
if not event_topics:
return []
# 批量编码用户词和标签词,避免逐条调用模型
unique_preference_keywords = list(dict.fromkeys(preference_keywords))
unique_topic_keywords = list(dict.fromkeys([row[1] for row in topic_rows if row[1]]))
pref_vec_map = _build_keyword_embedding_map(unique_preference_keywords)
topic_vec_map = _build_keyword_embedding_map(unique_topic_keywords)
# 预先建立“标准化后用户词集合”,用于精确匹配
normalized_pref_set = {_normalize_text(word) for word in unique_preference_keywords}
scored_results: list[MatchedEventResult] = []
for event in events:
topic_list = event_topics.get(event.id, [])
if not topic_list:
continue
exact_hits: list[str] = []
semantic_hits: list[dict[str, Any]] = []
score = 0.0
# 对事件标签逐个匹配用户兴趣
for topic_keyword, topic_relevance in topic_list:
normalized_topic = _normalize_text(topic_keyword)
topic_relevance_score = float(topic_relevance) if topic_relevance is not None else 50.0
# 1) 精确命中(包括完全相等与包含关系)
matched_exact = False
if normalized_topic in normalized_pref_set:
matched_exact = True
else:
for pref_word in normalized_pref_set:
if pref_word and (pref_word in normalized_topic or normalized_topic in pref_word):
matched_exact = True
break
if matched_exact:
exact_hits.append(topic_keyword)
# 精确命中给较高基础分,标签自身相关度作为增益
score += 45.0 + topic_relevance_score * 0.2
continue
# 2) 语义命中(未精确命中时再算)
topic_vec = topic_vec_map.get(topic_keyword)
if topic_vec is None:
continue
best_pref = None
best_sim = -1.0
for pref_keyword, pref_vec in pref_vec_map.items():
sim = float(np.dot(topic_vec, pref_vec))
if sim > best_sim:
best_sim = sim
best_pref = pref_keyword
if best_pref is not None and best_sim >= similarity_threshold:
semantic_hits.append(
{
"preference_keyword": best_pref,
"topic_keyword": topic_keyword,
"similarity": round(best_sim, 4),
}
)
# 语义命中分略低于精确命中,并由相似度放大
score += best_sim * 35.0 + topic_relevance_score * 0.12
# 如果精确和语义都没命中,直接跳过
if not exact_hits and not semantic_hits:
continue
# 融合事件热度和新鲜度,避免只看语义分
score += min(event.hot_score, 100) * 0.3
score += _calc_freshness_bonus(event)
# 返回标签时做去重,保证接口稳定
tags = list(dict.fromkeys([item[0] for item in topic_list]))
scored_results.append(
MatchedEventResult(
event=event,
match_score=round(score, 2),
exact_hits=list(dict.fromkeys(exact_hits)),
semantic_hits=semantic_hits,
tags=tags,
)
)
scored_results.sort(
key=lambda item: (item.match_score, item.event.hot_score, item.event.created_at),
reverse=True,
)
return scored_results[:final_limit]
+175 -38
View File
@@ -1,104 +1,241 @@
# app/services/summary_service.py
import os
import json
import os
from datetime import timedelta
from typing import Any
import numpy as np
from openai import AsyncOpenAI
from app.database import SessionLocal
from app.models.models import UnifiedEvent, TrendingEvent, InfoSource, utcnow
from app.models.models import (
ExtractedTopic,
InfoSource,
TargetType,
TrendingEvent,
UnifiedEvent,
utcnow,
)
from app.prompts.summary_prompts import (
SUMMARY_SYSTEM_PROMPT,
SUMMARY_USER_PROMPT_TEMPLATE,
)
from app.services.fetcher_service import embedder_model
HOT_SCORE_THRESHOLD = int(os.getenv("HOT_SCORE_THRESHOLD", 3))
AI_API_KEY = os.getenv("AI_API_KEY", '')
TOPIC_TAG_MIN_HOT_SCORE = int(os.getenv("TOPIC_TAG_MIN_HOT_SCORE", HOT_SCORE_THRESHOLD))
TOPIC_SIMILARITY_THRESHOLD = float(os.getenv("TOPIC_SIMILARITY_THRESHOLD", 0.82))
TOPIC_TAG_MAX_COUNT = int(os.getenv("TOPIC_TAG_MAX_COUNT", 8))
AI_API_KEY = os.getenv("AI_API_KEY", "")
# 1. 初始化异步客户端 (全局复用)
deepseek_client = AsyncOpenAI(
api_key=AI_API_KEY,
base_url="https://api.deepseek.com"
base_url="https://api.deepseek.com",
)
async def call_llm_for_summary(platform_data_text: str) -> dict:
"""调用 DeepSeek 生成统一标题和多平台视角摘要"""
prompt = SUMMARY_USER_PROMPT_TEMPLATE.format(
platform_data_text=platform_data_text
)
"""Call LLM for unified title, summary and topic candidates."""
prompt = SUMMARY_USER_PROMPT_TEMPLATE.format(platform_data_text=platform_data_text)
# await
response = await deepseek_client.chat.completions.create(
model="deepseek-chat",
messages=[
{"role": "system", "content": SUMMARY_SYSTEM_PROMPT},
{"role": "user", "content": prompt}
{"role": "user", "content": prompt},
],
response_format={"type": "json_object"},
temperature=1
temperature=1,
)
result_text = response.choices[0].message.content
return json.loads(result_text)
def _normalize_score(raw_score: Any) -> float | None:
try:
score = float(raw_score)
except (TypeError, ValueError):
return None
if score <= 1:
score *= 100
return max(0.0, min(100.0, score))
def parse_topic_keywords(llm_result: dict) -> list[dict[str, Any]]:
"""Parse topic keywords from LLM response; support list[str] and list[object]."""
raw_topics = llm_result.get("topic_keywords") or []
parsed: list[dict[str, Any]] = []
seen: set[str] = set()
for item in raw_topics:
keyword = ""
score = None
if isinstance(item, str):
keyword = item.strip()
elif isinstance(item, dict):
raw_keyword = (
item.get("keyword")
or item.get("topic_keyword")
or item.get("name")
or item.get("topic")
or ""
)
keyword = str(raw_keyword).strip()
score = _normalize_score(item.get("relevance_score") or item.get("score"))
if not keyword:
continue
keyword = keyword[:100]
normalized_key = keyword.casefold()
if normalized_key in seen:
continue
seen.add(normalized_key)
parsed.append({"keyword": keyword, "score": score})
return parsed
def normalize_topic_keywords(topic_candidates: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Deduplicate semantically similar tags using embedding similarity."""
if not topic_candidates:
return []
keywords = [item["keyword"] for item in topic_candidates]
vectors = embedder_model.encode(keywords, normalize_embeddings=True)
clusters: list[dict[str, Any]] = []
for item, vector in zip(topic_candidates, vectors):
vec = np.asarray(vector, dtype=np.float32)
best_idx = -1
best_sim = -1.0
for idx, cluster in enumerate(clusters):
sim = float(np.dot(vec, cluster["vector"]))
if sim > best_sim:
best_sim = sim
best_idx = idx
if best_idx >= 0 and best_sim >= TOPIC_SIMILARITY_THRESHOLD:
cluster = clusters[best_idx]
merged = cluster["vector"] * cluster["count"] + vec
norm = float(np.linalg.norm(merged))
if norm > 0:
cluster["vector"] = merged / norm
cluster["count"] += 1
if item["score"] is not None and (
cluster["score"] is None or item["score"] > cluster["score"]
):
cluster["score"] = item["score"]
# Prefer shorter tag as canonical keyword.
if len(item["keyword"]) < len(cluster["keyword"]):
cluster["keyword"] = item["keyword"]
else:
clusters.append(
{
"keyword": item["keyword"],
"score": item["score"],
"vector": vec,
"count": 1,
}
)
if any(cluster["score"] is not None for cluster in clusters):
clusters.sort(key=lambda x: x["score"] if x["score"] is not None else -1.0, reverse=True)
result = [
{"keyword": cluster["keyword"], "score": cluster["score"]}
for cluster in clusters[:TOPIC_TAG_MAX_COUNT]
]
return result
def replace_event_topics(db, event_id: int, normalized_topics: list[dict[str, Any]]) -> None:
"""Replace EVENT tags for one unified event atomically within current transaction."""
db.query(ExtractedTopic).filter(
ExtractedTopic.target_type == TargetType.EVENT,
ExtractedTopic.target_id == event_id,
).delete(synchronize_session=False)
for item in normalized_topics:
db.add(
ExtractedTopic(
target_type=TargetType.EVENT,
target_id=event_id,
topic_keyword=item["keyword"],
relevance_score=item["score"],
)
)
async def generate_unified_summaries():
"""定时任务:扫描高热度事件并生成/更新摘要"""
print(f"[{utcnow()}] 开始执行 DeepSeek 摘要生成任务...")
"""Scheduled task: refresh summaries and topic tags for hot unified events."""
print(f"[{utcnow()}] Start unified summary generation task...")
with SessionLocal() as db:
recent_threshold = utcnow() - timedelta(days=3)
# 必须满足:热度达标 AND (当前热度 > 上次生成摘要时的热度) AND 近期活跃
events = db.query(UnifiedEvent).filter(
UnifiedEvent.hot_score >= HOT_SCORE_THRESHOLD,
UnifiedEvent.hot_score > UnifiedEvent.last_summarized_trends_count,
UnifiedEvent.created_at >= recent_threshold
UnifiedEvent.created_at >= recent_threshold,
).all()
if not events:
print("当前没有需要更新摘要的大事件,任务结束。")
print("No events require summary update in this round.")
return
for event in events:
# 联合查询获取该事件在各平台的子新闻
trends = db.query(TrendingEvent, InfoSource.source_name) \
.join(InfoSource, TrendingEvent.source_id == InfoSource.id) \
.filter(TrendingEvent.unified_event_id == event.id) \
trends = (
db.query(TrendingEvent, InfoSource.source_name)
.join(InfoSource, TrendingEvent.source_id == InfoSource.id)
.filter(TrendingEvent.unified_event_id == event.id)
.all()
)
if not trends:
continue
# 按平台归类标题并去重
platform_dict = {}
platform_dict: dict[str, set[str]] = {}
for trend_record, source_name in trends:
if source_name not in platform_dict:
platform_dict[source_name] = set()
platform_dict[source_name].add(trend_record.current_headline)
platform_dict.setdefault(source_name, set()).add(trend_record.current_headline)
# 组装给大模型的 Prompt 数据
prompt_lines = [f"{platform}】: {', '.join(headlines)}" for platform, headlines in platform_dict.items()]
prompt_lines = [
f"[{platform}] {', '.join(sorted(headlines))}"
for platform, headlines in platform_dict.items()
]
platform_data_text = "\n".join(prompt_lines)
try:
# 调用封装好的异步函数
llm_result = await call_llm_for_summary(platform_data_text)
if "unified_title" in llm_result:
if "unified_title" in llm_result and llm_result["unified_title"]:
event.unified_title = llm_result["unified_title"]
if "ai_comprehensive_summary" in llm_result:
if "ai_comprehensive_summary" in llm_result and llm_result["ai_comprehensive_summary"]:
event.ai_comprehensive_summary = llm_result["ai_comprehensive_summary"]
# 成功后更新水位线
# 将最后一次总结时的热搜数量,更新为当前最新的 hot_score
if event.hot_score >= TOPIC_TAG_MIN_HOT_SCORE:
topic_candidates = parse_topic_keywords(llm_result)
normalized_topics = normalize_topic_keywords(topic_candidates)
if normalized_topics:
replace_event_topics(db, event.id, normalized_topics)
event.last_summarized_trends_count = event.hot_score
print(
f"Updated event {event.id} summary"
f" (hot_score={event.hot_score})."
)
print(f"成功更新大事件 ID {event.id} 的深度摘要 (当前热度: {event.hot_score})。")
except Exception as e:
print(f"大事件 ID {event.id} 摘要生成失败: {e}")
except Exception as exc:
print(f"Event {event.id} summary generation failed: {exc}")
continue
# 提交事务
db.commit()
+1 -1
View File
@@ -17,7 +17,7 @@ async def send_html_email(
to_email: str,
subject: str,
html_content: str,
sender_name: str = "AI 新闻早报",
sender_name: str = "AI 新闻",
sender_email: str = None
) -> bool:
"""底层纯异步发送邮件工具"""
+4 -2
View File
@@ -1,10 +1,12 @@
<!DOCTYPE html>
<html lang="">
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
<title>InsightRadar - 全网热点监控中枢</title>
<!-- Font Awesome 图标库 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
</head>
<body>
<div id="app"></div>
+34 -1
View File
@@ -8,9 +8,11 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"apexcharts": "^5.10.3",
"pinia": "^3.0.4",
"vue": "^3.5.29",
"vue-router": "^5.0.3"
"vue-router": "^5.0.3",
"vue3-apexcharts": "^1.11.1"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
@@ -2535,6 +2537,12 @@
}
}
},
"node_modules/@yr/monotone-cubic-spline": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz",
"integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==",
"license": "MIT"
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -2605,6 +2613,16 @@
"node": ">=14"
}
},
"node_modules/apexcharts": {
"version": "5.10.3",
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.10.3.tgz",
"integrity": "sha512-wwvkSLsodNOc/rHo5MJsn3GPM4Krc5Gs0zKX4Lfzq4LohcTbyKylYUGEqJFmXXxGR7yLbZQz31sB5RTqT5mv1g==",
"license": "SEE LICENSE IN LICENSE",
"peer": true,
"dependencies": {
"@yr/monotone-cubic-spline": "^1.0.3"
}
},
"node_modules/ast-kit": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz",
@@ -5195,6 +5213,21 @@
"typescript": ">=5.0.0"
}
},
"node_modules/vue3-apexcharts": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/vue3-apexcharts/-/vue3-apexcharts-1.11.1.tgz",
"integrity": "sha512-MbN3vg8bMG19wc0Lm1HkeQvODgLm56DgpIxtNUO0xpf/JCzYWVGE4jzXp2JISzy2s3Kul1yOxNQUYsLvKQ5L9g==",
"license": "see LICENSE in LICENSE",
"peerDependencies": {
"apexcharts": ">=5.10.0",
"vue": ">=3.0.0"
},
"peerDependenciesMeta": {
"apexcharts": {
"optional": false
}
}
},
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
+3 -1
View File
@@ -15,9 +15,11 @@
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"apexcharts": "^5.10.3",
"pinia": "^3.0.4",
"vue": "^3.5.29",
"vue-router": "^5.0.3"
"vue-router": "^5.0.3",
"vue3-apexcharts": "^1.11.1"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
+2 -7
View File
@@ -9,16 +9,11 @@
<style>
.page-fade-enter-active,
.page-fade-leave-active {
transition: opacity 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.page-fade-enter-from {
opacity: 0;
transform: translateX(-15px);
transition: opacity 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.page-fade-enter-from,
.page-fade-leave-to {
opacity: 0;
transform: translateX(15px);
}
</style>
+107
View File
@@ -0,0 +1,107 @@
/**
* 通用 HTTP 客户端:自动注入 Bearer Token,统一处理错误
*/
import { useAuthStore } from '@/stores/auth'
import { pinia } from '@/stores'
const API_BASE = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? '/api/v1'
// 后端返回的错误消息中英映射
const MESSAGE_MAP: Record<string, string> = {
'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': '该渠道类型已存在',
'Schedule not found': '推送时间不存在',
'Push endpoint not found': '推送渠道不存在',
'Preference not found': '偏好不存在',
'Invalid or expired token': '登录已过期,请重新登录',
'Authentication credentials were not provided': '请先登录',
}
function localizeMessage(msg: string): string {
return MESSAGE_MAP[msg] ?? msg
}
function getAuthHeaders(): Record<string, string> {
const authStore = useAuthStore(pinia)
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (authStore.accessToken) {
headers['Authorization'] = `Bearer ${authStore.accessToken}`
}
return headers
}
async function handleResponse<T>(response: Response): Promise<T> {
const raw = await response.text()
let data: Record<string, unknown> = {}
if (raw) {
try {
data = JSON.parse(raw) as Record<string, unknown>
} catch {
data = {}
}
}
if (!response.ok) {
const detail = data.detail
if (typeof detail === 'string') {
throw new Error(localizeMessage(detail))
}
throw new Error(`请求失败 (${response.status})`)
}
return data as T
}
/** GET 请求 */
export async function apiGet<T>(path: string, params?: Record<string, string | number>): Promise<T> {
let url = `${API_BASE}${path}`
if (params) {
const searchParams = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
searchParams.set(key, String(value))
}
url += `?${searchParams.toString()}`
}
const response = await fetch(url, { method: 'GET', headers: getAuthHeaders() })
return handleResponse<T>(response)
}
/** POST 请求 */
export async function apiPost<T>(path: string, body?: unknown): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
method: 'POST',
headers: getAuthHeaders(),
body: body !== undefined ? JSON.stringify(body) : undefined,
})
return handleResponse<T>(response)
}
/** PATCH 请求 */
export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
method: 'PATCH',
headers: getAuthHeaders(),
body: JSON.stringify(body),
})
return handleResponse<T>(response)
}
/** DELETE 请求 */
export async function apiDelete(path: string): Promise<void> {
const response = await fetch(`${API_BASE}${path}`, {
method: 'DELETE',
headers: getAuthHeaders(),
})
if (!response.ok && response.status !== 204) {
const raw = await response.text()
let detail = `请求失败 (${response.status})`
try {
const data = JSON.parse(raw) as Record<string, unknown>
if (typeof data.detail === 'string') detail = localizeMessage(data.detail)
} catch { /* ignore */ }
throw new Error(detail)
}
}
+68
View File
@@ -0,0 +1,68 @@
import { apiGet, apiPost, apiPatch, apiDelete } from './client'
import type { DeliveryConfig, DeliverySchedule, PushEndpoint } from '@/types/delivery'
/** 获取用户完整推送配置(时间表 + 渠道) */
export function fetchDeliveryConfig(userId: number): Promise<DeliveryConfig> {
return apiGet<DeliveryConfig>(`/users/${userId}/delivery-config`)
}
// ==========================================
// 推送时间表
// ==========================================
export function createDeliverySchedule(
userId: number,
payload: { delivery_time: string; is_active?: boolean },
): Promise<DeliverySchedule> {
return apiPost<DeliverySchedule>(`/users/${userId}/delivery-schedules`, payload)
}
export function updateDeliverySchedule(
userId: number,
scheduleId: number,
payload: { delivery_time?: string; is_active?: boolean },
): Promise<DeliverySchedule> {
return apiPatch<DeliverySchedule>(
`/users/${userId}/delivery-schedules/${scheduleId}`,
payload,
)
}
export function deleteDeliverySchedule(
userId: number,
scheduleId: number,
): Promise<void> {
return apiDelete(`/users/${userId}/delivery-schedules/${scheduleId}`)
}
// ==========================================
// 推送渠道
// ==========================================
export function createPushEndpoint(
userId: number,
payload: {
channel_type: string
channel_account: string
is_active?: boolean
priority_level?: number
},
): Promise<PushEndpoint> {
return apiPost<PushEndpoint>(`/users/${userId}/push-endpoints`, payload)
}
export function updatePushEndpoint(
userId: number,
endpointId: number,
payload: { channel_account?: string; is_active?: boolean; priority_level?: number },
): Promise<PushEndpoint> {
return apiPatch<PushEndpoint>(
`/users/${userId}/push-endpoints/${endpointId}`,
payload,
)
}
export function deletePushEndpoint(
userId: number,
endpointId: number,
): Promise<void> {
return apiDelete(`/users/${userId}/push-endpoints/${endpointId}`)
}
+30
View File
@@ -0,0 +1,30 @@
import { apiGet } from './client'
import type { PaginatedEvents, UnifiedEvent, HeadlineRevision, SystemStats } from '@/types/event'
/** 按 ID 查询单个统一事件(用于推荐跳转聚光灯展示) */
export function fetchEventById(eventId: number): Promise<UnifiedEvent> {
return apiGet<UnifiedEvent>(`/events/unified/${eventId}`)
}
/** 分页获取 AI 聚合事件列表 */
export function fetchUnifiedEvents(params?: {
min_hot?: number
hours?: number
skip?: number
limit?: number
}): Promise<PaginatedEvents> {
return apiGet<PaginatedEvents>('/events/unified', params as Record<string, number>)
}
/** 获取标题修改追踪列表 */
export function fetchHeadlineRevisions(params?: {
hours?: number
limit?: number
}): Promise<HeadlineRevision[]> {
return apiGet<HeadlineRevision[]>('/events/headline-revisions', params as Record<string, number>)
}
/** 获取系统运行状态 */
export function fetchSystemStats(): Promise<SystemStats> {
return apiGet<SystemStats>('/system/stats')
}
+33
View File
@@ -0,0 +1,33 @@
import { apiGet, apiPost, apiDelete } from './client'
import type { UserTopicPreference, RecommendationResponse } from '@/types/preference'
/** 获取用户兴趣关键词列表 */
export function fetchPreferences(userId: number): Promise<UserTopicPreference[]> {
return apiGet<UserTopicPreference[]>(`/users/${userId}/preferences`)
}
/** 新增一个兴趣关键词 */
export function createPreference(
userId: number,
keyword: string,
): Promise<UserTopicPreference> {
return apiPost<UserTopicPreference>(`/users/${userId}/preferences`, {
interested_keyword: keyword,
})
}
/** 删除一个兴趣关键词 */
export function deletePreference(userId: number, preferenceId: number): Promise<void> {
return apiDelete(`/users/${userId}/preferences/${preferenceId}`)
}
/** 基于兴趣词获取推荐事件 */
export function fetchRecommendedEvents(
userId: number,
params?: { min_hot?: number; hours?: number; limit?: number },
): Promise<RecommendationResponse> {
return apiGet<RecommendationResponse>(
`/users/${userId}/recommended-events`,
params as Record<string, number>,
)
}
-3
View File
@@ -62,9 +62,6 @@ body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
+113 -74
View File
@@ -1,59 +1,84 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;600;700&display=swap');
/* =========================================
1. 现代 SaaS 风格主题变量
1. 现代 SaaS 风格高级主题变量
========================================= */
:root {
/* 明亮模式 - 极简白与浅灰 */
--bg-base: #f8fafc;
--bg-surface: #ffffff;
--bg-input: #f1f5f9;
/* 明亮模式 - 高级极简白与冷灰,去除了单调的死白 */
--bg-base: #f4f6f8;
--bg-surface: rgba(255, 255, 255, 0.85); /* 半透明表面,为毛玻璃效果打基础 */
--bg-input: #ffffff;
--bg-hover: rgba(0, 0, 0, 0.04);
--border-subtle: #e2e8f0;
--border-strong: #cbd5e1;
--border-subtle: rgba(0, 0, 0, 0.08);
--border-strong: rgba(0, 0, 0, 0.15);
--text-primary: #0f172a;
--text-secondary: #64748b;
--text-placeholder: #94a3b8;
--text-primary: #111827;
--text-secondary: #4b5563;
--text-placeholder: #9ca3af;
--brand-primary: #4f46e5;
--brand-primary-hover: #4338ca;
--brand-primary-alpha: rgba(79, 70, 229, 0.1);
/* 品牌色优化:更具高级感的靛蓝色 */
--brand-primary: #4338ca;
--brand-primary-hover: #3730a3;
--brand-primary-alpha: rgba(67, 56, 202, 0.08);
--status-error: #ef4444;
--status-success: #10b981;
/* 现代柔和散阴影 */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.05), 0 8px 10px -6px rgba(0, 0, 0, 0.01);
/* 现代柔和长弥散阴影Apple 风格) */
--shadow-sm: 0 1px 3px rgba(0,0,0,0.05), 0 1px 2px rgba(0,0,0,0.03);
--shadow-md: 0 4px 12px rgba(0,0,0,0.04), 0 2px 4px rgba(0,0,0,0.02);
--shadow-xl: 0 20px 40px rgba(0,0,0,0.08), 0 8px 16px rgba(0,0,0,0.04);
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 20px;
--backdrop-blur: blur(12px);
}
html.dark {
/* 暗黑模式 - 深邃黑与暗石板色 */
--bg-base: #020617;
--bg-surface: #0f172a;
--bg-input: #1e293b;
/* 暗黑模式 - 纯粹的深邃黑与极光蓝 */
--bg-base: #09090b;
--bg-surface: rgba(24, 24, 27, 0.7);
--bg-input: rgba(255, 255, 255, 0.05);
--bg-hover: rgba(255, 255, 255, 0.1);
--border-subtle: #1e293b;
--border-strong: #334155;
--border-subtle: rgba(255, 255, 255, 0.1);
--border-strong: rgba(255, 255, 255, 0.2);
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--text-placeholder: #475569;
--text-primary: #f9fafb;
--text-secondary: #a1a1aa;
--text-placeholder: #52525b;
--brand-primary: #6366f1;
--brand-primary-hover: #818cf8;
--brand-primary-alpha: rgba(99, 102, 241, 0.15);
--brand-primary: #818cf8;
--brand-primary-hover: #a5b4fc;
--brand-primary-alpha: rgba(129, 140, 248, 0.15);
--status-error: #f87171;
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.2);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.3);
/* 深色模式需要更强的光效阴影 */
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5), 0 2px 4px rgba(0, 0, 0, 0.3);
--shadow-xl: 0 20px 40px rgba(0, 0, 0, 0.6), 0 8px 16px rgba(0, 0, 0, 0.4);
}
/* =========================================
滚动条美化
========================================= */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-placeholder);
}
/* =========================================
@@ -67,12 +92,13 @@ html, body {
margin: 0;
padding: 0;
min-height: 100vh;
font-family: 'Inter', 'Noto Sans SC', sans-serif;
/* 优化字体渲染,让文字显得更纤细高级 */
font-family: 'Inter', 'Noto Sans SC', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background-color: var(--bg-base);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition: background-color 0.3s ease, color 0.3s ease;
letter-spacing: 0.01em;
}
#app {
@@ -92,23 +118,23 @@ button {
border: none;
background: none;
font-family: inherit;
outline: none;
}
/* =========================================
高级背景环境光与数据网格
高级背景环境光与数据网格 (极简唯美风)
========================================= */
body::before {
content: '';
position: fixed;
inset: 0;
z-index: -2;
/* 绘制点阵网格 */
/* 高级细腻的点阵网格 */
background-image: radial-gradient(var(--border-strong) 1px, transparent 1px);
background-size: 24px 24px;
/* 使用遮罩让网格在四周自然淡出,避免边缘生硬 */
mask-image: radial-gradient(ellipse 80% 80% at 50% -20%, black 20%, transparent 80%);
-webkit-mask-image: radial-gradient(ellipse 80% 80% at 50% -20%, black 20%, transparent 80%);
opacity: 0.4;
background-size: 28px 28px;
mask-image: radial-gradient(ellipse 80% 80% at 50% -10%, black 10%, transparent 80%);
-webkit-mask-image: radial-gradient(ellipse 80% 80% at 50% -10%, black 10%, transparent 80%);
opacity: 0.3;
pointer-events: none;
}
@@ -116,46 +142,47 @@ html.dark body::before {
opacity: 0.15;
}
/* 极弱的雷达扫射呼吸环境光 */
/* 更为克制的极光呼吸环境光 */
body::after {
content: '';
position: fixed;
top: -50%;
left: -20%;
right: -20%;
height: 100vh;
top: -60%;
left: -30%;
right: -30%;
height: 120vh;
z-index: -3;
background: radial-gradient(ellipse at bottom, var(--brand-primary-alpha) 0%, transparent 60%);
opacity: 0.6;
background: radial-gradient(ellipse at bottom, var(--brand-primary-alpha) 0%, transparent 50%);
opacity: 0.5;
pointer-events: none;
animation: ambient-pulse 8s ease-in-out infinite alternate;
animation: ambient-pulse 10s ease-in-out infinite alternate;
}
@keyframes ambient-pulse {
0% {
transform: scale(1);
opacity: 0.4;
transform: scale(1) translateY(0);
opacity: 0.3;
}
100% {
transform: scale(1.05);
opacity: 0.7;
transform: scale(1.05) translateY(-2%);
opacity: 0.6;
}
}
/* =========================================
3. 现代表单控件体系
3. 现代表单控件体系 (磨砂与无边框风格)
========================================= */
.input-group {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 20px;
gap: 10px;
margin-bottom: 22px;
}
.input-label {
font-size: 14px;
font-weight: 500;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
letter-spacing: 0.03em;
}
.input-wrapper {
@@ -166,17 +193,19 @@ body::after {
.input-field {
width: 100%;
padding: 12px 14px;
padding: 14px 16px;
background-color: var(--bg-input);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 15px;
transition: all 0.2s ease;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: inset 0 2px 4px rgba(0,0,0,0.02);
}
.input-field::placeholder {
color: var(--text-placeholder);
font-weight: 400;
}
.input-field:hover {
@@ -186,7 +215,7 @@ body::after {
.input-field:focus {
outline: none;
border-color: var(--brand-primary);
box-shadow: 0 0 0 3px var(--brand-primary-alpha);
box-shadow: 0 0 0 4px var(--brand-primary-alpha), inset 0 1px 2px rgba(0,0,0,0.02);
background-color: var(--bg-surface);
}
@@ -194,11 +223,11 @@ body::after {
position: absolute;
right: 12px;
font-size: 13px;
font-weight: 500;
font-weight: 600;
color: var(--brand-primary);
padding: 4px 8px;
border-radius: 4px;
transition: background 0.2s;
padding: 6px 10px;
border-radius: var(--radius-sm);
transition: all 0.2s ease;
}
.input-action-btn:hover:not(:disabled) {
@@ -210,31 +239,41 @@ body::after {
cursor: not-allowed;
}
/* 主按钮的高级拟物渐变效果 */
.btn-primary {
width: 100%;
padding: 12px;
background-color: var(--brand-primary);
padding: 14px;
background: linear-gradient(135deg, var(--brand-primary) 0%, var(--brand-primary-hover) 100%);
color: #ffffff;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.02em;
border-radius: var(--radius-md);
transition: all 0.2s ease;
border: 1px solid rgba(255,255,255,0.1);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
justify-content: center;
align-items: center;
box-shadow: 0 4px 12px var(--brand-primary-alpha);
text-shadow: 0 1px 2px rgba(0,0,0,0.2);
}
.btn-primary:hover:not(:disabled) {
background-color: var(--brand-primary-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
box-shadow: 0 8px 20px var(--brand-primary-alpha);
filter: brightness(1.1);
}
.btn-primary:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 2px 8px var(--brand-primary-alpha);
}
.btn-primary:disabled {
opacity: 0.6;
background: var(--bg-input);
color: var(--text-placeholder);
border-color: var(--border-subtle);
box-shadow: none;
text-shadow: none;
cursor: not-allowed;
}
+25 -1
View File
@@ -25,6 +25,20 @@ function localizeMessage(message: string): string {
return MESSAGE_MAP[message] ?? message
}
function localizeDetail(detail: string): string {
const direct = localizeMessage(detail)
if (direct !== detail) {
return direct
}
const cooldownMatch = detail.match(/^Please wait (\d+)s before requesting another verification code$/)
if (cooldownMatch) {
return `操作过于频繁,请 ${cooldownMatch[1]} 秒后再试`
}
return detail
}
async function request<T>(path: string, payload: JsonValue): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
method: 'POST',
@@ -47,7 +61,17 @@ async function request<T>(path: string, payload: JsonValue): Promise<T> {
if (!response.ok) {
const detail = data.detail
if (typeof detail === 'string') {
throw new Error(localizeMessage(detail))
const error = new Error(localizeDetail(detail)) as Error & { retryAfter?: number }
if (response.status === 429) {
const retryAfterHeader = response.headers.get('Retry-After')
if (retryAfterHeader) {
const retryAfter = Number.parseInt(retryAfterHeader, 10)
if (Number.isFinite(retryAfter) && retryAfter > 0) {
error.retryAfter = retryAfter
}
}
}
throw error
}
throw new Error('请求失败,请稍后重试')
}
+108 -351
View File
@@ -1,392 +1,149 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useThemeStore } from '@/stores/theme'
const themeStore = useThemeStore()
const isAnimating = ref(false)
/**
* 切换主题,使用 View Transitions API 实现高级扩散动画(如果浏览器支持)
* 这种动画比之前像玩具一样的开关要高级得多,提供原生级的丝滑过渡
*/
function handleToggle(event: MouseEvent) {
const root = document.documentElement
root.style.setProperty('--theme-flash-x', `${event.clientX}px`)
root.style.setProperty('--theme-flash-y', `${event.clientY}px`)
// 检查浏览器是否支持 document.startViewTransition 并且用户没有开启减弱动画
const isAppearanceTransition = typeof document !== 'undefined' &&
'startViewTransition' in document &&
!window.matchMedia('(prefers-reduced-motion: reduce)').matches
isAnimating.value = true
themeStore.toggleTheme()
if (!isAppearanceTransition) {
// 降级处理:直接切换
themeStore.toggleTheme()
return
}
window.setTimeout(() => {
isAnimating.value = false
}, 520)
const x = event.clientX
const y = event.clientY
const endRadius = Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y)
)
// @ts-ignore: TypeScript 类型可能较旧,忽略 startViewTransition 报错
const transition = document.startViewTransition(() => {
themeStore.toggleTheme()
})
transition.ready.then(() => {
const clipPath = [
`circle(0px at ${x}px ${y}px)`,
`circle(${endRadius}px at ${x}px ${y}px)`
]
document.documentElement.animate(
{
clipPath: clipPath,
},
{
duration: 500,
easing: 'ease-in-out',
pseudoElement: '::view-transition-new(root)',
}
)
})
}
</script>
<template>
<button
class="theme-toggle"
:class="{ 'is-dark': themeStore.isDark, 'is-animating': isAnimating }"
type="button"
class="theme-toggle-btn"
:aria-label="themeStore.isDark ? '切换到浅色模式' : '切换到暗黑模式'"
title="切换显示模式"
@click="handleToggle"
>
<span class="toggle-track">
<span class="track-glow"></span>
<span class="track-stars"></span>
<span class="spark-layer">
<span class="spark"></span>
<span class="spark"></span>
<span class="spark"></span>
</span>
<span class="toggle-thumb">
<svg class="icon icon-sun" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M12 5.25a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0V6a.75.75 0 0 1 .75-.75ZM7.227 7.227a.75.75 0 0 1 1.06 0l1.06 1.06a.75.75 0 1 1-1.06 1.06l-1.06-1.06a.75.75 0 0 1 0-1.06Zm9.426 0a.75.75 0 0 1 1.06 1.06l-1.06 1.06a.75.75 0 1 1-1.06-1.06l1.06-1.06ZM12 9a3 3 0 1 1 0 6 3 3 0 0 1 0-6Zm-6.75 3a.75.75 0 0 1 .75-.75h1.5a.75.75 0 0 1 0 1.5H6a.75.75 0 0 1-.75-.75Zm11.25 0a.75.75 0 0 1 .75-.75h1.5a.75.75 0 0 1 0 1.5h-1.5a.75.75 0 0 1-.75-.75Zm-8.213 3.653a.75.75 0 0 1 1.06 0l1.06 1.06a.75.75 0 0 1-1.06 1.06l-1.06-1.06a.75.75 0 0 1 0-1.06Zm7.426 0a.75.75 0 0 1 1.06 1.06l-1.06 1.06a.75.75 0 0 1-1.06-1.06l1.06-1.06ZM12 16.5a.75.75 0 0 1 .75.75v1.5a.75.75 0 1 1-1.5 0v-1.5a.75.75 0 0 1 .75-.75Z"
/>
</svg>
<svg class="icon icon-moon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M14.5 2.25a.75.75 0 0 1 .604 1.195 7.95 7.95 0 0 0 4.6 12.395.75.75 0 0 1 .194 1.387 10.5 10.5 0 1 1-6.592-15.052.75.75 0 0 1 .194 1.387 8.954 8.954 0 0 0-1.75 16.693 9.002 9.002 0 0 0 6.074-2.04 9.45 9.45 0 0 1-4.822-8.253 9.44 9.44 0 0 1 1.305-4.82.75.75 0 0 1 .194-1.202Z"
/>
</svg>
</span>
</span>
<span class="toggle-text">{{ themeStore.isDark ? '浅色模式' : '暗黑模式' }}</span>
<div class="icon-container">
<i class="fa-solid fa-sun sun-icon" :class="{ 'is-hidden': themeStore.isDark }"></i>
<i class="fa-solid fa-moon moon-icon" :class="{ 'is-hidden': !themeStore.isDark }"></i>
</div>
</button>
</template>
<style scoped>
.theme-toggle {
border: 1px solid var(--surface-border);
background: var(--surface);
color: var(--text);
border-radius: 14px;
padding: 6px 10px 6px 8px;
display: inline-flex;
/* ==========================================
极简且高级的毛玻璃材质主题切换按钮
========================================== */
.theme-toggle-btn {
position: relative;
display: flex;
align-items: center;
gap: 10px;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 50%;
background: var(--bg-surface);
/* 使用轻微透明度与模糊实现毛玻璃质感 */
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid var(--border-subtle);
color: var(--text-secondary);
cursor: pointer;
position: relative;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: var(--shadow-sm);
overflow: hidden;
transition: transform 220ms ease, border-color 220ms ease, box-shadow 220ms ease;
outline: none;
}
.theme-toggle:hover {
transform: translateY(-1px);
border-color: var(--primary);
box-shadow: 0 8px 24px color-mix(in srgb, var(--primary) 24%, transparent);
.theme-toggle-btn:hover {
color: var(--text-primary);
border-color: var(--border-strong);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.theme-toggle:active {
transform: translateY(0) scale(0.98);
.theme-toggle-btn:active {
transform: translateY(0);
box-shadow: var(--shadow-sm);
}
.toggle-track {
width: 58px;
height: 32px;
border-radius: 999px;
background: linear-gradient(120deg, #d4e4ff, #b2c6ff);
border: 1px solid rgba(255, 255, 255, 0.45);
padding: 3px;
.icon-container {
position: relative;
overflow: hidden;
transition: background 360ms ease;
}
.theme-toggle.is-dark .toggle-track {
background: linear-gradient(120deg, #0f1731, #1f2e62);
}
.track-glow {
position: absolute;
width: 20px;
height: 20px;
border-radius: 999px;
left: 8px;
top: 5px;
background: rgba(255, 244, 170, 0.75);
filter: blur(2px);
opacity: 0.85;
transition: left 360ms cubic-bezier(0.22, 1, 0.36, 1), opacity 300ms ease, background 300ms ease;
}
.theme-toggle.is-dark .track-glow {
left: 30px;
opacity: 0.4;
background: rgba(166, 195, 255, 0.7);
}
.track-stars {
/* 图标动画:旋转加缩放的平滑过渡 */
.sun-icon, .moon-icon {
position: absolute;
inset: 0;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 18px;
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 隐藏状态:优雅地旋出 */
.sun-icon.is-hidden {
opacity: 0;
transition: opacity 260ms ease;
transform: translate(-50%, -50%) rotate(90deg) scale(0.5);
}
.track-stars::before,
.track-stars::after {
content: '';
position: absolute;
width: 3px;
height: 3px;
border-radius: 999px;
background: rgba(226, 235, 255, 0.95);
}
.track-stars::before {
left: 16px;
top: 9px;
box-shadow: 16px 6px 0 rgba(226, 235, 255, 0.75), 22px -2px 0 rgba(226, 235, 255, 0.55);
}
.track-stars::after {
left: 28px;
top: 18px;
box-shadow: -12px 5px 0 rgba(226, 235, 255, 0.65), 8px -8px 0 rgba(226, 235, 255, 0.75);
}
.theme-toggle.is-dark .track-stars {
opacity: 1;
}
.spark-layer {
position: absolute;
inset: 0;
pointer-events: none;
}
.spark {
position: absolute;
width: 4px;
height: 4px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.88);
.moon-icon.is-hidden {
opacity: 0;
}
.spark:nth-child(1) {
left: 22px;
top: 14px;
}
.spark:nth-child(2) {
left: 30px;
top: 11px;
}
.spark:nth-child(3) {
left: 26px;
top: 19px;
}
.theme-toggle.is-animating .spark:nth-child(1) {
animation: spark-burst-1 480ms ease-out;
}
.theme-toggle.is-animating .spark:nth-child(2) {
animation: spark-burst-2 480ms ease-out;
}
.theme-toggle.is-animating .spark:nth-child(3) {
animation: spark-burst-3 480ms ease-out;
}
.toggle-thumb {
width: 24px;
height: 24px;
border-radius: 999px;
background: linear-gradient(145deg, #ffffff, #f0f4ff);
box-shadow: 0 3px 10px rgba(37, 49, 89, 0.26);
position: relative;
transform: translateX(0);
transition: transform 360ms cubic-bezier(0.22, 1, 0.36, 1), background 320ms ease;
overflow: hidden;
}
.theme-toggle.is-dark .toggle-thumb {
transform: translateX(26px);
background: linear-gradient(145deg, #d5e0ff, #b7c9ff);
}
.icon {
position: absolute;
inset: 5px;
width: 14px;
height: 14px;
fill: #526ab0;
transition: opacity 240ms ease, transform 360ms cubic-bezier(0.22, 1, 0.36, 1);
}
.icon-sun {
opacity: 1;
transform: rotate(0deg) scale(1);
}
.icon-moon {
opacity: 0;
transform: rotate(-40deg) scale(0.45);
}
.theme-toggle.is-dark .icon-sun {
opacity: 0;
transform: rotate(70deg) scale(0.4);
}
.theme-toggle.is-dark .icon-moon {
opacity: 1;
transform: rotate(0deg) scale(1);
}
.toggle-text {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.02em;
color: var(--text);
}
.theme-toggle::after {
content: '';
position: absolute;
width: 14px;
height: 14px;
border-radius: 999px;
background: color-mix(in srgb, var(--primary) 36%, transparent);
left: 34px;
top: 14px;
transform: scale(0);
opacity: 0;
pointer-events: none;
}
.theme-toggle.is-animating::after {
animation: click-ripple 520ms ease-out;
}
.theme-toggle.is-animating .toggle-thumb {
animation: thumb-pop 520ms cubic-bezier(0.22, 1, 0.36, 1);
}
.theme-toggle.is-dark.is-animating .toggle-thumb {
animation-name: thumb-pop-dark;
}
.theme-toggle.is-animating .toggle-track {
animation: track-flare 520ms ease;
}
@keyframes click-ripple {
0% {
transform: scale(0.2);
opacity: 0.5;
}
100% {
transform: scale(4.2);
opacity: 0;
}
}
@keyframes thumb-pop {
0% {
transform: translateX(0) scale(1);
}
40% {
transform: translateX(13px) scale(1.1);
}
100% {
transform: translateX(26px) scale(1);
}
}
@keyframes thumb-pop-dark {
0% {
transform: translateX(26px) scale(1);
}
40% {
transform: translateX(13px) scale(1.1);
}
100% {
transform: translateX(0) scale(1);
}
}
@keyframes track-flare {
0% {
box-shadow: 0 0 0 rgba(255, 255, 255, 0);
}
45% {
box-shadow: 0 0 16px rgba(255, 255, 255, 0.45);
}
100% {
box-shadow: 0 0 0 rgba(255, 255, 255, 0);
}
}
@keyframes spark-burst-1 {
0% {
transform: translate(0, 0) scale(0.3);
opacity: 0.9;
}
100% {
transform: translate(-12px, -8px) scale(1.1);
opacity: 0;
}
}
@keyframes spark-burst-2 {
0% {
transform: translate(0, 0) scale(0.3);
opacity: 0.9;
}
100% {
transform: translate(10px, -10px) scale(1.15);
opacity: 0;
}
}
@keyframes spark-burst-3 {
0% {
transform: translate(0, 0) scale(0.3);
opacity: 0.9;
}
100% {
transform: translate(2px, 12px) scale(1.2);
opacity: 0;
}
}
@media (max-width: 620px) {
.toggle-text {
display: none;
}
.theme-toggle {
padding-right: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.theme-toggle,
.toggle-track,
.toggle-thumb,
.track-glow,
.icon {
transition: none;
}
.theme-toggle.is-animating::after,
.theme-toggle.is-animating .toggle-thumb,
.theme-toggle.is-dark.is-animating .toggle-thumb,
.theme-toggle.is-animating .toggle-track,
.theme-toggle.is-animating .spark {
animation: none;
}
transform: translate(-50%, -50%) rotate(-90deg) scale(0.5);
}
</style>
<style>
/* ==========================================
全局 View Transitions API 动画样式
控制页面级别的黑白模式无缝扩散切换
========================================== */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root) {
z-index: 1;
}
::view-transition-new(root) {
z-index: 9999;
}
</style>
+366
View File
@@ -0,0 +1,366 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import BrandLogo from '@/components/BrandLogo.vue'
import ThemeToggle from '@/components/ThemeToggle.vue'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const route = useRoute()
const router = useRouter()
const sidebarOpen = ref(false)
const displayName = computed(() => authStore.user?.nickname || authStore.user?.email?.split('@')[0] || '用户')
const avatarUrl = computed(
() =>
authStore.user?.avatar_url ||
`https://ui-avatars.com/api/?name=${encodeURIComponent(displayName.value)}&background=6366f1&color=fff`,
)
const navItems = [
{ name: '全局热点池', icon: 'fa-solid fa-fire', route: '/' },
{ 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' },
]
function isActive(path: string) {
return route.path === path
}
async function handleLogout() {
authStore.logout()
await router.replace('/login')
}
function toggleSidebar() {
sidebarOpen.value = !sidebarOpen.value
}
</script>
<template>
<div class="dashboard-shell">
<!-- 移动端侧边栏遮罩 -->
<div v-if="sidebarOpen" class="sidebar-overlay" @click="sidebarOpen = false"></div>
<!-- 侧边栏 -->
<aside class="sidebar" :class="{ open: sidebarOpen }">
<div class="sidebar-inner">
<!-- Logo -->
<div class="sidebar-logo">
<BrandLogo />
<span class="logo-text">InsightRadar<span class="logo-dot">.AI</span></span>
</div>
<!-- 导航菜单 -->
<nav class="sidebar-nav">
<RouterLink
v-for="item in navItems"
:key="item.route"
:to="item.route"
class="nav-item"
:class="{ active: isActive(item.route) }"
@click="sidebarOpen = false"
>
<i :class="item.icon" class="nav-icon"></i>
<span>{{ item.name }}</span>
</RouterLink>
</nav>
</div>
<!-- 用户信息 -->
<div class="sidebar-user">
<img :src="avatarUrl" class="user-avatar" alt="头像" />
<div class="user-info">
<p class="user-name">{{ displayName }}</p>
<p class="user-status">
<span class="status-dot"></span>
已登录
</p>
</div>
<button class="logout-btn" title="退出登录" @click="handleLogout">
<i class="fa-solid fa-right-from-bracket"></i>
</button>
</div>
</aside>
<!-- 主内容区 -->
<main class="main-area">
<!-- 顶部通栏 -->
<header class="top-header">
<button class="menu-toggle" @click="toggleSidebar">
<i class="fa-solid fa-bars"></i>
</button>
<div class="header-right">
<ThemeToggle />
</div>
</header>
<!-- 页面内容插槽 -->
<div class="page-content">
<RouterView v-slot="{ Component }">
<transition name="page-fade" mode="out-in">
<component :is="Component" />
</transition>
</RouterView>
</div>
</main>
</div>
</template>
<style scoped>
.dashboard-shell {
display: flex;
height: 100vh;
overflow: hidden;
}
/* ==========================================
侧边栏
========================================== */
.sidebar {
width: 260px;
min-width: 260px;
/* 增加侧边栏的毛玻璃高级感 */
background: var(--bg-surface);
backdrop-filter: var(--backdrop-blur);
-webkit-backdrop-filter: var(--backdrop-blur);
border-right: 1px solid var(--border-subtle);
display: flex;
flex-direction: column;
justify-content: space-between;
z-index: 40;
transition: transform 0.3s ease;
}
.sidebar-inner {
flex: 1;
overflow-y: auto;
}
.sidebar-logo {
height: 64px;
display: flex;
align-items: center;
padding: 0 20px;
gap: 12px;
border-bottom: 1px solid var(--border-subtle);
}
.logo-text {
font-size: 20px;
font-weight: 700;
letter-spacing: 0.02em;
}
.logo-dot {
color: var(--brand-primary);
}
/* 导航 */
.sidebar-nav {
padding: 16px 12px;
display: flex;
flex-direction: column;
gap: 4px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
text-decoration: none;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.nav-item:hover {
color: var(--text-primary);
background: var(--bg-hover);
transform: translateX(4px);
}
.nav-item.active {
color: var(--brand-primary);
background: var(--brand-primary-alpha);
border-left: 3px solid var(--brand-primary);
padding-left: 13px; /* 减去 border 的 3px 保持布局不跳动 */
font-weight: 600;
}
.nav-icon {
width: 18px;
text-align: center;
font-size: 15px;
}
/* 用户区 */
.sidebar-user {
padding: 16px 20px;
border-top: 1px solid var(--border-subtle);
display: flex;
align-items: center;
gap: 12px;
}
.user-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
flex-shrink: 0;
}
.user-info {
flex: 1;
min-width: 0;
}
.user-name {
font-size: 13px;
font-weight: 600;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-status {
font-size: 11px;
color: var(--status-success);
margin: 2px 0 0;
display: flex;
align-items: center;
gap: 4px;
}
.status-dot {
width: 6px;
height: 6px;
background: var(--status-success);
border-radius: 50%;
display: inline-block;
animation: pulse-dot 2s infinite;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.logout-btn {
color: var(--text-secondary);
padding: 8px;
border-radius: var(--radius-md);
font-size: 14px;
transition: all 0.2s;
}
.logout-btn:hover {
color: var(--status-error);
background: rgba(239, 68, 68, 0.1);
}
/* ==========================================
主内容区
========================================== */
.main-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.top-header {
height: 60px;
min-height: 60px;
/* 顶部导航毛玻璃 */
background: var(--bg-surface);
backdrop-filter: var(--backdrop-blur);
-webkit-backdrop-filter: var(--backdrop-blur);
border-bottom: 1px solid var(--border-subtle);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
z-index: 10;
}
.menu-toggle {
display: none;
font-size: 18px;
color: var(--text-secondary);
padding: 8px;
border-radius: var(--radius-md);
}
.menu-toggle:hover {
background: var(--bg-input);
color: var(--text-primary);
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
margin-left: auto;
}
.page-content {
flex: 1;
overflow-y: auto;
padding: 24px;
}
/* ==========================================
移动端适配
========================================== */
.sidebar-overlay {
display: none;
}
@media (max-width: 768px) {
.sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
transform: translateX(-100%);
}
.sidebar.open {
transform: translateX(0);
}
.sidebar-overlay {
display: block;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 30;
}
.menu-toggle {
display: block;
}
.page-content {
padding: 16px;
}
}
/* 页面过渡动画 */
.page-fade-enter-active,
.page-fade-leave-active {
transition: opacity 0.2s ease;
}
.page-fade-enter-from,
.page-fade-leave-to {
opacity: 0;
}
</style>
+35 -22
View File
@@ -2,36 +2,54 @@ import { createRouter, createWebHistory } from 'vue-router'
import { pinia } from '@/stores'
import { useAuthStore } from '@/stores/auth'
import HomeView from '@/views/HomeView.vue'
import DashboardLayout from '@/layouts/DashboardLayout.vue'
import LoginView from '@/views/LoginView.vue'
import RegisterView from '@/views/RegisterView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
meta: {
requiresAuth: true,
},
},
// 认证页面(不使用仪表盘布局)
{
path: '/login',
name: 'login',
component: LoginView,
meta: {
guestOnly: true,
},
meta: { guestOnly: true },
},
{
path: '/register',
name: 'register',
component: RegisterView,
meta: {
guestOnly: true,
},
meta: { guestOnly: true },
},
// 仪表盘内部页面(使用统一侧边栏布局)
{
path: '/',
component: DashboardLayout,
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'dashboard',
component: () => import('@/views/DashboardView.vue'),
},
{
path: 'revisions',
name: 'revisions',
component: () => import('@/views/RevisionsView.vue'),
},
{
path: 'topics',
name: 'topics',
component: () => import('@/views/TopicsView.vue'),
},
{
path: 'delivery',
name: 'delivery',
component: () => import('@/views/DeliveryView.vue'),
},
],
},
],
})
@@ -41,16 +59,11 @@ router.beforeEach((to) => {
authStore.restore()
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
return {
name: 'login',
query: {
redirect: to.fullPath,
},
}
return { name: 'login', query: { redirect: to.fullPath } }
}
if (to.meta.guestOnly && authStore.isAuthenticated) {
return { name: 'home' }
return { name: 'dashboard' }
}
return true
+26
View File
@@ -0,0 +1,26 @@
/** 推送时间表 */
export interface DeliverySchedule {
id: number
user_id: number
delivery_time: string
is_active: boolean
created_at: string
}
/** 推送渠道端点 */
export interface PushEndpoint {
id: number
user_id: number
channel_type: string
channel_account: string
is_active: boolean
priority_level: number
created_at: string
updated_at: string
}
/** 用户完整推送配置 */
export interface DeliveryConfig {
schedules: DeliverySchedule[]
endpoints: PushEndpoint[]
}
+48
View File
@@ -0,0 +1,48 @@
/** 各平台热搜子条目 */
export interface PlatformTrend {
source_id: number
platform_name: string
headline: string
url: string | null
current_ranking: number | null
ranking_history: number[]
}
/** AI 统一大事件 */
export interface UnifiedEvent {
event_id: number
unified_title: string
summary: string | null
hot_score: number
created_at: string
platforms: PlatformTrend[]
tags: string[]
}
/** 分页包装 */
export interface PaginatedEvents {
total: number
has_more: boolean
data: UnifiedEvent[]
}
/** 标题修改记录 */
export interface HeadlineRevision {
id: number
event_id: number
previous_headline: string
revised_headline: string
source_name: string | null
platform_icon: string | null
created_at: string
}
/** 系统运行状态 */
export interface SystemStats {
active_sources: number
total_sources: number
items_today: number
success_tasks_today: number
error_tasks_today: number
last_sync_at: string | null
}
+34
View File
@@ -0,0 +1,34 @@
/** 用户兴趣关键词 */
export interface UserTopicPreference {
id: number
user_id: number
interested_keyword: string
created_at: string
}
/** 语义命中详情 */
export interface SemanticHit {
preference_keyword: string
topic_keyword: string
similarity: number
}
/** 推荐事件 */
export interface MatchedEvent {
event_id: number
unified_title: string
summary: string | null
hot_score: number
created_at: string
tags: string[]
match_score: number
exact_hits: string[]
semantic_hits: SemanticHit[]
}
/** 推荐列表响应 */
export interface RecommendationResponse {
user_id: number
total: number
data: MatchedEvent[]
}
File diff suppressed because it is too large Load Diff
+720
View File
@@ -0,0 +1,720 @@
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import {
fetchDeliveryConfig,
createDeliverySchedule,
updateDeliverySchedule,
deleteDeliverySchedule,
createPushEndpoint,
updatePushEndpoint,
deletePushEndpoint,
} from '@/api/delivery'
import type { DeliverySchedule, PushEndpoint } from '@/types/delivery'
const authStore = useAuthStore()
const userId = computed(() => authStore.user?.id ?? 0)
const schedules = ref<DeliverySchedule[]>([])
const endpoints = ref<PushEndpoint[]>([])
const loading = ref(true)
const error = ref('')
const successMsg = ref('')
// 新增时间表单
const newTime = ref('08:30')
// 新增渠道表单
const newChannelType = ref('EMAIL')
const newChannelAccount = ref('')
const submittingSchedule = ref(false)
const submittingEndpoint = ref(false)
function showSuccess(msg: string) {
successMsg.value = msg
setTimeout(() => { successMsg.value = '' }, 3000)
}
function getChannelLabel(_type: string): string {
return '邮箱'
}
function getChannelIcon(_type: string): string {
return 'fa-solid fa-envelope'
}
/** 加载推送配置 */
async function loadConfig() {
if (!userId.value) return
loading.value = true
error.value = ''
try {
const config = await fetchDeliveryConfig(userId.value)
schedules.value = config.schedules
endpoints.value = config.endpoints
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
// ==========================================
// 推送时间表操作
// ==========================================
async function handleAddSchedule() {
if (!userId.value || !newTime.value) return
submittingSchedule.value = true
error.value = ''
try {
const created = await createDeliverySchedule(userId.value, {
delivery_time: newTime.value,
is_active: true,
})
schedules.value.push(created)
schedules.value.sort((a, b) => a.delivery_time.localeCompare(b.delivery_time))
showSuccess(`已添加推送时间 ${newTime.value}`)
} catch (e) {
error.value = e instanceof Error ? e.message : '添加失败'
} finally {
submittingSchedule.value = false
}
}
async function handleToggleSchedule(schedule: DeliverySchedule) {
if (!userId.value) return
error.value = ''
try {
const updated = await updateDeliverySchedule(userId.value, schedule.id, {
is_active: !schedule.is_active,
})
const idx = schedules.value.findIndex(s => s.id === schedule.id)
if (idx >= 0) schedules.value[idx] = updated
} catch (e) {
error.value = e instanceof Error ? e.message : '更新失败'
}
}
async function handleDeleteSchedule(schedule: DeliverySchedule) {
if (!userId.value) return
error.value = ''
try {
await deleteDeliverySchedule(userId.value, schedule.id)
schedules.value = schedules.value.filter(s => s.id !== schedule.id)
} catch (e) {
error.value = e instanceof Error ? e.message : '删除失败'
}
}
// ==========================================
// 推送渠道操作
// ==========================================
async function handleAddEndpoint() {
if (!userId.value || !newChannelAccount.value.trim()) return
submittingEndpoint.value = true
error.value = ''
try {
const created = await createPushEndpoint(userId.value, {
channel_type: newChannelType.value,
channel_account: newChannelAccount.value.trim(),
})
endpoints.value.push(created)
newChannelAccount.value = ''
showSuccess(`已添加${getChannelLabel(newChannelType.value)}推送渠道`)
} catch (e) {
error.value = e instanceof Error ? e.message : '添加失败'
} finally {
submittingEndpoint.value = false
}
}
async function handleToggleEndpoint(endpoint: PushEndpoint) {
if (!userId.value) return
error.value = ''
try {
const updated = await updatePushEndpoint(userId.value, endpoint.id, {
is_active: !endpoint.is_active,
})
const idx = endpoints.value.findIndex(ep => ep.id === endpoint.id)
if (idx >= 0) endpoints.value[idx] = updated
} catch (e) {
error.value = e instanceof Error ? e.message : '更新失败'
}
}
async function handleDeleteEndpoint(endpoint: PushEndpoint) {
if (!userId.value) return
error.value = ''
try {
await deletePushEndpoint(userId.value, endpoint.id)
endpoints.value = endpoints.value.filter(ep => ep.id !== endpoint.id)
} catch (e) {
error.value = e instanceof Error ? e.message : '删除失败'
}
}
onMounted(loadConfig)
</script>
<template>
<div class="delivery-page">
<div class="page-header">
<h1>
<i class="fa-solid fa-paper-plane" style="color: var(--brand-primary)"></i>
AI 简报设置
</h1>
<p class="page-desc">
配置你的专属 AI 简报推送设定推送时间和接收渠道后
系统会在指定时间将匹配到的热点事件整理成简报发送给你
</p>
</div>
<!-- 全局消息 -->
<p v-if="successMsg" class="global-msg success-msg">
<i class="fa-solid fa-check-circle"></i> {{ successMsg }}
</p>
<p v-if="error" class="global-msg error-msg">
<i class="fa-solid fa-circle-exclamation"></i> {{ error }}
</p>
<div v-if="loading" class="loading-state">
<i class="fa-solid fa-spinner fa-spin"></i>
<span>加载配置中...</span>
</div>
<div v-else class="config-sections">
<!-- ==========================================
推送时间管理
========================================== -->
<section class="config-section">
<div class="section-title">
<h2><i class="fa-regular fa-clock"></i> 推送时间</h2>
<p>设定每天希望收到 AI 简报的时间点可设多个相邻时间至少间隔 30 分钟</p>
</div>
<!-- 添加时间 -->
<div class="add-row">
<input v-model="newTime" type="time" class="time-input" />
<button class="add-btn" :disabled="submittingSchedule" @click="handleAddSchedule">
<i class="fa-solid fa-plus"></i>
{{ submittingSchedule ? '添加中...' : '添加时间' }}
</button>
</div>
<!-- 时间列表 -->
<div v-if="schedules.length === 0" class="empty-hint">
<p>还没有设置推送时间</p>
</div>
<div v-else class="schedule-list">
<div v-for="s in schedules" :key="s.id" class="schedule-card" :class="{ disabled: !s.is_active }">
<div class="schedule-info">
<span class="schedule-time">{{ s.delivery_time }}</span>
<span class="schedule-status" :class="s.is_active ? 'active' : 'paused'">
{{ s.is_active ? '已启用' : '已暂停' }}
</span>
</div>
<div class="schedule-actions">
<button class="toggle-btn" :title="s.is_active ? '暂停' : '启用'" @click="handleToggleSchedule(s)">
<i :class="s.is_active ? 'fa-solid fa-pause' : 'fa-solid fa-play'"></i>
</button>
<button class="del-btn" title="删除" @click="handleDeleteSchedule(s)">
<i class="fa-solid fa-trash-can"></i>
</button>
</div>
</div>
</div>
</section>
<!-- ==========================================
推送渠道管理
========================================== -->
<section class="config-section">
<div class="section-title">
<h2><i class="fa-solid fa-envelope"></i> 接收邮箱</h2>
<p>填写接收简报的邮箱地址可添加多个备用邮箱系统按优先级依次尝试</p>
</div>
<!-- 添加邮箱 -->
<div class="add-row endpoint-add">
<input
v-model="newChannelAccount"
type="email"
class="channel-input"
placeholder="输入接收邮箱地址"
/>
<button
class="add-btn"
:disabled="submittingEndpoint || !newChannelAccount.trim()"
@click="handleAddEndpoint"
>
<i class="fa-solid fa-plus"></i>
{{ submittingEndpoint ? '添加中...' : '添加' }}
</button>
</div>
<!-- 渠道列表 -->
<div v-if="endpoints.length === 0" class="empty-hint">
<p>还没有配置推送渠道</p>
</div>
<div v-else class="endpoint-list">
<div v-for="ep in endpoints" :key="ep.id" class="endpoint-card" :class="{ disabled: !ep.is_active }">
<div class="endpoint-info">
<div class="endpoint-icon-wrap">
<i :class="getChannelIcon(ep.channel_type)"></i>
</div>
<div class="endpoint-detail">
<p class="endpoint-type">{{ getChannelLabel(ep.channel_type) }}</p>
<p class="endpoint-account">{{ ep.channel_account }}</p>
</div>
</div>
<div class="endpoint-right">
<span class="priority-badge">优先级 {{ ep.priority_level }}</span>
<div class="endpoint-actions">
<button
class="toggle-btn"
:title="ep.is_active ? '暂停' : '启用'"
@click="handleToggleEndpoint(ep)"
>
<i :class="ep.is_active ? 'fa-solid fa-pause' : 'fa-solid fa-play'"></i>
</button>
<button class="del-btn" title="删除" @click="handleDeleteEndpoint(ep)">
<i class="fa-solid fa-trash-can"></i>
</button>
</div>
</div>
</div>
</div>
</section>
<!-- 工作原理说明 -->
<section class="config-section info-section">
<h3><i class="fa-solid fa-gears"></i> 推送工作原理</h3>
<div class="info-grid">
<div class="info-step">
<div class="step-num">1</div>
<p><strong>爬虫抓取</strong>系统定时从各平台抓取热搜数据</p>
</div>
<div class="info-step">
<div class="step-num">2</div>
<p><strong>AI 聚类</strong>通过语义向量将相同事件聚合归并</p>
</div>
<div class="info-step">
<div class="step-num">3</div>
<p><strong>兴趣匹配</strong>将事件标签与您的关键词进行精确/语义匹配</p>
</div>
<div class="info-step">
<div class="step-num">4</div>
<p><strong>定时推送</strong>在设定时间将命中的事件整理成简报推送</p>
</div>
</div>
</section>
</div>
</div>
</template>
<style scoped>
.delivery-page {
max-width: 800px;
margin: 0 auto;
}
.page-header {
margin-bottom: 24px;
}
.page-header h1 {
font-size: 24px;
font-weight: 700;
margin: 0 0 8px;
display: flex;
align-items: center;
gap: 10px;
}
.page-desc {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.6;
margin: 0;
}
.global-msg {
font-size: 13px;
margin: 0 0 16px;
padding: 10px 14px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
gap: 8px;
}
.success-msg {
background: rgba(16, 185, 129, 0.08);
color: var(--status-success);
border: 1px solid rgba(16, 185, 129, 0.2);
}
.error-msg {
background: rgba(239, 68, 68, 0.08);
color: var(--status-error);
border: 1px solid rgba(239, 68, 68, 0.2);
}
.loading-state {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 60px;
color: var(--text-secondary);
}
/* ==========================================
通用区块样式
========================================== */
.config-sections {
display: flex;
flex-direction: column;
gap: 24px;
}
.config-section {
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: 30px;
box-shadow: var(--shadow-sm);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.config-section:hover {
box-shadow: var(--shadow-md);
border-color: rgba(99, 102, 241, 0.2);
}
.section-title {
margin-bottom: 20px;
}
.section-title h2 {
font-size: 17px;
font-weight: 700;
margin: 0 0 6px;
display: flex;
align-items: center;
gap: 8px;
}
.section-title p {
font-size: 13px;
color: var(--text-secondary);
margin: 0;
}
/* ==========================================
添加行
========================================== */
.add-row {
display: flex;
gap: 10px;
margin-bottom: 16px;
}
.time-input {
padding: 10px 14px;
background: var(--bg-input);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 14px;
font-family: inherit;
}
.time-input:focus {
outline: none;
border-color: var(--brand-primary);
box-shadow: 0 0 0 3px var(--brand-primary-alpha);
}
.channel-input {
flex: 1;
padding: 10px 14px;
background: var(--bg-input);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 14px;
}
.channel-input::placeholder {
color: var(--text-placeholder);
}
.channel-input:focus {
outline: none;
border-color: var(--brand-primary);
box-shadow: 0 0 0 3px var(--brand-primary-alpha);
}
.add-btn {
padding: 12px 24px;
background: linear-gradient(135deg, var(--brand-primary) 0%, var(--brand-primary-hover) 100%);
color: #fff;
font-size: 14px;
font-weight: 600;
border-radius: var(--radius-md);
white-space: nowrap;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 12px var(--brand-primary-alpha);
border: 1px solid rgba(255,255,255,0.1);
}
.add-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 20px var(--brand-primary-alpha);
filter: brightness(1.1);
}
.add-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.empty-hint {
text-align: center;
padding: 20px;
color: var(--text-secondary);
font-size: 13px;
}
/* ==========================================
时间表列表
========================================== */
.schedule-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.schedule-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--bg-input);
border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
transition: all 0.2s;
}
.schedule-card.disabled {
opacity: 0.5;
}
.schedule-info {
display: flex;
align-items: center;
gap: 12px;
}
.schedule-time {
font-size: 20px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.schedule-status {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
}
.schedule-status.active {
background: rgba(16, 185, 129, 0.12);
color: var(--status-success);
}
.schedule-status.paused {
background: rgba(107, 114, 128, 0.12);
color: var(--text-secondary);
}
.schedule-actions {
display: flex;
gap: 6px;
}
.toggle-btn,
.del-btn {
padding: 8px;
border-radius: var(--radius-md);
color: var(--text-secondary);
font-size: 13px;
transition: all 0.2s;
}
.toggle-btn:hover {
color: var(--brand-primary);
background: var(--brand-primary-alpha);
}
.del-btn:hover {
color: var(--status-error);
background: rgba(239, 68, 68, 0.1);
}
/* ==========================================
渠道列表
========================================== */
.endpoint-add {
flex-wrap: wrap;
}
.endpoint-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.endpoint-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px;
background: var(--bg-input);
border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
transition: all 0.2s;
}
.endpoint-card.disabled {
opacity: 0.5;
}
.endpoint-info {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.endpoint-icon-wrap {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
background: var(--brand-primary-alpha);
display: flex;
align-items: center;
justify-content: center;
color: var(--brand-primary);
font-size: 16px;
flex-shrink: 0;
}
.endpoint-detail {
min-width: 0;
}
.endpoint-type {
font-size: 13px;
font-weight: 600;
margin: 0;
}
.endpoint-account {
font-size: 12px;
color: var(--text-secondary);
margin: 2px 0 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.endpoint-right {
display: flex;
align-items: center;
gap: 12px;
}
.priority-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
color: var(--text-secondary);
white-space: nowrap;
}
.endpoint-actions {
display: flex;
gap: 6px;
}
/* ==========================================
工作原理说明
========================================== */
.info-section {
background: transparent;
border: 1px dashed var(--border-subtle);
}
.info-section h3 {
font-size: 15px;
font-weight: 600;
margin: 0 0 16px;
display: flex;
align-items: center;
gap: 8px;
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@media (max-width: 600px) {
.info-grid {
grid-template-columns: 1fr;
}
}
.info-step {
display: flex;
gap: 10px;
align-items: flex-start;
}
.step-num {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--brand-primary-alpha);
color: var(--brand-primary);
font-size: 12px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.info-step p {
font-size: 13px;
color: var(--text-secondary);
margin: 0;
line-height: 1.5;
}
.info-step strong {
color: var(--text-primary);
}
</style>
+71 -37
View File
@@ -40,8 +40,8 @@ watch(loginMode, () => {
successMessage.value = ''
})
function startCooldown() {
countdown.value = CODE_RESEND_SECONDS
function startCooldown(seconds = CODE_RESEND_SECONDS) {
countdown.value = Math.max(1, seconds)
if (countdownTimer) {
clearInterval(countdownTimer)
}
@@ -88,6 +88,10 @@ async function handleSendLoginCode() {
successMessage.value = result.message || '验证码已发送'
startCooldown()
} catch (error) {
const retryAfter = (error as Error & { retryAfter?: number }).retryAfter
if (typeof retryAfter === 'number' && retryAfter > 0) {
startCooldown(retryAfter)
}
errorMessage.value = error instanceof Error ? error.message : '验证码发送失败,请稍后重试'
}
}
@@ -261,17 +265,21 @@ onUnmounted(() => {
</template>
<style scoped>
/* ==========================================
全新高级分屏布局与背景
========================================== */
.split-layout {
display: flex;
min-height: 100vh;
background: var(--bg-base);
}
.brand-panel {
flex: 1;
display: none;
background-color: var(--bg-surface);
background: linear-gradient(135deg, var(--bg-surface), var(--bg-base));
border-right: 1px solid var(--border-subtle);
padding: 60px;
padding: 80px;
position: relative;
overflow: hidden;
}
@@ -287,43 +295,62 @@ onUnmounted(() => {
.brand-content {
position: relative;
z-index: 2;
max-width: 480px;
max-width: 500px;
background: rgba(255, 255, 255, 0.02);
padding: 40px;
border-radius: var(--radius-xl);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255,255,255,0.05);
box-shadow: var(--shadow-xl);
}
.logo {
display: flex;
align-items: center;
gap: 16px;
font-size: 26px;
font-weight: 700;
margin-bottom: 60px;
font-size: 28px;
font-weight: 800;
margin-bottom: 40px;
letter-spacing: -0.02em;
background: linear-gradient(to right, var(--text-primary), var(--text-secondary));
-webkit-background-clip: text;
color: transparent;
}
.brand-title {
font-size: 40px;
line-height: 1.2;
font-weight: 700;
letter-spacing: -0.02em;
font-size: 48px;
line-height: 1.1;
font-weight: 800;
letter-spacing: -0.03em;
margin-bottom: 24px;
color: var(--text-primary);
}
.brand-desc {
font-size: 16px;
line-height: 1.6;
font-size: 18px;
line-height: 1.7;
color: var(--text-secondary);
font-weight: 500;
}
.ambient-glow {
position: absolute;
top: 50%;
right: -20%;
width: 600px;
height: 600px;
right: -10%;
width: 80vw;
height: 80vw;
background: radial-gradient(circle, var(--brand-primary-alpha) 0%, transparent 60%);
transform: translateY(-50%);
filter: blur(60px);
filter: blur(80px);
z-index: 1;
pointer-events: none;
animation: float-glow 10s infinite alternate ease-in-out;
}
@keyframes float-glow {
0% { transform: translateY(-50%) scale(1); opacity: 0.5; }
100% { transform: translateY(-48%) scale(1.05); opacity: 0.8; }
}
.form-panel {
@@ -331,64 +358,71 @@ onUnmounted(() => {
display: flex;
flex-direction: column;
position: relative;
background: var(--bg-surface);
backdrop-filter: var(--backdrop-blur);
-webkit-backdrop-filter: var(--backdrop-blur);
}
.top-actions {
position: absolute;
top: 24px;
right: 24px;
top: 32px;
right: 32px;
}
.form-container {
margin: auto;
width: 100%;
max-width: 420px;
max-width: 440px;
padding: 40px 24px;
}
.form-header {
margin-bottom: 24px;
margin-bottom: 32px;
}
.form-header h2 {
font-size: 28px;
font-weight: 700;
margin: 0 0 8px 0;
letter-spacing: -0.01em;
font-size: 32px;
font-weight: 800;
margin: 0 0 12px 0;
letter-spacing: -0.02em;
}
.form-header p {
color: var(--text-secondary);
margin: 0;
font-size: 15px;
font-size: 16px;
}
.login-mode-tabs {
margin-bottom: 18px;
margin-bottom: 24px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
background: var(--bg-input);
padding: 6px;
border-radius: var(--radius-lg);
border: 1px solid var(--border-subtle);
}
.mode-btn {
height: 40px;
height: 44px;
border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
background: var(--bg-input);
border: none;
background: transparent;
color: var(--text-secondary);
font-size: 14px;
font-size: 15px;
font-weight: 600;
transition: all 0.2s ease;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.mode-btn.active {
border-color: var(--brand-primary);
background: var(--brand-primary-alpha);
background: var(--bg-surface);
color: var(--brand-primary);
box-shadow: var(--shadow-sm);
}
.mode-btn:hover {
border-color: var(--border-strong);
.mode-btn:hover:not(.active) {
color: var(--text-primary);
}
.auth-form {
+57 -33
View File
@@ -52,8 +52,8 @@ const strengthColor = computed(() => {
// ==========================
function startCooldown() {
countdown.value = CODE_RESEND_SECONDS
function startCooldown(seconds = CODE_RESEND_SECONDS) {
countdown.value = Math.max(1, seconds)
if (countdownTimer) clearInterval(countdownTimer)
countdownTimer = setInterval(() => {
countdown.value -= 1
@@ -76,6 +76,10 @@ async function handleSendCode() {
successMessage.value = result.message || '验证码已发送'
startCooldown()
} catch (error) {
const retryAfter = (error as Error & { retryAfter?: number }).retryAfter
if (typeof retryAfter === 'number' && retryAfter > 0) {
startCooldown(retryAfter)
}
errorMessage.value = error instanceof Error ? error.message : '验证码发送失败,请稍后重试'
}
}
@@ -242,18 +246,21 @@ onUnmounted(() => {
</template>
<style scoped>
/* 大部分样式复用 Login 的 split-layout 体系 */
/* ==========================================
全新高级分屏布局与背景
========================================== */
.split-layout {
display: flex;
min-height: 100vh;
background: var(--bg-base);
}
.brand-panel {
flex: 1;
display: none;
background-color: var(--bg-surface);
background: linear-gradient(135deg, var(--bg-surface), var(--bg-base));
border-right: 1px solid var(--border-subtle);
padding: 60px;
padding: 80px;
position: relative;
overflow: hidden;
}
@@ -269,50 +276,63 @@ onUnmounted(() => {
.brand-content {
position: relative;
z-index: 2;
max-width: 480px;
max-width: 500px;
/* 增加悬浮透视感 */
background: rgba(255, 255, 255, 0.02);
padding: 40px;
border-radius: var(--radius-xl);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255,255,255,0.05);
box-shadow: var(--shadow-xl);
}
.logo {
display: flex;
align-items: center;
gap: 16px;
font-size: 26px;
font-weight: 700;
margin-bottom: 60px;
}
.logo-dot {
width: 14px;
height: 14px;
background: var(--brand-primary);
border-radius: 4px;
font-size: 28px;
font-weight: 800;
margin-bottom: 40px;
letter-spacing: -0.02em;
background: linear-gradient(to right, var(--text-primary), var(--text-secondary));
-webkit-background-clip: text;
color: transparent;
}
.brand-title {
font-size: 40px;
line-height: 1.2;
font-weight: 700;
letter-spacing: -0.02em;
font-size: 48px;
line-height: 1.1;
font-weight: 800;
letter-spacing: -0.03em;
margin-bottom: 24px;
color: var(--text-primary);
}
.brand-desc {
font-size: 16px;
line-height: 1.6;
font-size: 18px;
line-height: 1.7;
color: var(--text-secondary);
font-weight: 500;
}
.ambient-glow {
position: absolute;
top: 60%;
top: 50%;
left: -10%;
width: 500px;
height: 500px;
width: 80vw;
height: 80vw;
background: radial-gradient(circle, var(--brand-primary-alpha) 0%, transparent 60%);
transform: translateY(-50%);
filter: blur(50px);
filter: blur(80px);
z-index: 1;
pointer-events: none;
animation: float-glow 10s infinite alternate ease-in-out;
}
@keyframes float-glow {
0% { transform: translateY(-50%) scale(1); opacity: 0.5; }
100% { transform: translateY(-48%) scale(1.05); opacity: 0.8; }
}
.form-panel {
@@ -320,18 +340,21 @@ onUnmounted(() => {
display: flex;
flex-direction: column;
position: relative;
background: var(--bg-surface);
backdrop-filter: var(--backdrop-blur);
-webkit-backdrop-filter: var(--backdrop-blur);
}
.top-actions {
position: absolute;
top: 24px;
right: 24px;
top: 32px;
right: 32px;
}
.form-container {
margin: auto;
width: 100%;
max-width: 400px;
max-width: 440px;
padding: 40px 24px;
}
@@ -340,15 +363,16 @@ onUnmounted(() => {
}
.form-header h2 {
font-size: 28px;
font-weight: 700;
margin: 0 0 8px 0;
font-size: 32px;
font-weight: 800;
margin: 0 0 12px 0;
letter-spacing: -0.02em;
}
.form-header p {
color: var(--text-secondary);
margin: 0;
font-size: 15px;
font-size: 16px;
}
/* 现代密码强度条 */
+524
View File
@@ -0,0 +1,524 @@
<script setup lang="ts">
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,已去重 */
titles: string[]
/** 每次修改对应的时间(与 titles[i+1] 对应) */
change_times: string[]
first_at: string
last_at: string
change_count: number
}
const revisions = ref<HeadlineRevision[]>([])
const loading = ref(true)
const error = ref('')
const hoursRange = ref(48)
// 平台名到图标的映射(与首页保持一致,避免同一平台在不同页面图标不一致)
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',
'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',
}
function getPlatformIcon(name: string): string {
return platformIconMap[name] || 'fa-solid fa-globe'
}
/** 格式化时间 */
function formatTime(dateStr: string): string {
const d = new Date(dateStr)
const now = Date.now()
const diff = now - d.getTime()
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} 小时前`
return d.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
/**
* 将原始修改记录按 event_id 分组,并在每组内拼接完整的标题演变链。
* 规则:同组内按 created_at 升序排列,然后依次将 previous/revised 串成链条。
*/
const revisionChains = computed<RevisionChain[]>(() => {
// 按 event_id 分组
const groups = new Map<number, HeadlineRevision[]>()
for (const rev of revisions.value) {
const list = groups.get(rev.event_id) ?? []
list.push(rev)
groups.set(rev.event_id, list)
}
const chains: RevisionChain[] = []
for (const [event_id, items] of groups) {
// 组内按时间升序
items.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
// 拼接标题链,避免重复(相邻记录的 revised 与下一条 previous 通常相同)
const titles: string[] = [items[0].previous_headline]
const change_times: string[] = []
for (const item of items) {
// 若链条末尾与本条 previous 不同,说明有断层,仍然追加
if (titles[titles.length - 1] !== item.previous_headline) {
titles.push(item.previous_headline)
change_times.push(item.created_at)
}
titles.push(item.revised_headline)
change_times.push(item.created_at)
}
chains.push({
event_id,
source_name: items[0].source_name,
titles,
change_times,
first_at: items[0].created_at,
last_at: items[items.length - 1].created_at,
change_count: items.length,
})
}
// 最终按最新修改时间降序
chains.sort((a, b) => new Date(b.last_at).getTime() - new Date(a.last_at).getTime())
return chains
})
/** 加载数据 */
async function loadRevisions() {
loading.value = true
error.value = ''
try {
revisions.value = await fetchHeadlineRevisions({ hours: hoursRange.value, limit: 200 })
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
/** 切换时间范围 */
function changeRange(hours: number) {
hoursRange.value = hours
loadRevisions()
}
onMounted(loadRevisions)
</script>
<template>
<div class="revisions-page">
<div class="page-header">
<div>
<h1>
<i class="fa-solid fa-mask" style="color: var(--status-error)"></i>
公关修改追踪
</h1>
<p class="page-desc">
实时监控各平台热搜标题被暗改的记录当爬虫检测到标题变更时会自动记录修改前后的差异
</p>
</div>
</div>
<!-- 时间范围选择 -->
<div class="filter-bar">
<span class="filter-label">查看范围</span>
<div class="filter-tabs">
<button
v-for="opt in [{ label: '24小时', value: 24 }, { label: '48小时', value: 48 }, { label: '7天', value: 168 }]"
:key="opt.value"
class="filter-tab"
:class="{ active: hoursRange === opt.value }"
@click="changeRange(opt.value)"
>
{{ opt.label }}
</button>
</div>
<span class="result-count"> {{ revisionChains.length }} 个事件 · {{ revisions.length }} 次修改</span>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-state">
<i class="fa-solid fa-spinner fa-spin"></i>
<span>加载中...</span>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-state">
<i class="fa-solid fa-circle-exclamation"></i>
<span>{{ error }}</span>
</div>
<!-- 空状态 -->
<div v-else-if="revisions.length === 0" class="empty-state">
<i class="fa-solid fa-shield-check"></i>
<p>该时段内未检测到标题修改</p>
<p class="empty-hint">这是个好消息说明各平台暂无异常公关操作</p>
</div>
<!-- 修改记录列表按事件分组展示完整标题演变链 -->
<div v-else class="revision-list">
<div v-for="chain in revisionChains" :key="chain.event_id" class="revision-card">
<div class="revision-header">
<div class="platform-info">
<i :class="getPlatformIcon(chain.source_name || '')"></i>
<span>{{ chain.source_name || '未知平台' }}</span>
<span v-if="chain.change_count > 1" class="change-badge">
{{ chain.change_count }} 次修改
</span>
</div>
<div class="revision-time-range">
<span>{{ formatTime(chain.first_at) }}</span>
<template v-if="chain.change_count > 1">
<i class="fa-solid fa-arrow-right time-arrow"></i>
<span>{{ formatTime(chain.last_at) }}</span>
</template>
</div>
</div>
<!-- 标题演变链 -->
<div class="chain-area">
<template v-for="(title, idx) in chain.titles" :key="idx">
<!-- 标题节点 -->
<div
class="chain-title"
:class="{
'chain-title--original': idx === 0,
'chain-title--current': idx === chain.titles.length - 1,
'chain-title--middle': idx > 0 && idx < chain.titles.length - 1,
}"
>
<span class="chain-step-label">
{{ idx === 0 ? '原始' : idx === chain.titles.length - 1 ? '现在' : `${idx}` }}
</span>
<p class="chain-title-text">{{ title }}</p>
<span v-if="idx < chain.change_times.length" class="chain-step-time">
{{ formatTime(chain.change_times[idx]) }}
</span>
</div>
<!-- 箭头分隔最后一个标题后不需要 -->
<div v-if="idx < chain.titles.length - 1" class="chain-arrow">
<i class="fa-solid fa-arrow-down"></i>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.revisions-page {
max-width: 860px;
margin: 0 auto;
}
.page-header {
margin-bottom: 24px;
}
.page-header h1 {
font-size: 24px;
font-weight: 700;
margin: 0 0 8px;
display: flex;
align-items: center;
gap: 10px;
}
.page-desc {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.6;
margin: 0;
}
/* ==========================================
过滤栏
========================================== */
.filter-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.filter-label {
font-size: 13px;
color: var(--text-secondary);
}
.filter-tab {
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
border-radius: var(--radius-sm);
border: none;
background: transparent;
color: var(--text-secondary);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.filter-tabs {
display: flex;
gap: 4px;
background: var(--bg-input);
padding: 4px;
border-radius: var(--radius-lg);
border: 1px solid var(--border-subtle);
}
.filter-tab:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.filter-tab.active {
background: var(--bg-surface);
color: var(--brand-primary);
box-shadow: var(--shadow-sm);
}
.result-count {
font-size: 12px;
color: var(--text-secondary);
margin-left: auto;
}
/* ==========================================
状态
========================================== */
.loading-state,
.error-state {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 60px;
color: var(--text-secondary);
}
.error-state {
color: var(--status-error);
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-secondary);
}
.empty-state i {
font-size: 40px;
margin-bottom: 12px;
display: block;
color: var(--status-success);
opacity: 0.6;
}
.empty-state p {
margin: 0;
font-size: 14px;
}
.empty-hint {
margin-top: 6px !important;
font-size: 13px !important;
color: var(--text-placeholder);
}
/* ==========================================
修改记录卡片
========================================== */
.revision-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.revision-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;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: var(--shadow-sm);
}
.revision-card:hover {
border-color: var(--brand-primary-alpha);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.revision-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.platform-info {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 500;
}
.platform-info i {
font-size: 16px;
}
.revision-time-range {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
}
.time-arrow {
font-size: 10px;
color: var(--text-placeholder);
}
/* 修改次数徽章 */
.change-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 99px;
font-size: 11px;
font-weight: 600;
background: rgba(239, 68, 68, 0.12);
color: var(--status-error);
border: 1px solid rgba(239, 68, 68, 0.2);
}
/* ==========================================
标题演变链
========================================== */
.chain-area {
display: flex;
flex-direction: column;
gap: 0;
}
.chain-title {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
border-radius: var(--radius-md);
border: 1px solid transparent;
}
/* 原始标题 —— 红色删除线风格 */
.chain-title--original {
background: rgba(239, 68, 68, 0.05);
border-color: rgba(239, 68, 68, 0.12);
}
.chain-title--original .chain-step-label {
background: rgba(239, 68, 68, 0.15);
color: var(--status-error);
}
.chain-title--original .chain-title-text {
color: var(--text-secondary);
text-decoration: line-through;
text-decoration-color: var(--status-error);
}
/* 中间过渡版本 —— 橙/琥珀色风格 */
.chain-title--middle {
background: rgba(245, 158, 11, 0.05);
border-color: rgba(245, 158, 11, 0.15);
}
.chain-title--middle .chain-step-label {
background: rgba(245, 158, 11, 0.15);
color: #d97706;
}
.chain-title--middle .chain-title-text {
color: var(--text-primary);
}
/* 当前最新版本 —— 绿色高亮风格 */
.chain-title--current {
background: rgba(16, 185, 129, 0.05);
border-color: rgba(16, 185, 129, 0.12);
}
.chain-title--current .chain-step-label {
background: rgba(16, 185, 129, 0.15);
color: var(--status-success);
}
.chain-title--current .chain-title-text {
color: var(--status-success);
font-weight: 500;
}
/* 步骤标签(原始 / 第N次 / 现在) */
.chain-step-label {
flex-shrink: 0;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
margin-top: 2px;
}
.chain-title-text {
flex: 1;
font-size: 14px;
margin: 0;
line-height: 1.5;
}
.chain-step-time {
flex-shrink: 0;
font-size: 11px;
color: var(--text-placeholder);
white-space: nowrap;
margin-top: 3px;
}
/* 链条箭头 */
.chain-arrow {
display: flex;
justify-content: flex-start;
padding: 3px 0 3px 22px;
color: var(--text-placeholder);
font-size: 10px;
}
</style>
+668
View File
@@ -0,0 +1,668 @@
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { fetchPreferences, createPreference, deletePreference, fetchRecommendedEvents } from '@/api/preferences'
import type { UserTopicPreference, MatchedEvent } from '@/types/preference'
const authStore = useAuthStore()
const userId = computed(() => authStore.user?.id ?? 0)
const preferences = ref<UserTopicPreference[]>([])
const newKeyword = ref('')
const loading = ref(true)
const submitting = ref(false)
const error = ref('')
const successMsg = ref('')
const matchedEvents = ref<MatchedEvent[]>([])
const loadingMatched = ref(false)
const matchedError = ref('')
/** 加载用户的兴趣关键词 */
async function loadPreferences() {
if (!userId.value) return
loading.value = true
error.value = ''
try {
preferences.value = await fetchPreferences(userId.value)
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
/** 加载命中关键词的推荐事件 */
async function loadMatchedEvents() {
if (!userId.value) return
loadingMatched.value = true
matchedError.value = ''
try {
const result = await fetchRecommendedEvents(userId.value, { limit: 30 })
matchedEvents.value = result.data
} catch (e) {
matchedError.value = e instanceof Error ? e.message : '加载失败'
} finally {
loadingMatched.value = false
}
}
/** 格式化热度标签 */
function hotLabel(score: number): { text: string; color: string; bg: string } {
if (score >= 50) return { text: `🔥 ${score}`, color: '#ef4444', bg: 'rgba(239,68,68,0.1)' }
if (score >= 20) return { text: `🌡 ${score}`, color: '#f59e0b', bg: 'rgba(245,158,11,0.1)' }
return { text: `${score}`, color: 'var(--text-secondary)', bg: 'var(--bg-input)' }
}
/** 添加新关键词 */
async function handleAdd() {
const keyword = newKeyword.value.trim()
if (!keyword) return
if (!userId.value) return
submitting.value = true
error.value = ''
successMsg.value = ''
try {
const created = await createPreference(userId.value, keyword)
preferences.value.unshift(created)
newKeyword.value = ''
successMsg.value = `已添加「${keyword}`
setTimeout(() => { successMsg.value = '' }, 3000)
loadMatchedEvents()
} catch (e) {
error.value = e instanceof Error ? e.message : '添加失败'
} finally {
submitting.value = false
}
}
/** 删除关键词 */
async function handleDelete(pref: UserTopicPreference) {
if (!userId.value) return
error.value = ''
try {
await deletePreference(userId.value, pref.id)
preferences.value = preferences.value.filter(p => p.id !== pref.id)
loadMatchedEvents()
} catch (e) {
error.value = e instanceof Error ? e.message : '删除失败'
}
}
/** Enter 键提交 */
function onInputKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault()
handleAdd()
}
}
onMounted(async () => {
await loadPreferences()
loadMatchedEvents()
})
</script>
<template>
<div class="topics-page">
<div class="page-header">
<h1>
<i class="fa-solid fa-rss" style="color: var(--brand-primary)"></i>
我的泛订阅
</h1>
<p class="page-desc">
添加你感兴趣的关键词系统会自动匹配全网热点事件并推送给你
支持精确匹配和 AI 语义匹配
</p>
</div>
<!-- 添加关键词 -->
<div class="add-section">
<div class="add-form">
<div class="input-wrapper">
<i class="fa-solid fa-plus input-icon"></i>
<input
v-model="newKeyword"
type="text"
class="keyword-input"
placeholder="输入关键词,如「直升机」「科比」「佐巴扬」..."
maxlength="100"
@keydown="onInputKeydown"
/>
</div>
<button class="add-btn" :disabled="submitting || !newKeyword.trim()" @click="handleAdd">
{{ submitting ? '添加中...' : '添加' }}
</button>
</div>
<!-- 提示消息 -->
<p v-if="successMsg" class="msg success-msg">
<i class="fa-solid fa-check-circle"></i> {{ successMsg }}
</p>
<p v-if="error" class="msg error-msg">
<i class="fa-solid fa-circle-exclamation"></i> {{ error }}
</p>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-state">
<i class="fa-solid fa-spinner fa-spin"></i>
<span>加载中...</span>
</div>
<!-- 关键词列表 -->
<div v-else class="keywords-section">
<h2 class="sub-title">
已订阅的关键词
<span class="count-badge">{{ preferences.length }}</span>
</h2>
<div v-if="preferences.length === 0" class="empty-state">
<i class="fa-solid fa-bookmark"></i>
<p>还没有添加任何关键词</p>
<p class="empty-hint">在上方输入框中添加你感兴趣的话题</p>
</div>
<div v-else class="keywords-grid">
<div v-for="pref in preferences" :key="pref.id" class="keyword-card">
<div class="keyword-content">
<i class="fa-solid fa-hashtag keyword-icon"></i>
<span class="keyword-text">{{ pref.interested_keyword }}</span>
</div>
<button class="delete-btn" title="删除" @click="handleDelete(pref)">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</div>
</div>
<!-- 命中的热点事件 -->
<div class="matched-section">
<h2 class="sub-title">
<i class="fa-solid fa-wand-magic-sparkles" style="color: var(--brand-primary)"></i>
命中的热点事件
<span v-if="!loadingMatched && matchedEvents.length > 0" class="count-badge">
{{ matchedEvents.length }}
</span>
</h2>
<!-- 加载中 -->
<div v-if="loadingMatched" class="loading-state">
<i class="fa-solid fa-spinner fa-spin"></i>
<span>AI 匹配中...</span>
</div>
<!-- 错误 -->
<p v-else-if="matchedError" class="msg error-msg">
<i class="fa-solid fa-circle-exclamation"></i> {{ matchedError }}
</p>
<!-- 无关键词 -->
<div v-else-if="preferences.length === 0" class="empty-state">
<i class="fa-solid fa-search"></i>
<p>先添加关键词才能查看匹配事件</p>
</div>
<!-- 无匹配 -->
<div v-else-if="matchedEvents.length === 0" class="empty-state">
<i class="fa-solid fa-satellite-dish"></i>
<p>暂未匹配到相关事件</p>
<p class="empty-hint">系统会在下次 AI 摘要生成后自动更新</p>
</div>
<!-- 匹配事件列表 -->
<div v-else class="matched-list">
<RouterLink
v-for="ev in matchedEvents"
:key="ev.event_id"
:to="{ path: '/', query: { event: ev.event_id } }"
class="matched-card"
>
<!-- 热度 + 匹配度 -->
<div class="matched-card-meta">
<span
class="hot-chip"
:style="{ color: hotLabel(ev.hot_score).color, background: hotLabel(ev.hot_score).bg }"
>
{{ hotLabel(ev.hot_score).text }}
</span>
<span class="match-score-chip">
<i class="fa-solid fa-crosshairs"></i>
匹配度 {{ ev.match_score.toFixed(0) }}
</span>
<span class="matched-goto">
<i class="fa-solid fa-arrow-up-right-from-square"></i> 查看详情
</span>
</div>
<!-- 标题 -->
<p class="matched-title">{{ ev.unified_title }}</p>
<!-- AI 摘要 -->
<p v-if="ev.summary" class="matched-summary">{{ ev.summary }}</p>
<!-- 命中的关键词标签 -->
<div class="matched-hits">
<span v-for="hit in ev.exact_hits.slice(0, 4)" :key="hit" class="hit-tag exact">
<i class="fa-solid fa-bullseye"></i> {{ hit }}
</span>
<span
v-for="sh in ev.semantic_hits.slice(0, 3)"
:key="sh.topic_keyword"
class="hit-tag semantic"
>
<i class="fa-solid fa-brain"></i> {{ sh.topic_keyword }}
<span class="sim-pct">{{ (sh.similarity * 100).toFixed(0) }}%</span>
</span>
</div>
</RouterLink>
</div>
</div>
<!-- 功能说明 -->
<div class="info-panel">
<h3><i class="fa-solid fa-lightbulb"></i> 匹配说明</h3>
<ul>
<li><strong>精确匹配</strong>关键词与事件标签完全一致或互为包含关系时命中</li>
<li><strong>语义匹配</strong>使用向量模型计算语义相似度超过阈值自动命中</li>
<li><strong>推送触发</strong>当新事件的标签命中您的关键词时将在设定时间推送简报</li>
</ul>
</div>
</div>
</template>
<style scoped>
.topics-page {
max-width: 800px;
margin: 0 auto;
}
.page-header {
margin-bottom: 32px;
}
.page-header h1 {
font-size: 24px;
font-weight: 700;
margin: 0 0 8px;
display: flex;
align-items: center;
gap: 10px;
}
.page-desc {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.6;
margin: 0;
}
/* ==========================================
添加区域
========================================== */
.add-section {
margin-bottom: 32px;
}
.add-form {
display: flex;
gap: 12px;
}
.input-wrapper {
flex: 1;
position: relative;
display: flex;
align-items: center;
}
.input-icon {
position: absolute;
left: 14px;
color: var(--text-placeholder);
font-size: 13px;
}
.keyword-input {
width: 100%;
padding: 14px 16px 14px 42px;
background: var(--bg-input);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 15px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: inset 0 2px 4px rgba(0,0,0,0.02);
}
.keyword-input::placeholder {
color: var(--text-placeholder);
}
.keyword-input:focus {
outline: none;
border-color: var(--brand-primary);
box-shadow: 0 0 0 4px var(--brand-primary-alpha), inset 0 1px 2px rgba(0,0,0,0.02);
background: var(--bg-surface);
}
.add-btn {
padding: 14px 28px;
background: linear-gradient(135deg, var(--brand-primary) 0%, var(--brand-primary-hover) 100%);
color: #fff;
font-size: 15px;
font-weight: 600;
border-radius: var(--radius-md);
white-space: nowrap;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 12px var(--brand-primary-alpha);
}
.add-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 20px var(--brand-primary-alpha);
filter: brightness(1.1);
}
.add-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.msg {
font-size: 13px;
margin: 10px 0 0;
display: flex;
align-items: center;
gap: 6px;
}
.success-msg {
color: var(--status-success);
}
.error-msg {
color: var(--status-error);
}
/* ==========================================
关键词列表
========================================== */
.keywords-section {
margin-bottom: 32px;
}
.sub-title {
font-size: 16px;
font-weight: 600;
margin: 0 0 16px;
display: flex;
align-items: center;
gap: 8px;
}
.count-badge {
font-size: 12px;
padding: 2px 10px;
border-radius: 10px;
background: var(--brand-primary-alpha);
color: var(--brand-primary);
font-weight: 700;
}
.loading-state {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 40px;
color: var(--text-secondary);
}
.empty-state {
text-align: center;
padding: 40px;
color: var(--text-secondary);
}
.empty-state i {
font-size: 36px;
opacity: 0.3;
margin-bottom: 12px;
display: block;
}
.empty-state p {
margin: 0;
font-size: 14px;
}
.empty-hint {
margin-top: 6px !important;
font-size: 13px !important;
color: var(--text-placeholder);
}
.keywords-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.keyword-card {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
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-lg);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: var(--shadow-sm);
}
.keyword-card:hover {
border-color: var(--brand-primary-alpha);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.keyword-content {
display: flex;
align-items: center;
gap: 6px;
}
.keyword-icon {
color: var(--brand-primary);
font-size: 12px;
}
.keyword-text {
font-size: 14px;
font-weight: 500;
}
.delete-btn {
color: var(--text-placeholder);
padding: 4px 6px;
border-radius: 4px;
font-size: 12px;
transition: all 0.2s;
}
.delete-btn:hover {
color: var(--status-error);
background: rgba(239, 68, 68, 0.1);
}
/* ==========================================
命中事件区块
========================================== */
.matched-section {
margin-bottom: 32px;
}
.matched-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.matched-card {
display: block;
padding: 16px;
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);
text-decoration: none;
color: inherit;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.matched-card:hover {
border-color: var(--brand-primary-alpha);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.matched-card-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.hot-chip {
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
border-radius: 99px;
}
.match-score-chip {
font-size: 11px;
color: var(--text-placeholder);
display: flex;
align-items: center;
gap: 4px;
}
.matched-goto {
margin-left: auto;
font-size: 11px;
color: var(--brand-primary);
opacity: 0;
transition: opacity 0.2s;
white-space: nowrap;
}
.matched-card:hover .matched-goto {
opacity: 1;
}
.matched-title {
font-size: 15px;
font-weight: 600;
line-height: 1.5;
margin: 0 0 6px;
color: var(--text-primary);
}
.matched-summary {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.6;
margin: 0 0 8px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.matched-hits {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.hit-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 99px;
font-size: 11px;
font-weight: 600;
}
.hit-tag.exact {
background: rgba(99, 102, 241, 0.1);
color: #6366f1;
}
.hit-tag.semantic {
background: rgba(16, 185, 129, 0.1);
color: var(--status-success);
}
.sim-pct {
opacity: 0.7;
}
/* ==========================================
功能说明
========================================== */
.info-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);
padding: 24px;
box-shadow: var(--shadow-sm);
}
.info-panel h3 {
font-size: 14px;
font-weight: 600;
margin: 0 0 12px;
display: flex;
align-items: center;
gap: 8px;
color: var(--text-primary);
}
.info-panel h3 i {
color: #facc15;
}
.info-panel ul {
list-style: none;
padding: 0;
margin: 0;
}
.info-panel li {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.8;
padding-left: 16px;
position: relative;
}
.info-panel li::before {
content: '•';
position: absolute;
left: 0;
color: var(--brand-primary);
}
</style>
+5 -2
View File
@@ -8,12 +8,15 @@ import vueDevTools from 'vite-plugin-vue-devtools'
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
// vueDevTools(),
],
server: {
host: '0.0.0.0',
port: 5173,
strictPort: true,
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
target: 'http://10.252.130.135:8000',
changeOrigin: true,
},
},