mirror of
https://github.com/stardrophere/InsightRadar.git
synced 2026-06-05 23:56:36 +08:00
big update
This commit is contained in:
@@ -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.database import SessionLocal
|
||||||
|
from app.models.models import AppUser
|
||||||
|
|
||||||
|
bearer_scheme = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
"""
|
"""
|
||||||
@@ -10,4 +17,40 @@ def get_db():
|
|||||||
try:
|
try:
|
||||||
yield db
|
yield db
|
||||||
finally:
|
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
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import math
|
||||||
import os
|
import os
|
||||||
from datetime import timedelta
|
from datetime import timedelta, timezone
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
@@ -30,8 +31,18 @@ from app.utils.email_utils import send_html_email
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
REGISTER_CODE_EXPIRE_MINUTES = int(os.getenv("REGISTER_CODE_EXPIRE_MINUTES", "10"))
|
DEFAULT_REGISTER_CODE_EXPIRE_MINUTES = 10
|
||||||
LOGIN_CODE_EXPIRE_MINUTES = int(os.getenv("LOGIN_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:
|
def _normalize_email(email: str) -> str:
|
||||||
@@ -78,6 +89,41 @@ def _create_code_record(
|
|||||||
return code_record, code
|
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:
|
def _build_auth_response(user: AppUser) -> AuthTokenResponse:
|
||||||
token, expires_in = create_access_token(user_id=user.id, email=user.email)
|
token, expires_in = create_access_token(user_id=user.id, email=user.email)
|
||||||
return AuthTokenResponse(
|
return AuthTokenResponse(
|
||||||
@@ -95,6 +141,7 @@ async def send_register_code(payload: RegisterCodeSendRequest, db: Session = Dep
|
|||||||
if existing_user:
|
if existing_user:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email is already registered")
|
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)
|
_invalidate_unused_codes(db, email, VerificationPurpose.REGISTER)
|
||||||
code_record, code = _create_code_record(
|
code_record, code = _create_code_record(
|
||||||
db,
|
db,
|
||||||
@@ -128,6 +175,7 @@ async def send_login_code(payload: LoginCodeSendRequest, db: Session = Depends(g
|
|||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Email is not registered")
|
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)
|
_invalidate_unused_codes(db, email, VerificationPurpose.LOGIN)
|
||||||
code_record, code = _create_code_record(
|
code_record, code = _create_code_record(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,69 +1,215 @@
|
|||||||
# app/api/endpoints/events.py
|
# app/api/endpoints/events.py
|
||||||
from fastapi import APIRouter, Depends, Query
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import List
|
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.api.dependencies import get_db
|
||||||
from app.models.models import UnifiedEvent, TrendingEvent, InfoSource, RankingLog, utcnow
|
from app.models.models import (
|
||||||
# 导入你上传的 Schema
|
ExtractedTopic,
|
||||||
from app.schemas.event_schema import UnifiedEventResponse, PlatformTrendResponse
|
InfoSource,
|
||||||
|
RankingLog,
|
||||||
|
TargetType,
|
||||||
|
TrendingEvent,
|
||||||
|
UnifiedEvent,
|
||||||
|
utcnow,
|
||||||
|
)
|
||||||
|
from app.schemas.event_schema import (
|
||||||
|
PaginatedUnifiedEventResponse,
|
||||||
|
PlatformTrendResponse,
|
||||||
|
UnifiedEventResponse,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
# 排名轨迹最多返回多少个点,避免长时间跨度下数据过大
|
||||||
|
MAX_RANKING_POINTS = 30
|
||||||
|
|
||||||
@router.get("/unified", response_model=List[UnifiedEventResponse])
|
|
||||||
|
@router.get("/unified", response_model=PaginatedUnifiedEventResponse)
|
||||||
def list_unified_events(
|
def list_unified_events(
|
||||||
min_hot: int = Query(5, description="热度过滤阈值"),
|
min_hot: int = Query(5, ge=0, description="热度阈值,仅返回 hot_score >= 此值的事件"),
|
||||||
hours: int = Query(24, description="查询过去 X 小时的数据"),
|
hours: int = Query(24, ge=1, le=720, description="查询最近多少小时的数据"),
|
||||||
db: Session = Depends(get_db)
|
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)
|
time_limit = utcnow() - timedelta(hours=hours)
|
||||||
|
|
||||||
# 1. 查询大事件(按热度降序,且满足时间范围)
|
# 先查总数,用于前端判断是否还有更多
|
||||||
events = db.query(UnifiedEvent).filter(
|
base_query = db.query(UnifiedEvent).filter(
|
||||||
UnifiedEvent.hot_score >= min_hot,
|
UnifiedEvent.hot_score >= min_hot,
|
||||||
UnifiedEvent.created_at >= time_limit
|
UnifiedEvent.created_at >= time_limit,
|
||||||
).order_by(UnifiedEvent.hot_score.desc()).all()
|
)
|
||||||
|
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:
|
for ev in events:
|
||||||
# 2. 联表查询:获取该大事件下关联的所有平台及其具体热搜信息
|
platform_list: list[PlatformTrendResponse] = []
|
||||||
trends = db.query(TrendingEvent, InfoSource.source_name).join(
|
for trend, source_name in trend_map.get(ev.id, []):
|
||||||
InfoSource, TrendingEvent.source_id == InfoSource.id
|
history = ranking_map.get(trend.id, [])
|
||||||
).filter(TrendingEvent.unified_event_id == ev.id).all()
|
# 截取尾部,只保留最近的点
|
||||||
|
if len(history) > MAX_RANKING_POINTS:
|
||||||
|
history = history[-MAX_RANKING_POINTS:]
|
||||||
|
|
||||||
platform_list = []
|
platform_list.append(
|
||||||
for trend, s_name in trends:
|
PlatformTrendResponse(
|
||||||
# 3. 获取排名历史轨迹 (用于前端渲染)
|
source_id=trend.source_id,
|
||||||
# 这里的排序顺序 asc 保证了数组从旧到新
|
platform_name=source_name,
|
||||||
logs = db.query(RankingLog.ranking_position).filter(
|
headline=trend.current_headline,
|
||||||
RankingLog.event_id == trend.id,
|
url=trend.event_url,
|
||||||
RankingLog.observed_at >= time_limit
|
current_ranking=trend.current_ranking,
|
||||||
).order_by(RankingLog.observed_at.asc()).all()
|
ranking_history=history,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# 组装符合 PlatformTrendResponse 结构的字典
|
results.append(
|
||||||
platform_list.append(PlatformTrendResponse(
|
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,
|
source_id=trend.source_id,
|
||||||
platform_name=s_name,
|
platform_name=source_name,
|
||||||
headline=trend.current_headline,
|
headline=trend.current_headline,
|
||||||
url=trend.event_url,
|
url=trend.event_url,
|
||||||
current_ranking=trend.current_ranking,
|
current_ranking=trend.current_ranking,
|
||||||
ranking_history=[log[0] for log in logs]
|
ranking_history=history,
|
||||||
))
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# 4. 组装符合 UnifiedEventResponse 结构的字典
|
return UnifiedEventResponse(
|
||||||
results.append(UnifiedEventResponse(
|
event_id=ev.id,
|
||||||
event_id=ev.id,
|
unified_title=ev.unified_title if ev.unified_title else "暂无标题",
|
||||||
unified_title=ev.unified_title if ev.unified_title else "暂无标题",
|
summary=ev.ai_comprehensive_summary,
|
||||||
summary=ev.ai_comprehensive_summary,
|
hot_score=ev.hot_score,
|
||||||
hot_score=ev.hot_score,
|
created_at=ev.created_at,
|
||||||
created_at=ev.created_at,
|
platforms=platform_list,
|
||||||
platforms=platform_list
|
tags=tags,
|
||||||
))
|
)
|
||||||
|
|
||||||
return results
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# app/api/router.py
|
# app/api/router.py
|
||||||
from fastapi import APIRouter
|
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()
|
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(events.router, prefix="/events", tags=["Unified Events"])
|
||||||
|
|
||||||
|
# 认证
|
||||||
api_router.include_router(auth.router, prefix="/auth", tags=["Auth"])
|
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"])
|
||||||
|
|||||||
@@ -8,9 +8,15 @@ import time
|
|||||||
from typing import Tuple
|
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_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:
|
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("=")
|
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]:
|
def create_access_token(user_id: int, email: str) -> Tuple[str, int]:
|
||||||
expires_in = AUTH_TOKEN_EXPIRE_MINUTES * 60
|
expires_in = AUTH_TOKEN_EXPIRE_MINUTES * 60
|
||||||
payload = {
|
payload = {
|
||||||
@@ -77,3 +88,51 @@ def create_access_token(user_id: int, email: str) -> Tuple[str, int]:
|
|||||||
).digest()
|
).digest()
|
||||||
token = f"{encoded_payload}.{_urlsafe_b64encode(signature)}"
|
token = f"{encoded_payload}.{_urlsafe_b64encode(signature)}"
|
||||||
return token, expires_in
|
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)
|
||||||
|
|||||||
@@ -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
@@ -1,12 +1,24 @@
|
|||||||
# app/main.py
|
# app/main.py
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from dotenv import load_dotenv
|
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 apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
from app.services.fetcher_service import fetch_and_save_trending_data
|
from app.services.fetcher_service import fetch_and_save_trending_data
|
||||||
from app.services.summary_service import generate_unified_summaries
|
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.database import engine
|
||||||
from app.models.models import Base
|
from app.models.models import Base
|
||||||
|
|
||||||
@@ -47,14 +59,24 @@ async def lifespan(app: FastAPI):
|
|||||||
id='ai_summary_job',
|
id='ai_summary_job',
|
||||||
replace_existing=True
|
replace_existing=True
|
||||||
)
|
)
|
||||||
|
# 推送调度:每分钟检查是否有用户需要接收邮件推送
|
||||||
|
scheduler.add_job(
|
||||||
|
check_and_deliver,
|
||||||
|
'interval',
|
||||||
|
minutes=1,
|
||||||
|
id='delivery_check_job',
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
print(f"定时抓取任务已启动,每 {CRAWL_INTERVAL} 分钟执行一次")
|
print(f"定时抓取任务已启动,每 {CRAWL_INTERVAL} 分钟执行一次")
|
||||||
print(f"AI 摘要生成任务已启动,每 {SUMMARY_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 开始接受请求
|
yield # 此时 FastAPI 开始接受请求
|
||||||
|
|
||||||
@@ -67,7 +89,19 @@ async def lifespan(app: FastAPI):
|
|||||||
app = FastAPI(title="AI 新闻聚合引擎 API", lifespan=lifespan)
|
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")
|
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,
|
||||||
|
)
|
||||||
@@ -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 = """
|
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
|
||||||
|
|
||||||
要求:
|
Rules:
|
||||||
1. 摘要结构类似:"该事件在多平台发酵。微博侧重讨论...,知乎硬核解析...,科技媒体关注..."。
|
1. Return strict JSON with exactly these keys:
|
||||||
2. 提炼出各平台的讨论侧重点,不要简单罗列标题。
|
- "unified_title": string
|
||||||
3. 必须以严格的 JSON 格式返回,只包含 "unified_title" 和 "ai_comprehensive_summary" 两个字段,不要有多余的说明。
|
- "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}
|
{platform_data_text}
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1,23 +1,30 @@
|
|||||||
# app/schemas/event_schema.py
|
# app/schemas/event_schema.py
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
class PlatformTrendResponse(BaseModel):
|
class PlatformTrendResponse(BaseModel):
|
||||||
source_id: int
|
source_id: int
|
||||||
platform_name: str # 平台名称,如 "微博热搜"
|
platform_name: str
|
||||||
headline: str # 平台对应的具体热搜标题
|
headline: str
|
||||||
url: Optional[str] # 跳转链接
|
url: Optional[str]
|
||||||
current_ranking: Optional[int] # 当前排名
|
current_ranking: Optional[int]
|
||||||
ranking_history: List[int] # 排名历史轨迹,如 [50, 45, 20, 5, 1],供 ApexCharts 渲染
|
ranking_history: List[int]
|
||||||
|
|
||||||
|
|
||||||
class UnifiedEventResponse(BaseModel):
|
class UnifiedEventResponse(BaseModel):
|
||||||
event_id: int
|
event_id: int
|
||||||
unified_title: str # AI 生成的统一大标题
|
unified_title: str
|
||||||
summary: Optional[str] # AI 生成的摘要
|
summary: Optional[str]
|
||||||
hot_score: int # 总热度值
|
hot_score: int
|
||||||
created_at: datetime # 事件发现时间
|
created_at: datetime
|
||||||
platforms: List[PlatformTrendResponse] # 挂载的各个平台子热搜
|
platforms: List[PlatformTrendResponse]
|
||||||
# tags: List[str] = [] # 如果后续打通了 ExtractedTopic,可以在这里返回标签
|
tags: List[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedUnifiedEventResponse(BaseModel):
|
||||||
|
"""分页包装:避免一次性返回全量数据"""
|
||||||
|
total: int
|
||||||
|
has_more: bool
|
||||||
|
data: List[UnifiedEventResponse]
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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]
|
||||||
@@ -1,104 +1,241 @@
|
|||||||
# app/services/summary_service.py
|
# app/services/summary_service.py
|
||||||
import os
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
from openai import AsyncOpenAI
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
from app.database import SessionLocal
|
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 (
|
from app.prompts.summary_prompts import (
|
||||||
SUMMARY_SYSTEM_PROMPT,
|
SUMMARY_SYSTEM_PROMPT,
|
||||||
SUMMARY_USER_PROMPT_TEMPLATE,
|
SUMMARY_USER_PROMPT_TEMPLATE,
|
||||||
)
|
)
|
||||||
|
from app.services.fetcher_service import embedder_model
|
||||||
|
|
||||||
HOT_SCORE_THRESHOLD = int(os.getenv("HOT_SCORE_THRESHOLD", 3))
|
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(
|
deepseek_client = AsyncOpenAI(
|
||||||
api_key=AI_API_KEY,
|
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:
|
async def call_llm_for_summary(platform_data_text: str) -> dict:
|
||||||
"""调用 DeepSeek 生成统一标题和多平台视角摘要"""
|
"""Call LLM for unified title, summary and topic candidates."""
|
||||||
prompt = SUMMARY_USER_PROMPT_TEMPLATE.format(
|
prompt = SUMMARY_USER_PROMPT_TEMPLATE.format(platform_data_text=platform_data_text)
|
||||||
platform_data_text=platform_data_text
|
|
||||||
)
|
|
||||||
|
|
||||||
# await
|
|
||||||
response = await deepseek_client.chat.completions.create(
|
response = await deepseek_client.chat.completions.create(
|
||||||
model="deepseek-chat",
|
model="deepseek-chat",
|
||||||
messages=[
|
messages=[
|
||||||
{"role": "system", "content": SUMMARY_SYSTEM_PROMPT},
|
{"role": "system", "content": SUMMARY_SYSTEM_PROMPT},
|
||||||
{"role": "user", "content": prompt}
|
{"role": "user", "content": prompt},
|
||||||
],
|
],
|
||||||
response_format={"type": "json_object"},
|
response_format={"type": "json_object"},
|
||||||
temperature=1
|
temperature=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
result_text = response.choices[0].message.content
|
result_text = response.choices[0].message.content
|
||||||
return json.loads(result_text)
|
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():
|
async def generate_unified_summaries():
|
||||||
"""定时任务:扫描高热度事件并生成/更新摘要"""
|
"""Scheduled task: refresh summaries and topic tags for hot unified events."""
|
||||||
print(f"[{utcnow()}] 开始执行 DeepSeek 摘要生成任务...")
|
print(f"[{utcnow()}] Start unified summary generation task...")
|
||||||
|
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
recent_threshold = utcnow() - timedelta(days=3)
|
recent_threshold = utcnow() - timedelta(days=3)
|
||||||
|
|
||||||
# 必须满足:热度达标 AND (当前热度 > 上次生成摘要时的热度) AND 近期活跃
|
|
||||||
events = db.query(UnifiedEvent).filter(
|
events = db.query(UnifiedEvent).filter(
|
||||||
UnifiedEvent.hot_score >= HOT_SCORE_THRESHOLD,
|
UnifiedEvent.hot_score >= HOT_SCORE_THRESHOLD,
|
||||||
UnifiedEvent.hot_score > UnifiedEvent.last_summarized_trends_count,
|
UnifiedEvent.hot_score > UnifiedEvent.last_summarized_trends_count,
|
||||||
UnifiedEvent.created_at >= recent_threshold
|
UnifiedEvent.created_at >= recent_threshold,
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
if not events:
|
if not events:
|
||||||
print("当前没有需要更新摘要的大事件,任务结束。")
|
print("No events require summary update in this round.")
|
||||||
return
|
return
|
||||||
|
|
||||||
for event in events:
|
for event in events:
|
||||||
# 联合查询获取该事件在各平台的子新闻
|
trends = (
|
||||||
trends = db.query(TrendingEvent, InfoSource.source_name) \
|
db.query(TrendingEvent, InfoSource.source_name)
|
||||||
.join(InfoSource, TrendingEvent.source_id == InfoSource.id) \
|
.join(InfoSource, TrendingEvent.source_id == InfoSource.id)
|
||||||
.filter(TrendingEvent.unified_event_id == event.id) \
|
.filter(TrendingEvent.unified_event_id == event.id)
|
||||||
.all()
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
if not trends:
|
if not trends:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 按平台归类标题并去重
|
platform_dict: dict[str, set[str]] = {}
|
||||||
platform_dict = {}
|
|
||||||
for trend_record, source_name in trends:
|
for trend_record, source_name in trends:
|
||||||
if source_name not in platform_dict:
|
platform_dict.setdefault(source_name, set()).add(trend_record.current_headline)
|
||||||
platform_dict[source_name] = set()
|
|
||||||
platform_dict[source_name].add(trend_record.current_headline)
|
|
||||||
|
|
||||||
# 组装给大模型的 Prompt 数据
|
prompt_lines = [
|
||||||
prompt_lines = [f"【{platform}】: {', '.join(headlines)}" for platform, headlines in platform_dict.items()]
|
f"[{platform}] {', '.join(sorted(headlines))}"
|
||||||
|
for platform, headlines in platform_dict.items()
|
||||||
|
]
|
||||||
platform_data_text = "\n".join(prompt_lines)
|
platform_data_text = "\n".join(prompt_lines)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 调用封装好的异步函数
|
|
||||||
llm_result = await call_llm_for_summary(platform_data_text)
|
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"]
|
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"]
|
event.ai_comprehensive_summary = llm_result["ai_comprehensive_summary"]
|
||||||
|
|
||||||
# 成功后更新水位线
|
if event.hot_score >= TOPIC_TAG_MIN_HOT_SCORE:
|
||||||
# 将最后一次总结时的热搜数量,更新为当前最新的 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
|
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 exc:
|
||||||
|
print(f"Event {event.id} summary generation failed: {exc}")
|
||||||
except Exception as e:
|
|
||||||
print(f"大事件 ID {event.id} 摘要生成失败: {e}")
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 提交事务
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ async def send_html_email(
|
|||||||
to_email: str,
|
to_email: str,
|
||||||
subject: str,
|
subject: str,
|
||||||
html_content: str,
|
html_content: str,
|
||||||
sender_name: str = "AI 新闻早报",
|
sender_name: str = "AI 新闻",
|
||||||
sender_email: str = None
|
sender_email: str = None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""底层纯异步发送邮件工具"""
|
"""底层纯异步发送邮件工具"""
|
||||||
|
|||||||
+4
-2
@@ -1,10 +1,12 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<link rel="icon" href="/favicon.ico">
|
<link rel="icon" href="/favicon.ico">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
Generated
+34
-1
@@ -8,9 +8,11 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"apexcharts": "^5.10.3",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.29",
|
"vue": "^3.5.29",
|
||||||
"vue-router": "^5.0.3"
|
"vue-router": "^5.0.3",
|
||||||
|
"vue3-apexcharts": "^1.11.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tsconfig/node24": "^24.0.4",
|
"@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": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
@@ -2605,6 +2613,16 @@
|
|||||||
"node": ">=14"
|
"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": {
|
"node_modules/ast-kit": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz",
|
||||||
@@ -5195,6 +5213,21 @@
|
|||||||
"typescript": ">=5.0.0"
|
"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": {
|
"node_modules/webpack-virtual-modules": {
|
||||||
"version": "0.6.2",
|
"version": "0.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||||
|
|||||||
@@ -15,9 +15,11 @@
|
|||||||
"format": "prettier --write --experimental-cli src/"
|
"format": "prettier --write --experimental-cli src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"apexcharts": "^5.10.3",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.29",
|
"vue": "^3.5.29",
|
||||||
"vue-router": "^5.0.3"
|
"vue-router": "^5.0.3",
|
||||||
|
"vue3-apexcharts": "^1.11.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tsconfig/node24": "^24.0.4",
|
"@tsconfig/node24": "^24.0.4",
|
||||||
|
|||||||
@@ -9,16 +9,11 @@
|
|||||||
<style>
|
<style>
|
||||||
.page-fade-enter-active,
|
.page-fade-enter-active,
|
||||||
.page-fade-leave-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);
|
transition: opacity 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||||
}
|
|
||||||
|
|
||||||
.page-fade-enter-from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(-15px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-fade-enter-from,
|
||||||
.page-fade-leave-to {
|
.page-fade-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(15px);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`)
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -62,9 +62,6 @@ body {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
background: var(--color-background);
|
background: var(--color-background);
|
||||||
transition:
|
|
||||||
color 0.5s,
|
|
||||||
background-color 0.5s;
|
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
font-family:
|
font-family:
|
||||||
Inter,
|
Inter,
|
||||||
|
|||||||
+113
-74
@@ -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');
|
@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 {
|
:root {
|
||||||
/* 明亮模式 - 极简白与浅灰 */
|
/* 明亮模式 - 高级极简白与冷灰,去除了单调的死白 */
|
||||||
--bg-base: #f8fafc;
|
--bg-base: #f4f6f8;
|
||||||
--bg-surface: #ffffff;
|
--bg-surface: rgba(255, 255, 255, 0.85); /* 半透明表面,为毛玻璃效果打基础 */
|
||||||
--bg-input: #f1f5f9;
|
--bg-input: #ffffff;
|
||||||
|
--bg-hover: rgba(0, 0, 0, 0.04);
|
||||||
|
|
||||||
--border-subtle: #e2e8f0;
|
--border-subtle: rgba(0, 0, 0, 0.08);
|
||||||
--border-strong: #cbd5e1;
|
--border-strong: rgba(0, 0, 0, 0.15);
|
||||||
|
|
||||||
--text-primary: #0f172a;
|
--text-primary: #111827;
|
||||||
--text-secondary: #64748b;
|
--text-secondary: #4b5563;
|
||||||
--text-placeholder: #94a3b8;
|
--text-placeholder: #9ca3af;
|
||||||
|
|
||||||
--brand-primary: #4f46e5;
|
/* 品牌色优化:更具高级感的靛蓝色 */
|
||||||
--brand-primary-hover: #4338ca;
|
--brand-primary: #4338ca;
|
||||||
--brand-primary-alpha: rgba(79, 70, 229, 0.1);
|
--brand-primary-hover: #3730a3;
|
||||||
|
--brand-primary-alpha: rgba(67, 56, 202, 0.08);
|
||||||
|
|
||||||
--status-error: #ef4444;
|
--status-error: #ef4444;
|
||||||
--status-success: #10b981;
|
--status-success: #10b981;
|
||||||
|
|
||||||
/* 现代柔和扩散阴影 */
|
/* 现代柔和长弥散阴影(Apple 风格) */
|
||||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
--shadow-sm: 0 1px 3px rgba(0,0,0,0.05), 0 1px 2px rgba(0,0,0,0.03);
|
||||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
|
--shadow-md: 0 4px 12px rgba(0,0,0,0.04), 0 2px 4px rgba(0,0,0,0.02);
|
||||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.05), 0 8px 10px -6px rgba(0, 0, 0, 0.01);
|
--shadow-xl: 0 20px 40px rgba(0,0,0,0.08), 0 8px 16px rgba(0,0,0,0.04);
|
||||||
|
|
||||||
--radius-md: 8px;
|
--radius-sm: 6px;
|
||||||
--radius-lg: 12px;
|
--radius-md: 10px;
|
||||||
--radius-xl: 16px;
|
--radius-lg: 14px;
|
||||||
|
--radius-xl: 20px;
|
||||||
|
|
||||||
|
--backdrop-blur: blur(12px);
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark {
|
html.dark {
|
||||||
/* 暗黑模式 - 深邃黑与暗石板色 */
|
/* 暗黑模式 - 纯粹的深邃黑与极光蓝 */
|
||||||
--bg-base: #020617;
|
--bg-base: #09090b;
|
||||||
--bg-surface: #0f172a;
|
--bg-surface: rgba(24, 24, 27, 0.7);
|
||||||
--bg-input: #1e293b;
|
--bg-input: rgba(255, 255, 255, 0.05);
|
||||||
|
--bg-hover: rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
--border-subtle: #1e293b;
|
--border-subtle: rgba(255, 255, 255, 0.1);
|
||||||
--border-strong: #334155;
|
--border-strong: rgba(255, 255, 255, 0.2);
|
||||||
|
|
||||||
--text-primary: #f8fafc;
|
--text-primary: #f9fafb;
|
||||||
--text-secondary: #94a3b8;
|
--text-secondary: #a1a1aa;
|
||||||
--text-placeholder: #475569;
|
--text-placeholder: #52525b;
|
||||||
|
|
||||||
--brand-primary: #6366f1;
|
--brand-primary: #818cf8;
|
||||||
--brand-primary-hover: #818cf8;
|
--brand-primary-hover: #a5b4fc;
|
||||||
--brand-primary-alpha: rgba(99, 102, 241, 0.15);
|
--brand-primary-alpha: rgba(129, 140, 248, 0.15);
|
||||||
|
|
||||||
--status-error: #f87171;
|
--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;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
min-height: 100vh;
|
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);
|
background-color: var(--bg-base);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
transition: background-color 0.3s ease, color 0.3s ease;
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
@@ -92,23 +118,23 @@ button {
|
|||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================
|
/* =========================================
|
||||||
高级背景环境光与数据网格
|
高级背景环境光与数据网格 (极简唯美风)
|
||||||
========================================= */
|
========================================= */
|
||||||
body::before {
|
body::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: -2;
|
z-index: -2;
|
||||||
/* 绘制点阵网格 */
|
/* 高级细腻的点阵网格 */
|
||||||
background-image: radial-gradient(var(--border-strong) 1px, transparent 1px);
|
background-image: radial-gradient(var(--border-strong) 1px, transparent 1px);
|
||||||
background-size: 24px 24px;
|
background-size: 28px 28px;
|
||||||
/* 使用遮罩让网格在四周自然淡出,避免边缘生硬 */
|
mask-image: radial-gradient(ellipse 80% 80% at 50% -10%, black 10%, transparent 80%);
|
||||||
mask-image: radial-gradient(ellipse 80% 80% at 50% -20%, black 20%, transparent 80%);
|
-webkit-mask-image: radial-gradient(ellipse 80% 80% at 50% -10%, black 10%, transparent 80%);
|
||||||
-webkit-mask-image: radial-gradient(ellipse 80% 80% at 50% -20%, black 20%, transparent 80%);
|
opacity: 0.3;
|
||||||
opacity: 0.4;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,46 +142,47 @@ html.dark body::before {
|
|||||||
opacity: 0.15;
|
opacity: 0.15;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 极弱的雷达扫射呼吸环境光 */
|
/* 更为克制的极光呼吸环境光 */
|
||||||
body::after {
|
body::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: -50%;
|
top: -60%;
|
||||||
left: -20%;
|
left: -30%;
|
||||||
right: -20%;
|
right: -30%;
|
||||||
height: 100vh;
|
height: 120vh;
|
||||||
z-index: -3;
|
z-index: -3;
|
||||||
background: radial-gradient(ellipse at bottom, var(--brand-primary-alpha) 0%, transparent 60%);
|
background: radial-gradient(ellipse at bottom, var(--brand-primary-alpha) 0%, transparent 50%);
|
||||||
opacity: 0.6;
|
opacity: 0.5;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
animation: ambient-pulse 8s ease-in-out infinite alternate;
|
animation: ambient-pulse 10s ease-in-out infinite alternate;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes ambient-pulse {
|
@keyframes ambient-pulse {
|
||||||
0% {
|
0% {
|
||||||
transform: scale(1);
|
transform: scale(1) translateY(0);
|
||||||
opacity: 0.4;
|
opacity: 0.3;
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
transform: scale(1.05);
|
transform: scale(1.05) translateY(-2%);
|
||||||
opacity: 0.7;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================
|
/* =========================================
|
||||||
3. 现代表单控件体系
|
3. 现代表单控件体系 (磨砂与无边框风格)
|
||||||
========================================= */
|
========================================= */
|
||||||
.input-group {
|
.input-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-label {
|
.input-label {
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
letter-spacing: 0.03em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-wrapper {
|
.input-wrapper {
|
||||||
@@ -166,17 +193,19 @@ body::after {
|
|||||||
|
|
||||||
.input-field {
|
.input-field {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px 14px;
|
padding: 14px 16px;
|
||||||
background-color: var(--bg-input);
|
background-color: var(--bg-input);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 15px;
|
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 {
|
.input-field::placeholder {
|
||||||
color: var(--text-placeholder);
|
color: var(--text-placeholder);
|
||||||
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-field:hover {
|
.input-field:hover {
|
||||||
@@ -186,7 +215,7 @@ body::after {
|
|||||||
.input-field:focus {
|
.input-field:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--brand-primary);
|
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);
|
background-color: var(--bg-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,11 +223,11 @@ body::after {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
right: 12px;
|
right: 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
color: var(--brand-primary);
|
color: var(--brand-primary);
|
||||||
padding: 4px 8px;
|
padding: 6px 10px;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
transition: background 0.2s;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-action-btn:hover:not(:disabled) {
|
.input-action-btn:hover:not(:disabled) {
|
||||||
@@ -210,31 +239,41 @@ body::after {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 主按钮的高级拟物渐变效果 */
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px;
|
padding: 14px;
|
||||||
background-color: var(--brand-primary);
|
background: linear-gradient(135deg, var(--brand-primary) 0%, var(--brand-primary-hover) 100%);
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
border-radius: var(--radius-md);
|
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;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: 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) {
|
.btn-primary:hover:not(:disabled) {
|
||||||
background-color: var(--brand-primary-hover);
|
transform: translateY(-2px);
|
||||||
transform: translateY(-1px);
|
box-shadow: 0 8px 20px var(--brand-primary-alpha);
|
||||||
box-shadow: var(--shadow-md);
|
filter: brightness(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:active:not(:disabled) {
|
.btn-primary:active:not(:disabled) {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 2px 8px var(--brand-primary-alpha);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:disabled {
|
.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;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,20 @@ function localizeMessage(message: string): string {
|
|||||||
return MESSAGE_MAP[message] ?? message
|
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> {
|
async function request<T>(path: string, payload: JsonValue): Promise<T> {
|
||||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -47,7 +61,17 @@ async function request<T>(path: string, payload: JsonValue): Promise<T> {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const detail = data.detail
|
const detail = data.detail
|
||||||
if (typeof detail === 'string') {
|
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('请求失败,请稍后重试')
|
throw new Error('请求失败,请稍后重试')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,392 +1,149 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
|
||||||
const themeStore = useThemeStore()
|
const themeStore = useThemeStore()
|
||||||
const isAnimating = ref(false)
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换主题,使用 View Transitions API 实现高级扩散动画(如果浏览器支持)
|
||||||
|
* 这种动画比之前像玩具一样的开关要高级得多,提供原生级的丝滑过渡
|
||||||
|
*/
|
||||||
function handleToggle(event: MouseEvent) {
|
function handleToggle(event: MouseEvent) {
|
||||||
const root = document.documentElement
|
// 检查浏览器是否支持 document.startViewTransition 并且用户没有开启减弱动画
|
||||||
root.style.setProperty('--theme-flash-x', `${event.clientX}px`)
|
const isAppearanceTransition = typeof document !== 'undefined' &&
|
||||||
root.style.setProperty('--theme-flash-y', `${event.clientY}px`)
|
'startViewTransition' in document &&
|
||||||
|
!window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
|
||||||
isAnimating.value = true
|
if (!isAppearanceTransition) {
|
||||||
themeStore.toggleTheme()
|
// 降级处理:直接切换
|
||||||
|
themeStore.toggleTheme()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
window.setTimeout(() => {
|
const x = event.clientX
|
||||||
isAnimating.value = false
|
const y = event.clientY
|
||||||
}, 520)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button
|
<button
|
||||||
class="theme-toggle"
|
class="theme-toggle-btn"
|
||||||
:class="{ 'is-dark': themeStore.isDark, 'is-animating': isAnimating }"
|
|
||||||
type="button"
|
|
||||||
:aria-label="themeStore.isDark ? '切换到浅色模式' : '切换到暗黑模式'"
|
:aria-label="themeStore.isDark ? '切换到浅色模式' : '切换到暗黑模式'"
|
||||||
|
title="切换显示模式"
|
||||||
@click="handleToggle"
|
@click="handleToggle"
|
||||||
>
|
>
|
||||||
<span class="toggle-track">
|
<div class="icon-container">
|
||||||
<span class="track-glow"></span>
|
<i class="fa-solid fa-sun sun-icon" :class="{ 'is-hidden': themeStore.isDark }"></i>
|
||||||
<span class="track-stars"></span>
|
<i class="fa-solid fa-moon moon-icon" :class="{ 'is-hidden': !themeStore.isDark }"></i>
|
||||||
<span class="spark-layer">
|
</div>
|
||||||
<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>
|
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.theme-toggle {
|
/* ==========================================
|
||||||
border: 1px solid var(--surface-border);
|
极简且高级的毛玻璃材质主题切换按钮
|
||||||
background: var(--surface);
|
========================================== */
|
||||||
color: var(--text);
|
.theme-toggle-btn {
|
||||||
border-radius: 14px;
|
position: relative;
|
||||||
padding: 6px 10px 6px 8px;
|
display: flex;
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
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;
|
cursor: pointer;
|
||||||
position: relative;
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: transform 220ms ease, border-color 220ms ease, box-shadow 220ms ease;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-toggle:hover {
|
.theme-toggle-btn:hover {
|
||||||
transform: translateY(-1px);
|
color: var(--text-primary);
|
||||||
border-color: var(--primary);
|
border-color: var(--border-strong);
|
||||||
box-shadow: 0 8px 24px color-mix(in srgb, var(--primary) 24%, transparent);
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-toggle:active {
|
.theme-toggle-btn:active {
|
||||||
transform: translateY(0) scale(0.98);
|
transform: translateY(0);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-track {
|
.icon-container {
|
||||||
width: 58px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: linear-gradient(120deg, #d4e4ff, #b2c6ff);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.45);
|
|
||||||
padding: 3px;
|
|
||||||
position: relative;
|
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;
|
width: 20px;
|
||||||
height: 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;
|
.sun-icon, .moon-icon {
|
||||||
opacity: 0.4;
|
|
||||||
background: rgba(166, 195, 255, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-stars {
|
|
||||||
position: absolute;
|
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;
|
opacity: 0;
|
||||||
transition: opacity 260ms ease;
|
transform: translate(-50%, -50%) rotate(90deg) scale(0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-stars::before,
|
.moon-icon.is-hidden {
|
||||||
.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);
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
transform: translate(-50%, -50%) rotate(-90deg) scale(0.5);
|
||||||
|
}
|
||||||
.spark:nth-child(1) {
|
</style>
|
||||||
left: 22px;
|
|
||||||
top: 14px;
|
<style>
|
||||||
}
|
/* ==========================================
|
||||||
|
全局 View Transitions API 动画样式
|
||||||
.spark:nth-child(2) {
|
控制页面级别的黑白模式无缝扩散切换
|
||||||
left: 30px;
|
========================================== */
|
||||||
top: 11px;
|
::view-transition-old(root),
|
||||||
}
|
::view-transition-new(root) {
|
||||||
|
animation: none;
|
||||||
.spark:nth-child(3) {
|
mix-blend-mode: normal;
|
||||||
left: 26px;
|
}
|
||||||
top: 19px;
|
|
||||||
}
|
::view-transition-old(root) {
|
||||||
|
z-index: 1;
|
||||||
.theme-toggle.is-animating .spark:nth-child(1) {
|
}
|
||||||
animation: spark-burst-1 480ms ease-out;
|
::view-transition-new(root) {
|
||||||
}
|
z-index: 9999;
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -2,36 +2,54 @@ import { createRouter, createWebHistory } from 'vue-router'
|
|||||||
|
|
||||||
import { pinia } from '@/stores'
|
import { pinia } from '@/stores'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import HomeView from '@/views/HomeView.vue'
|
import DashboardLayout from '@/layouts/DashboardLayout.vue'
|
||||||
import LoginView from '@/views/LoginView.vue'
|
import LoginView from '@/views/LoginView.vue'
|
||||||
import RegisterView from '@/views/RegisterView.vue'
|
import RegisterView from '@/views/RegisterView.vue'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
routes: [
|
routes: [
|
||||||
{
|
// 认证页面(不使用仪表盘布局)
|
||||||
path: '/',
|
|
||||||
name: 'home',
|
|
||||||
component: HomeView,
|
|
||||||
meta: {
|
|
||||||
requiresAuth: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
name: 'login',
|
name: 'login',
|
||||||
component: LoginView,
|
component: LoginView,
|
||||||
meta: {
|
meta: { guestOnly: true },
|
||||||
guestOnly: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/register',
|
path: '/register',
|
||||||
name: 'register',
|
name: 'register',
|
||||||
component: RegisterView,
|
component: RegisterView,
|
||||||
meta: {
|
meta: { guestOnly: true },
|
||||||
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()
|
authStore.restore()
|
||||||
|
|
||||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||||
return {
|
return { name: 'login', query: { redirect: to.fullPath } }
|
||||||
name: 'login',
|
|
||||||
query: {
|
|
||||||
redirect: to.fullPath,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (to.meta.guestOnly && authStore.isAuthenticated) {
|
if (to.meta.guestOnly && authStore.isAuthenticated) {
|
||||||
return { name: 'home' }
|
return { name: 'dashboard' }
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
@@ -40,8 +40,8 @@ watch(loginMode, () => {
|
|||||||
successMessage.value = ''
|
successMessage.value = ''
|
||||||
})
|
})
|
||||||
|
|
||||||
function startCooldown() {
|
function startCooldown(seconds = CODE_RESEND_SECONDS) {
|
||||||
countdown.value = CODE_RESEND_SECONDS
|
countdown.value = Math.max(1, seconds)
|
||||||
if (countdownTimer) {
|
if (countdownTimer) {
|
||||||
clearInterval(countdownTimer)
|
clearInterval(countdownTimer)
|
||||||
}
|
}
|
||||||
@@ -88,6 +88,10 @@ async function handleSendLoginCode() {
|
|||||||
successMessage.value = result.message || '验证码已发送'
|
successMessage.value = result.message || '验证码已发送'
|
||||||
startCooldown()
|
startCooldown()
|
||||||
} catch (error) {
|
} 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 : '验证码发送失败,请稍后重试'
|
errorMessage.value = error instanceof Error ? error.message : '验证码发送失败,请稍后重试'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,17 +265,21 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* ==========================================
|
||||||
|
全新高级分屏布局与背景
|
||||||
|
========================================== */
|
||||||
.split-layout {
|
.split-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
background: var(--bg-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-panel {
|
.brand-panel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: none;
|
display: none;
|
||||||
background-color: var(--bg-surface);
|
background: linear-gradient(135deg, var(--bg-surface), var(--bg-base));
|
||||||
border-right: 1px solid var(--border-subtle);
|
border-right: 1px solid var(--border-subtle);
|
||||||
padding: 60px;
|
padding: 80px;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -287,43 +295,62 @@ onUnmounted(() => {
|
|||||||
.brand-content {
|
.brand-content {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
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 {
|
.logo {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
font-size: 26px;
|
font-size: 28px;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
margin-bottom: 60px;
|
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 {
|
.brand-title {
|
||||||
font-size: 40px;
|
font-size: 48px;
|
||||||
line-height: 1.2;
|
line-height: 1.1;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.03em;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-desc {
|
.brand-desc {
|
||||||
font-size: 16px;
|
font-size: 18px;
|
||||||
line-height: 1.6;
|
line-height: 1.7;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ambient-glow {
|
.ambient-glow {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
right: -20%;
|
right: -10%;
|
||||||
width: 600px;
|
width: 80vw;
|
||||||
height: 600px;
|
height: 80vw;
|
||||||
background: radial-gradient(circle, var(--brand-primary-alpha) 0%, transparent 60%);
|
background: radial-gradient(circle, var(--brand-primary-alpha) 0%, transparent 60%);
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
filter: blur(60px);
|
filter: blur(80px);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
pointer-events: none;
|
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 {
|
.form-panel {
|
||||||
@@ -331,64 +358,71 @@ onUnmounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
backdrop-filter: var(--backdrop-blur);
|
||||||
|
-webkit-backdrop-filter: var(--backdrop-blur);
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-actions {
|
.top-actions {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 24px;
|
top: 32px;
|
||||||
right: 24px;
|
right: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-container {
|
.form-container {
|
||||||
margin: auto;
|
margin: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 420px;
|
max-width: 440px;
|
||||||
padding: 40px 24px;
|
padding: 40px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-header {
|
.form-header {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-header h2 {
|
.form-header h2 {
|
||||||
font-size: 28px;
|
font-size: 32px;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
margin: 0 0 8px 0;
|
margin: 0 0 12px 0;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-header p {
|
.form-header p {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 15px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-mode-tabs {
|
.login-mode-tabs {
|
||||||
margin-bottom: 18px;
|
margin-bottom: 24px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-btn {
|
.mode-btn {
|
||||||
height: 40px;
|
height: 44px;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
border: 1px solid var(--border-subtle);
|
border: none;
|
||||||
background: var(--bg-input);
|
background: transparent;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-btn.active {
|
.mode-btn.active {
|
||||||
border-color: var(--brand-primary);
|
background: var(--bg-surface);
|
||||||
background: var(--brand-primary-alpha);
|
|
||||||
color: var(--brand-primary);
|
color: var(--brand-primary);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-btn:hover {
|
.mode-btn:hover:not(.active) {
|
||||||
border-color: var(--border-strong);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-form {
|
.auth-form {
|
||||||
|
|||||||
@@ -52,8 +52,8 @@ const strengthColor = computed(() => {
|
|||||||
|
|
||||||
// ==========================
|
// ==========================
|
||||||
|
|
||||||
function startCooldown() {
|
function startCooldown(seconds = CODE_RESEND_SECONDS) {
|
||||||
countdown.value = CODE_RESEND_SECONDS
|
countdown.value = Math.max(1, seconds)
|
||||||
if (countdownTimer) clearInterval(countdownTimer)
|
if (countdownTimer) clearInterval(countdownTimer)
|
||||||
countdownTimer = setInterval(() => {
|
countdownTimer = setInterval(() => {
|
||||||
countdown.value -= 1
|
countdown.value -= 1
|
||||||
@@ -76,6 +76,10 @@ async function handleSendCode() {
|
|||||||
successMessage.value = result.message || '验证码已发送'
|
successMessage.value = result.message || '验证码已发送'
|
||||||
startCooldown()
|
startCooldown()
|
||||||
} catch (error) {
|
} 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 : '验证码发送失败,请稍后重试'
|
errorMessage.value = error instanceof Error ? error.message : '验证码发送失败,请稍后重试'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,18 +246,21 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 大部分样式复用 Login 的 split-layout 体系 */
|
/* ==========================================
|
||||||
|
全新高级分屏布局与背景
|
||||||
|
========================================== */
|
||||||
.split-layout {
|
.split-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
background: var(--bg-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-panel {
|
.brand-panel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: none;
|
display: none;
|
||||||
background-color: var(--bg-surface);
|
background: linear-gradient(135deg, var(--bg-surface), var(--bg-base));
|
||||||
border-right: 1px solid var(--border-subtle);
|
border-right: 1px solid var(--border-subtle);
|
||||||
padding: 60px;
|
padding: 80px;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -269,50 +276,63 @@ onUnmounted(() => {
|
|||||||
.brand-content {
|
.brand-content {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
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 {
|
.logo {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
font-size: 26px;
|
font-size: 28px;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
margin-bottom: 60px;
|
margin-bottom: 40px;
|
||||||
}
|
letter-spacing: -0.02em;
|
||||||
|
background: linear-gradient(to right, var(--text-primary), var(--text-secondary));
|
||||||
.logo-dot {
|
-webkit-background-clip: text;
|
||||||
width: 14px;
|
color: transparent;
|
||||||
height: 14px;
|
|
||||||
background: var(--brand-primary);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-title {
|
.brand-title {
|
||||||
font-size: 40px;
|
font-size: 48px;
|
||||||
line-height: 1.2;
|
line-height: 1.1;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.03em;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-desc {
|
.brand-desc {
|
||||||
font-size: 16px;
|
font-size: 18px;
|
||||||
line-height: 1.6;
|
line-height: 1.7;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ambient-glow {
|
.ambient-glow {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 60%;
|
top: 50%;
|
||||||
left: -10%;
|
left: -10%;
|
||||||
width: 500px;
|
width: 80vw;
|
||||||
height: 500px;
|
height: 80vw;
|
||||||
background: radial-gradient(circle, var(--brand-primary-alpha) 0%, transparent 60%);
|
background: radial-gradient(circle, var(--brand-primary-alpha) 0%, transparent 60%);
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
filter: blur(50px);
|
filter: blur(80px);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
pointer-events: none;
|
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 {
|
.form-panel {
|
||||||
@@ -320,18 +340,21 @@ onUnmounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
backdrop-filter: var(--backdrop-blur);
|
||||||
|
-webkit-backdrop-filter: var(--backdrop-blur);
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-actions {
|
.top-actions {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 24px;
|
top: 32px;
|
||||||
right: 24px;
|
right: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-container {
|
.form-container {
|
||||||
margin: auto;
|
margin: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 400px;
|
max-width: 440px;
|
||||||
padding: 40px 24px;
|
padding: 40px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,15 +363,16 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-header h2 {
|
.form-header h2 {
|
||||||
font-size: 28px;
|
font-size: 32px;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
margin: 0 0 8px 0;
|
margin: 0 0 12px 0;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-header p {
|
.form-header p {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 15px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 现代密码强度条 */
|
/* 现代密码强度条 */
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -8,12 +8,15 @@ import vueDevTools from 'vite-plugin-vue-devtools'
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
vue(),
|
vue(),
|
||||||
vueDevTools(),
|
// vueDevTools(),
|
||||||
],
|
],
|
||||||
server: {
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://127.0.0.1:8000',
|
target: 'http://10.252.130.135:8000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user