mirror of
https://github.com/stardrophere/InsightRadar.git
synced 2026-06-05 23:56:36 +08:00
login+ai cluster
This commit is contained in:
+1
-1
@@ -10,7 +10,7 @@ __pycache__/
|
||||
.idea
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
.vscode/
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# app/api/dependencies.py
|
||||
from app.database import SessionLocal
|
||||
|
||||
def get_db():
|
||||
"""
|
||||
FastAPI 依赖注入:为每个 HTTP 请求提供独立的数据库会话。
|
||||
请求处理完成后自动关闭,防止连接泄漏。
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,238 @@
|
||||
import os
|
||||
from datetime import timedelta
|
||||
from typing import Tuple
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.dependencies import get_db
|
||||
from app.core.security import (
|
||||
create_access_token,
|
||||
generate_verification_code,
|
||||
hash_password,
|
||||
hash_verification_code,
|
||||
verify_password,
|
||||
verify_verification_code,
|
||||
)
|
||||
from app.models.models import AppUser, EmailVerificationCode, VerificationPurpose, utcnow
|
||||
from app.schemas.auth_schema import (
|
||||
AuthTokenResponse,
|
||||
LoginCodeSendRequest,
|
||||
LoginRequest,
|
||||
LoginWithCodeRequest,
|
||||
MessageResponse,
|
||||
RegisterCodeSendRequest,
|
||||
RegisterRequest,
|
||||
UserProfileResponse,
|
||||
)
|
||||
from app.utils.email_utils import send_html_email
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
REGISTER_CODE_EXPIRE_MINUTES = int(os.getenv("REGISTER_CODE_EXPIRE_MINUTES", "10"))
|
||||
LOGIN_CODE_EXPIRE_MINUTES = int(os.getenv("LOGIN_CODE_EXPIRE_MINUTES", "10"))
|
||||
|
||||
|
||||
def _normalize_email(email: str) -> str:
|
||||
return email.strip().lower()
|
||||
|
||||
|
||||
def _build_verification_email(code: str, purpose_text: str, expire_minutes: int) -> str:
|
||||
return f"""
|
||||
<div style="font-family: Arial, sans-serif; line-height: 1.6; color: #222;">
|
||||
<h2 style="margin-bottom: 12px;">InsightRadar Email Verification</h2>
|
||||
<p>Your {purpose_text} verification code is:</p>
|
||||
<p style="font-size: 28px; font-weight: bold; letter-spacing: 4px; color: #0b57d0;">{code}</p>
|
||||
<p>The code is valid for {expire_minutes} minutes. Do not share it with others.</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def _invalidate_unused_codes(db: Session, email: str, purpose: VerificationPurpose) -> None:
|
||||
db.query(EmailVerificationCode).filter(
|
||||
EmailVerificationCode.email == email,
|
||||
EmailVerificationCode.purpose == purpose,
|
||||
EmailVerificationCode.is_used.is_(False),
|
||||
).update({EmailVerificationCode.is_used: True}, synchronize_session=False)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _create_code_record(
|
||||
db: Session,
|
||||
*,
|
||||
email: str,
|
||||
purpose: VerificationPurpose,
|
||||
expire_minutes: int,
|
||||
) -> Tuple[EmailVerificationCode, str]:
|
||||
code = generate_verification_code()
|
||||
now = utcnow()
|
||||
code_record = EmailVerificationCode(
|
||||
email=email,
|
||||
purpose=purpose,
|
||||
code_hash=hash_verification_code(code),
|
||||
expires_at=now + timedelta(minutes=expire_minutes),
|
||||
)
|
||||
db.add(code_record)
|
||||
db.commit()
|
||||
return code_record, code
|
||||
|
||||
|
||||
def _build_auth_response(user: AppUser) -> AuthTokenResponse:
|
||||
token, expires_in = create_access_token(user_id=user.id, email=user.email)
|
||||
return AuthTokenResponse(
|
||||
access_token=token,
|
||||
expires_in=expires_in,
|
||||
user=UserProfileResponse.model_validate(user),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/register/send-code", response_model=MessageResponse)
|
||||
async def send_register_code(payload: RegisterCodeSendRequest, db: Session = Depends(get_db)):
|
||||
email = _normalize_email(payload.email)
|
||||
|
||||
existing_user = db.query(AppUser).filter(AppUser.email == email).first()
|
||||
if existing_user:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email is already registered")
|
||||
|
||||
_invalidate_unused_codes(db, email, VerificationPurpose.REGISTER)
|
||||
code_record, code = _create_code_record(
|
||||
db,
|
||||
email=email,
|
||||
purpose=VerificationPurpose.REGISTER,
|
||||
expire_minutes=REGISTER_CODE_EXPIRE_MINUTES,
|
||||
)
|
||||
|
||||
email_sent = await send_html_email(
|
||||
to_email=email,
|
||||
subject="InsightRadar Registration Code",
|
||||
html_content=_build_verification_email(code, "registration", REGISTER_CODE_EXPIRE_MINUTES),
|
||||
)
|
||||
if not email_sent:
|
||||
code_record.is_used = True
|
||||
db.add(code_record)
|
||||
db.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to send verification code",
|
||||
)
|
||||
|
||||
return MessageResponse(message="Verification code sent")
|
||||
|
||||
|
||||
@router.post("/login/send-code", response_model=MessageResponse)
|
||||
async def send_login_code(payload: LoginCodeSendRequest, db: Session = Depends(get_db)):
|
||||
email = _normalize_email(payload.email)
|
||||
user = db.query(AppUser).filter(AppUser.email == email).first()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Email is not registered")
|
||||
|
||||
_invalidate_unused_codes(db, email, VerificationPurpose.LOGIN)
|
||||
code_record, code = _create_code_record(
|
||||
db,
|
||||
email=email,
|
||||
purpose=VerificationPurpose.LOGIN,
|
||||
expire_minutes=LOGIN_CODE_EXPIRE_MINUTES,
|
||||
)
|
||||
|
||||
email_sent = await send_html_email(
|
||||
to_email=email,
|
||||
subject="InsightRadar Login Code",
|
||||
html_content=_build_verification_email(code, "login", LOGIN_CODE_EXPIRE_MINUTES),
|
||||
)
|
||||
if not email_sent:
|
||||
code_record.is_used = True
|
||||
db.add(code_record)
|
||||
db.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to send verification code",
|
||||
)
|
||||
|
||||
return MessageResponse(message="Verification code sent")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/register",
|
||||
response_model=AuthTokenResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def register(payload: RegisterRequest, db: Session = Depends(get_db)):
|
||||
email = _normalize_email(payload.email)
|
||||
existing_user = db.query(AppUser).filter(AppUser.email == email).first()
|
||||
if existing_user:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email is already registered")
|
||||
|
||||
now = utcnow()
|
||||
code_record = db.query(EmailVerificationCode).filter(
|
||||
EmailVerificationCode.email == email,
|
||||
EmailVerificationCode.purpose == VerificationPurpose.REGISTER,
|
||||
EmailVerificationCode.is_used.is_(False),
|
||||
EmailVerificationCode.expires_at >= now,
|
||||
).order_by(EmailVerificationCode.created_at.desc()).first()
|
||||
|
||||
if not code_record:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Verification code does not exist or expired")
|
||||
|
||||
if not verify_verification_code(payload.verification_code, code_record.code_hash):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid verification code")
|
||||
|
||||
nickname = payload.nickname or email.split("@")[0]
|
||||
user = AppUser(
|
||||
email=email,
|
||||
password_hash=hash_password(payload.password),
|
||||
nickname=nickname,
|
||||
metadata_={"email_verified_at": now.isoformat()},
|
||||
)
|
||||
|
||||
code_record.is_used = True
|
||||
db.add(user)
|
||||
db.add(code_record)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
return _build_auth_response(user)
|
||||
|
||||
|
||||
@router.post("/login", response_model=AuthTokenResponse)
|
||||
async def login(payload: LoginRequest, db: Session = Depends(get_db)):
|
||||
email = _normalize_email(payload.email)
|
||||
user = db.query(AppUser).filter(AppUser.email == email).first()
|
||||
|
||||
if not user or not user.password_hash:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
|
||||
|
||||
if not verify_password(payload.password, user.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
|
||||
|
||||
return _build_auth_response(user)
|
||||
|
||||
|
||||
@router.post("/login/code", response_model=AuthTokenResponse)
|
||||
async def login_with_code(payload: LoginWithCodeRequest, db: Session = Depends(get_db)):
|
||||
email = _normalize_email(payload.email)
|
||||
user = db.query(AppUser).filter(AppUser.email == email).first()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or verification code")
|
||||
|
||||
now = utcnow()
|
||||
code_record = db.query(EmailVerificationCode).filter(
|
||||
EmailVerificationCode.email == email,
|
||||
EmailVerificationCode.purpose == VerificationPurpose.LOGIN,
|
||||
EmailVerificationCode.is_used.is_(False),
|
||||
EmailVerificationCode.expires_at >= now,
|
||||
).order_by(EmailVerificationCode.created_at.desc()).first()
|
||||
|
||||
if not code_record:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Verification code does not exist or expired")
|
||||
|
||||
if not verify_verification_code(payload.verification_code, code_record.code_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or verification code")
|
||||
|
||||
code_record.is_used = True
|
||||
db.add(code_record)
|
||||
db.commit()
|
||||
|
||||
return _build_auth_response(user)
|
||||
@@ -0,0 +1,69 @@
|
||||
# app/api/endpoints/events.py
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import timedelta
|
||||
from typing import List
|
||||
|
||||
from app.api.dependencies import get_db
|
||||
from app.models.models import UnifiedEvent, TrendingEvent, InfoSource, RankingLog, utcnow
|
||||
# 导入你上传的 Schema
|
||||
from app.schemas.event_schema import UnifiedEventResponse, PlatformTrendResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/unified", response_model=List[UnifiedEventResponse])
|
||||
def list_unified_events(
|
||||
min_hot: int = Query(5, description="热度过滤阈值"),
|
||||
hours: int = Query(24, description="查询过去 X 小时的数据"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
获取聚合大事件列表,完全适配前端 template.html 所需的数据结构
|
||||
"""
|
||||
# 计算时间水位线
|
||||
time_limit = utcnow() - timedelta(hours=hours)
|
||||
|
||||
# 1. 查询大事件(按热度降序,且满足时间范围)
|
||||
events = db.query(UnifiedEvent).filter(
|
||||
UnifiedEvent.hot_score >= min_hot,
|
||||
UnifiedEvent.created_at >= time_limit
|
||||
).order_by(UnifiedEvent.hot_score.desc()).all()
|
||||
|
||||
results = []
|
||||
for ev in events:
|
||||
# 2. 联表查询:获取该大事件下关联的所有平台及其具体热搜信息
|
||||
trends = db.query(TrendingEvent, InfoSource.source_name).join(
|
||||
InfoSource, TrendingEvent.source_id == InfoSource.id
|
||||
).filter(TrendingEvent.unified_event_id == ev.id).all()
|
||||
|
||||
platform_list = []
|
||||
for trend, s_name in trends:
|
||||
# 3. 获取排名历史轨迹 (用于前端渲染)
|
||||
# 这里的排序顺序 asc 保证了数组从旧到新
|
||||
logs = db.query(RankingLog.ranking_position).filter(
|
||||
RankingLog.event_id == trend.id,
|
||||
RankingLog.observed_at >= time_limit
|
||||
).order_by(RankingLog.observed_at.asc()).all()
|
||||
|
||||
# 组装符合 PlatformTrendResponse 结构的字典
|
||||
platform_list.append(PlatformTrendResponse(
|
||||
source_id=trend.source_id,
|
||||
platform_name=s_name,
|
||||
headline=trend.current_headline,
|
||||
url=trend.event_url,
|
||||
current_ranking=trend.current_ranking,
|
||||
ranking_history=[log[0] for log in logs]
|
||||
))
|
||||
|
||||
# 4. 组装符合 UnifiedEventResponse 结构的字典
|
||||
results.append(UnifiedEventResponse(
|
||||
event_id=ev.id,
|
||||
unified_title=ev.unified_title if ev.unified_title else "暂无标题",
|
||||
summary=ev.ai_comprehensive_summary,
|
||||
hot_score=ev.hot_score,
|
||||
created_at=ev.created_at,
|
||||
platforms=platform_list
|
||||
))
|
||||
|
||||
return results
|
||||
@@ -4,7 +4,7 @@ from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from app.database import get_db
|
||||
from app.schemas.schemas import (
|
||||
from app.schemas.source_schema import (
|
||||
InfoSourceCreate, InfoSourceUpdate, InfoSourceResponse, PaginatedResponse
|
||||
)
|
||||
from app.crud import crud_source
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
# app/api/router.py
|
||||
from fastapi import APIRouter
|
||||
from app.api.endpoints import sources
|
||||
from app.api.endpoints import auth, sources, events
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
# 信息源管理
|
||||
api_router.include_router(sources.router, prefix="/sources", tags=["信息源管理"])
|
||||
api_router.include_router(sources.router, prefix="/sources", tags=["信息源管理"])
|
||||
|
||||
# 注册大事件相关的路由
|
||||
api_router.include_router(events.router, prefix="/events", tags=["Unified Events"])
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["Auth"])
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
PASSWORD_HASH_ITERATIONS = int(os.getenv("PASSWORD_HASH_ITERATIONS", "120000"))
|
||||
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"))
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
salt = secrets.token_hex(16)
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
password.encode("utf-8"),
|
||||
salt.encode("utf-8"),
|
||||
PASSWORD_HASH_ITERATIONS,
|
||||
)
|
||||
return (
|
||||
f"pbkdf2_sha256${PASSWORD_HASH_ITERATIONS}${salt}$"
|
||||
f"{base64.urlsafe_b64encode(digest).decode('utf-8')}"
|
||||
)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
algorithm, iterations, salt, expected = password_hash.split("$", 3)
|
||||
if algorithm != "pbkdf2_sha256":
|
||||
return False
|
||||
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
plain_password.encode("utf-8"),
|
||||
salt.encode("utf-8"),
|
||||
int(iterations),
|
||||
)
|
||||
calculated = base64.urlsafe_b64encode(digest).decode("utf-8")
|
||||
return hmac.compare_digest(calculated, expected)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def generate_verification_code(length: int = 6) -> str:
|
||||
return "".join(secrets.choice("0123456789") for _ in range(length))
|
||||
|
||||
|
||||
def hash_verification_code(code: str) -> str:
|
||||
return hashlib.sha256(code.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def verify_verification_code(code: str, expected_hash: str) -> bool:
|
||||
return hmac.compare_digest(hash_verification_code(code), expected_hash)
|
||||
|
||||
|
||||
def _urlsafe_b64encode(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).decode("utf-8").rstrip("=")
|
||||
|
||||
|
||||
def create_access_token(user_id: int, email: str) -> Tuple[str, int]:
|
||||
expires_in = AUTH_TOKEN_EXPIRE_MINUTES * 60
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"email": email,
|
||||
"exp": int(time.time()) + expires_in,
|
||||
}
|
||||
payload_bytes = json.dumps(payload, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
|
||||
encoded_payload = _urlsafe_b64encode(payload_bytes)
|
||||
signature = hmac.new(
|
||||
AUTH_SECRET_KEY.encode("utf-8"),
|
||||
encoded_payload.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
token = f"{encoded_payload}.{_urlsafe_b64encode(signature)}"
|
||||
return token, expires_in
|
||||
@@ -3,7 +3,7 @@ from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
|
||||
from app.models.models import InfoSource
|
||||
from app.schemas.schemas import InfoSourceCreate, InfoSourceUpdate
|
||||
from app.schemas.source_schema import InfoSourceCreate, InfoSourceUpdate
|
||||
|
||||
|
||||
def get(db: Session, source_id: int) -> Optional[InfoSource]:
|
||||
|
||||
+15
-1
@@ -6,6 +6,7 @@ from dotenv import load_dotenv
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from app.services.fetcher_service import fetch_and_save_trending_data
|
||||
from app.services.summary_service import generate_unified_summaries
|
||||
from app.database import engine
|
||||
from app.models.models import Base
|
||||
|
||||
@@ -14,6 +15,7 @@ from app.api.router import api_router
|
||||
|
||||
load_dotenv()
|
||||
CRAWL_INTERVAL = int(os.getenv("CRAWL_INTERVAL_MINUTES", 10))
|
||||
SUMMARY_INTERVAL = int(os.getenv("SUMMARY_INTERVAL_MINUTES", 30))
|
||||
|
||||
scheduler = AsyncIOScheduler()
|
||||
|
||||
@@ -36,15 +38,27 @@ async def lifespan(app: FastAPI):
|
||||
id='trending_fetch_job',
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
# 平台摘要
|
||||
scheduler.add_job(
|
||||
generate_unified_summaries,
|
||||
'interval',
|
||||
minutes=SUMMARY_INTERVAL,
|
||||
id='ai_summary_job',
|
||||
replace_existing=True
|
||||
)
|
||||
scheduler.start()
|
||||
print(f"定时抓取任务已启动,每 {CRAWL_INTERVAL} 分钟执行一次")
|
||||
print(f"AI 摘要生成任务已启动,每 {SUMMARY_INTERVAL} 分钟执行一次")
|
||||
|
||||
# 为了测试方便,启动时立即执行一次
|
||||
await fetch_and_save_trending_data()
|
||||
|
||||
await generate_unified_summaries()
|
||||
|
||||
yield # 此时 FastAPI 开始接受请求
|
||||
|
||||
# 3. 优雅关闭
|
||||
# 优雅关闭
|
||||
scheduler.shutdown()
|
||||
print("定时任务已安全关闭")
|
||||
|
||||
|
||||
+194
-111
@@ -29,35 +29,40 @@ BigIntType = BigInteger().with_variant(Integer, "sqlite")
|
||||
|
||||
class SourceType(str, enum.Enum):
|
||||
"""信息源的抓取方式"""
|
||||
HOT_TREND = "HOT_TREND" # 热搜榜单类
|
||||
RSS_FEED = "RSS_FEED" # 传统RSS订阅
|
||||
API = "API" # 接口抓取类
|
||||
HOT_TREND = "HOT_TREND" # 热搜榜单类 (如微博热搜)
|
||||
RSS_FEED = "RSS_FEED" # 传统RSS订阅 (如36氪、纽约时报)
|
||||
API = "API" # 接口直接接入类
|
||||
|
||||
|
||||
class TargetType(str, enum.Enum):
|
||||
"""
|
||||
多态目标类型 (Polymorphic Target)
|
||||
用于标记一条评论或一个标签到底是挂载在哪个实体下的。
|
||||
用于标记一条评论、标签或推送记录,到底是挂载在哪个实体下的。
|
||||
"""
|
||||
EVENT = "EVENT" # 挂载在单个热搜事件下
|
||||
TREND = "TREND" # 挂载在宏观趋势下
|
||||
ARTICLE = "ARTICLE" # 挂载在具体新闻文章下
|
||||
EVENT = "EVENT" # 挂载在AI聚合后的大事件下
|
||||
TREND = "TREND" # 挂载在单个平台的热搜条目下
|
||||
ARTICLE = "ARTICLE" # 挂载在具体的长篇新闻文章下
|
||||
|
||||
|
||||
class TaskStatus(str, enum.Enum):
|
||||
"""后台任务状态"""
|
||||
SUCCESS = "SUCCESS"
|
||||
ERROR = "ERROR"
|
||||
"""后台爬虫/推送任务的执行状态"""
|
||||
SUCCESS = "SUCCESS" # 执行成功
|
||||
ERROR = "ERROR" # 发生报错
|
||||
|
||||
|
||||
class GenderType(str, enum.Enum):
|
||||
"""用户性别枚举"""
|
||||
"""用户性别枚举,常用于给AI提供Prompt背景信息以生成个性化摘要"""
|
||||
MALE = "MALE"
|
||||
FEMALE = "FEMALE"
|
||||
OTHER = "OTHER"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class VerificationPurpose(str, enum.Enum):
|
||||
REGISTER = "REGISTER"
|
||||
LOGIN = "LOGIN"
|
||||
|
||||
|
||||
def utcnow():
|
||||
"""
|
||||
获取带UTC时区的当前时间 (最佳实践)
|
||||
@@ -77,10 +82,14 @@ class InfoSource(Base):
|
||||
__tablename__ = "info_sources"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
source_name: Mapped[str] = mapped_column(String(100), comment="信息源名称")
|
||||
source_type: Mapped[SourceType] = mapped_column(Enum(SourceType))
|
||||
home_url: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
# 信息源的展示名称,例如 "微博热搜", "今日头条"
|
||||
source_name: Mapped[str] = mapped_column(String(100), comment="信息源中文名称")
|
||||
# 抓取类型,决定爬虫调用哪个解析逻辑
|
||||
source_type: Mapped[SourceType] = mapped_column(Enum(SourceType), comment="抓取方式枚举")
|
||||
# 极其重要:原意存官网链接,但实际开发中常借用来存放 API的专属标识(如 'weibo', 'toutiao')
|
||||
home_url: Mapped[Optional[str]] = mapped_column(String(255), comment="官网链接或API的平台标识ID")
|
||||
# 爬虫开关:如果某平台封禁了我们,可以直接置为False,爬虫将自动跳过该平台
|
||||
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否开启定时抓取")
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
@@ -91,19 +100,22 @@ class InfoSource(Base):
|
||||
# ==========================================
|
||||
class UnifiedEvent(Base):
|
||||
"""
|
||||
AI 统一事件表
|
||||
核心业务逻辑:比如微博热搜叫“苹果发布会”,知乎热搜叫“iPhone 16 测评”,
|
||||
它们在子表(TrendingEvent)是两条记录,但通过 AI 语义向量对比后,
|
||||
会将它们统一挂载到这个表的一个 UnifiedEvent ID 下,实现跨平台事件聚合。
|
||||
AI 统一事件表 (核心大脑)
|
||||
逻辑:微博的“苹果发布会”和知乎的“iPhone 16 测评”,通过语义相似度碰撞后,统一归入此表的一行记录中。
|
||||
"""
|
||||
__tablename__ = "unified_events"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigIntType, primary_key=True, autoincrement=True)
|
||||
unified_title: Mapped[str] = mapped_column(String(255), comment="AI统一标题")
|
||||
ai_comprehensive_summary: Mapped[Optional[str]] = mapped_column(Text, comment="AI全局深度总结")
|
||||
# 经过AI润色去重后的中立、客观的标准大标题
|
||||
unified_title: Mapped[str] = mapped_column(String(255), comment="AI生成的客观统一大标题")
|
||||
# AI阅读子新闻后生成的千字长文摘要,直接用于早报推送
|
||||
ai_comprehensive_summary: Mapped[Optional[str]] = mapped_column(Text, comment="AI综合全网子新闻生成的深度总结")
|
||||
|
||||
center_embedding: Mapped[Optional[str]] = mapped_column(Text, comment="中心向量") # 用于高维空间相似度计算
|
||||
hot_score: Mapped[int] = mapped_column(Integer, default=0, comment="聚合热度得分")
|
||||
# [高阶字段] 将文本转化成高维浮点数向量,爬虫抓到新新闻时,跟这个向量算余弦相似度来判断是不是同一个事件
|
||||
center_embedding: Mapped[Optional[str]] = mapped_column(Text, comment="该事件簇的中心语义向量")
|
||||
# 事件热度值:挂载的平台越多、相关评论越多,分数越高,用于首页的热榜排序
|
||||
hot_score: Mapped[int] = mapped_column(Integer, default=0, comment="聚合热度得分(分数越高排名越靠前)")
|
||||
last_summarized_trends_count: Mapped[int] = mapped_column(Integer, default=0, comment="用于判断是否需要重新调用LLM")
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
@@ -115,51 +127,71 @@ class UnifiedEvent(Base):
|
||||
class TrendingEvent(Base):
|
||||
"""
|
||||
各平台热搜数据明细表
|
||||
存放从爬虫直接拉取下来的最原始的热搜数据。
|
||||
"""
|
||||
__tablename__ = "trending_events"
|
||||
__table_args__ = (
|
||||
# 联合唯一索引:同一个来源(比如微博)的同一条外部ID(MD5)只能存在一条记录,防重插核心保障
|
||||
# 联合唯一索引:同一个来源的同一个哈希只能存一条,完美实现 UPSERT (去重更新)
|
||||
UniqueConstraint("source_id", "external_id", name="idx_unique_external_trend"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigIntType, primary_key=True, autoincrement=True)
|
||||
source_id: Mapped[int] = mapped_column(ForeignKey("info_sources.id"))
|
||||
unified_event_id: Mapped[Optional[int]] = mapped_column(ForeignKey("unified_events.id"))
|
||||
# 关联:这条热搜是从哪个平台(InfoSource)抓来的
|
||||
source_id: Mapped[int] = mapped_column(ForeignKey("info_sources.id"), comment="所属信息源ID")
|
||||
# 关联:它被AI归类到了哪个大事件(UnifiedEvent)之下 (可为空,表示未归类的新鲜事)
|
||||
unified_event_id: Mapped[Optional[int]] = mapped_column(ForeignKey("unified_events.id"),
|
||||
comment="所属的聚合大事件ID")
|
||||
|
||||
external_id: Mapped[str] = mapped_column(String(32), comment="32位MD5哈希指纹防重")
|
||||
title_embedding: Mapped[Optional[str]] = mapped_column(Text)
|
||||
# 极其核心:将第三方易变的标题/URL,强制压平为32位不变的 MD5 字符串,用作唯一防重指纹
|
||||
external_id: Mapped[str] = mapped_column(String(32), comment="通过平台ID+原始ID生成的32位MD5防重指纹")
|
||||
# 这条特定热搜标题的独立语义向量,用于和 unified_events 做碰撞
|
||||
title_embedding: Mapped[Optional[str]] = mapped_column(Text, comment="标题的语义向量")
|
||||
|
||||
icon_url: Mapped[Optional[str]] = mapped_column(String(500))
|
||||
current_headline: Mapped[str] = mapped_column(String(255))
|
||||
event_url: Mapped[Optional[str]] = mapped_column(String(500))
|
||||
app_link: Mapped[Optional[str]] = mapped_column(String(500))
|
||||
current_ranking: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
brief_snippet: Mapped[Optional[str]] = mapped_column(Text)
|
||||
# 爬虫抓下来的热搜配图、带'爆'或'热'字的角标链接
|
||||
icon_url: Mapped[Optional[str]] = mapped_column(String(500), comment="热榜附带的小图标或配图链接")
|
||||
# 最新标题 (注意:小编随时可能改标题,所以绝不能放入唯一索引)
|
||||
current_headline: Mapped[str] = mapped_column(String(255), comment="当前最新的热搜标题")
|
||||
# 该热点在PC/H5端的访问链接
|
||||
event_url: Mapped[Optional[str]] = mapped_column(String(500), comment="浏览器访问链接")
|
||||
# 该热点专门用于手机App唤醒的 DeepLink (如 sinaweibo://...)
|
||||
app_link: Mapped[Optional[str]] = mapped_column(String(500), comment="移动端App唤醒专属链接")
|
||||
# 本次抓取时,它在平台上的名次 (如 1, 2, 3)
|
||||
current_ranking: Mapped[Optional[int]] = mapped_column(Integer, comment="当前最新排名(可能随时上下浮动)")
|
||||
# 有些平台在热搜底下会配一句话简介
|
||||
brief_snippet: Mapped[Optional[str]] = mapped_column(Text, comment="平台自带的一句话简介或导语")
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class NewsArticle(Base):
|
||||
"""新闻文章明细表 (与 TrendingEvent 类似,但侧重长文本阅读)"""
|
||||
"""
|
||||
新闻文章明细表 (长篇资讯)
|
||||
与 TrendingEvent 类似,但它主要用来存放 36氪、纽约时报等长篇正文,用于提供深度的阅读素材。
|
||||
"""
|
||||
__tablename__ = "news_articles"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("source_id", "external_id", name="idx_unique_external_article"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigIntType, primary_key=True, autoincrement=True)
|
||||
source_id: Mapped[int] = mapped_column(ForeignKey("info_sources.id"))
|
||||
unified_event_id: Mapped[Optional[int]] = mapped_column(ForeignKey("unified_events.id"))
|
||||
source_id: Mapped[int] = mapped_column(ForeignKey("info_sources.id"), comment="所属信息源ID")
|
||||
unified_event_id: Mapped[Optional[int]] = mapped_column(ForeignKey("unified_events.id"),
|
||||
comment="深度文章也可归入大事件分析")
|
||||
|
||||
external_id: Mapped[str] = mapped_column(String(32))
|
||||
title_embedding: Mapped[Optional[str]] = mapped_column(Text)
|
||||
external_id: Mapped[str] = mapped_column(String(32), comment="RSS原文<guid>生成的MD5防重指纹")
|
||||
title_embedding: Mapped[Optional[str]] = mapped_column(Text, comment="新闻标题/摘要的语义向量")
|
||||
|
||||
cover_image_url: Mapped[Optional[str]] = mapped_column(String(500))
|
||||
article_title: Mapped[str] = mapped_column(String(255))
|
||||
article_url: Mapped[Optional[str]] = mapped_column(String(500))
|
||||
author_name: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
original_summary: Mapped[Optional[str]] = mapped_column(Text)
|
||||
publish_time: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
# 新闻文章的封面大图,很适合前端做瀑布流展示
|
||||
cover_image_url: Mapped[Optional[str]] = mapped_column(String(500), comment="新闻封面大图链接")
|
||||
article_title: Mapped[str] = mapped_column(String(255), comment="新闻原文标题")
|
||||
article_url: Mapped[Optional[str]] = mapped_column(String(500), comment="新闻原文链接")
|
||||
# 作者或发布机构 (如 "澎湃新闻", "虎嗅作者X")
|
||||
author_name: Mapped[Optional[str]] = mapped_column(String(100), comment="作者或发布机构名称")
|
||||
# RSS原文中附带的长摘要,甚至是完整的 HTML 格式正文
|
||||
original_summary: Mapped[Optional[str]] = mapped_column(Text, comment="原文自带的长摘要或正文片段")
|
||||
# 新闻在平台上的真实发布时间 (可能比我们爬取的时间要早几天)
|
||||
publish_time: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), comment="新闻实际发布的时间")
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
@@ -171,33 +203,40 @@ class NewsArticle(Base):
|
||||
class HeadlineRevision(Base):
|
||||
"""
|
||||
标题修订历史表
|
||||
用于记录平台方暗戳戳修改热搜词条的行为(例如公关介入改标题)。
|
||||
当系统通过哈希发现某条新闻是老熟人,但标题发生了改变时,会自动往这里插一条记录。
|
||||
常用于公关监测(看看谁半夜偷偷改了标题)。
|
||||
"""
|
||||
__tablename__ = "headline_revisions"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigIntType, primary_key=True, autoincrement=True)
|
||||
event_id: Mapped[int] = mapped_column(ForeignKey("trending_events.id"))
|
||||
previous_headline: Mapped[str] = mapped_column(String(255))
|
||||
revised_headline: Mapped[str] = mapped_column(String(255))
|
||||
# 属于哪一条被修改的热搜
|
||||
event_id: Mapped[int] = mapped_column(ForeignKey("trending_events.id"), comment="关联的热搜ID")
|
||||
previous_headline: Mapped[str] = mapped_column(String(255), comment="修改前的旧标题")
|
||||
revised_headline: Mapped[str] = mapped_column(String(255), comment="修改后的新标题")
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow,
|
||||
comment="系统发现被修改的时间")
|
||||
|
||||
|
||||
class RankingLog(Base):
|
||||
"""
|
||||
热搜排名时间序列化日志
|
||||
每一次抓取都会生成一条记录,可以用于前端绘制热搜“排名起伏折线图”。
|
||||
每次爬虫运行(例如每10分钟),都会往这里塞一堆数据,记录某热搜当时的具体名次。
|
||||
前端可以通过这张表画出非常漂亮的“名次起伏折线图(K线图)”。
|
||||
"""
|
||||
__tablename__ = "ranking_logs"
|
||||
__table_args__ = (
|
||||
# 针对时间序列查询优化的复合索引,加速类似 "查询某事件在过去24小时内的排名变化" 的操作
|
||||
# 复合索引,加速 "查询某事件在某段时间内的走势"
|
||||
Index("idx_event_time", "event_id", "observed_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigIntType, primary_key=True, autoincrement=True)
|
||||
event_id: Mapped[int] = mapped_column(ForeignKey("trending_events.id"))
|
||||
ranking_position: Mapped[int] = mapped_column(Integer)
|
||||
observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
event_id: Mapped[int] = mapped_column(ForeignKey("trending_events.id"), comment="关联的热搜ID")
|
||||
# 当时它在第几名
|
||||
ranking_position: Mapped[int] = mapped_column(Integer, comment="当时抓取时的排名名次")
|
||||
# 爬虫看到它的那一瞬间的时间
|
||||
observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow,
|
||||
comment="观察到该名次的准确时间")
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
@@ -205,44 +244,49 @@ class RankingLog(Base):
|
||||
# ==========================================
|
||||
# 模块五:多态话题与多态评论
|
||||
# ==========================================
|
||||
# 【设计模式】:多态设计
|
||||
# 通过 target_type (存表名/类型) + target_id (存主键ID) 的组合,
|
||||
# 让这两个表既能挂载在"单一热搜"下,也能挂载在"新闻文章"下,甚至挂在"统一大事件"下,避免了建立无数个外键的冗余。
|
||||
|
||||
class ExtractedTopic(Base):
|
||||
"""AI 提取的核心话题标签表"""
|
||||
"""
|
||||
AI 提取的核心话题标签表
|
||||
设计模式(多态):一条标签("AI")既能打在大事件上,也能打在单篇文章上。
|
||||
"""
|
||||
__tablename__ = "extracted_topics"
|
||||
__table_args__ = (
|
||||
Index("idx_topic_keyword", "topic_keyword"),
|
||||
# 多态查询索引,加速 target_type + target_id 的组合查询
|
||||
Index("idx_polymorphic_topics", "target_type", "target_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigIntType, primary_key=True, autoincrement=True)
|
||||
target_type: Mapped[TargetType] = mapped_column(Enum(TargetType))
|
||||
target_id: Mapped[int] = mapped_column(BigIntType)
|
||||
topic_keyword: Mapped[str] = mapped_column(String(100))
|
||||
relevance_score: Mapped[Optional[float]] = mapped_column(Float)
|
||||
target_type: Mapped[TargetType] = mapped_column(Enum(TargetType), comment="挂载目标的类型(大事件/热点/文章)")
|
||||
target_id: Mapped[int] = mapped_column(BigIntType, comment="对应的具体主键ID")
|
||||
# 提取出的标签词,例如 "自动驾驶", "马斯克"
|
||||
topic_keyword: Mapped[str] = mapped_column(String(100), comment="提取出的核心关键词汇")
|
||||
# AI 认为这个词和这篇文章的相关程度(0~100),方便以后做精准度过滤
|
||||
relevance_score: Mapped[Optional[float]] = mapped_column(Float, comment="AI计算的相关度得分")
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
class DiscussionComment(Base):
|
||||
"""全平台统一评论表"""
|
||||
"""
|
||||
全平台统一评论库
|
||||
不论是微博网友的短评,还是新闻网站的长评,全部扔进这张多态表。
|
||||
"""
|
||||
__tablename__ = "discussion_comments"
|
||||
__table_args__ = (
|
||||
Index("idx_polymorphic_comments", "target_type", "target_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigIntType, primary_key=True, autoincrement=True)
|
||||
target_type: Mapped[TargetType] = mapped_column(Enum(TargetType))
|
||||
target_id: Mapped[int] = mapped_column(BigIntType)
|
||||
target_type: Mapped[TargetType] = mapped_column(Enum(TargetType), comment="被评论内容的类型")
|
||||
target_id: Mapped[int] = mapped_column(BigIntType, comment="被评论内容的主键ID")
|
||||
|
||||
commenter_name: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
comment_content: Mapped[str] = mapped_column(Text)
|
||||
likes_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
external_comment_id: Mapped[Optional[str]] = mapped_column(String(32))
|
||||
comment_time: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
commenter_name: Mapped[Optional[str]] = mapped_column(String(100), comment="发评人昵称")
|
||||
comment_content: Mapped[str] = mapped_column(Text, comment="评论正文内容")
|
||||
# 这条评论本身获得的点赞数,可用于筛选出“神评论”一并推送给用户
|
||||
likes_count: Mapped[int] = mapped_column(Integer, default=0, comment="评论被点赞的数量")
|
||||
# 防复抓:用第三方平台原生评论ID做的MD5哈希
|
||||
external_comment_id: Mapped[Optional[str]] = mapped_column(String(32), comment="第三方评论ID的MD5指纹")
|
||||
comment_time: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), comment="评论实际发布时间")
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
@@ -251,29 +295,53 @@ class DiscussionComment(Base):
|
||||
# 模块六:用户画像与多渠道高可用推送系统
|
||||
# ==========================================
|
||||
class AppUser(Base):
|
||||
"""系统核心用户表"""
|
||||
"""
|
||||
系统核心用户表
|
||||
不仅存放密码,还包含用户的基础画像,供大模型做个性化阅读推荐。
|
||||
"""
|
||||
__tablename__ = "app_users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
email: Mapped[str] = mapped_column(String(150), unique=True, index=True)
|
||||
password_hash: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
email: Mapped[str] = mapped_column(String(150), unique=True, index=True, comment="主账号邮箱")
|
||||
password_hash: Mapped[Optional[str]] = mapped_column(String(255), comment="密码哈希(第三方登录可为空)")
|
||||
|
||||
nickname: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
avatar_url: Mapped[Optional[str]] = mapped_column(String(500))
|
||||
gender: Mapped[GenderType] = mapped_column(Enum(GenderType), default=GenderType.UNKNOWN)
|
||||
nickname: Mapped[Optional[str]] = mapped_column(String(100), comment="用户展示昵称")
|
||||
avatar_url: Mapped[Optional[str]] = mapped_column(String(500), comment="用户头像地址")
|
||||
gender: Mapped[GenderType] = mapped_column(Enum(GenderType), default=GenderType.UNKNOWN,
|
||||
comment="用户性别(用于AI调整行文语气)")
|
||||
|
||||
# 预留的 JSON 字段,可以存放未来灵活变化的用户配置,避免频繁修改表结构
|
||||
metadata_: Mapped[Optional[Any]] = mapped_column("metadata", JSON, comment="自定义扩展偏好")
|
||||
# 极其强大:一个万能收纳箱!前端未来想加任何诸如“夜间模式”、“字体变大”的开关,
|
||||
# 全部丢进这个 JSON 字段即可,从此免去手动修改后端表结构的麻烦。
|
||||
metadata_: Mapped[Optional[Any]] = mapped_column("metadata", JSON,
|
||||
comment="JSON扩展字段: 存放灵活多变的前端用户偏好设置")
|
||||
|
||||
timezone: Mapped[str] = mapped_column(String(50), default="Asia/Shanghai")
|
||||
# 时区对于定时推送系统极其重要!保证纽约的用户和北京的用户都能在早晨8点收到新闻。
|
||||
timezone: Mapped[str] = mapped_column(String(50), default="Asia/Shanghai", comment="用户所在地时区")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class EmailVerificationCode(Base):
|
||||
__tablename__ = "email_verification_codes"
|
||||
__table_args__ = (
|
||||
Index("idx_email_code_lookup", "email", "purpose", "is_used", "expires_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
email: Mapped[str] = mapped_column(String(150), index=True, nullable=False)
|
||||
purpose: Mapped[VerificationPurpose] = mapped_column(Enum(VerificationPurpose), nullable=False)
|
||||
code_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
is_used: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class UserPushEndpoint(Base):
|
||||
"""
|
||||
多渠道推送端点配置表
|
||||
一个用户可能绑定了邮箱(EMAIL)和微信(WECHAT),支持配置降级重试(priority_level)。
|
||||
多渠道推送端点配置表 (高可用解耦设计)
|
||||
一个用户可以配置好几个推送渠道(邮箱、微信、钉钉),
|
||||
万一主渠道今天报错了,系统会自动按优先级(priority)降级寻找备用渠道重发。
|
||||
"""
|
||||
__tablename__ = "user_push_endpoints"
|
||||
__table_args__ = (
|
||||
@@ -281,63 +349,76 @@ class UserPushEndpoint(Base):
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("app_users.id"))
|
||||
channel_type: Mapped[str] = mapped_column(String(50), comment="如 EMAIL, WECHAT")
|
||||
channel_account: Mapped[str] = mapped_column(String(255))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
priority_level: Mapped[int] = mapped_column(Integer, default=1, comment="1最高,降级重试")
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("app_users.id"), comment="所属用户ID")
|
||||
# 填入大写的纯字符串,如 EMAIL, WECHAT_BOT, TELEGRAM
|
||||
channel_type: Mapped[str] = mapped_column(String(50), comment="推送渠道类型标识")
|
||||
# 具体的发送目标地址
|
||||
channel_account: Mapped[str] = mapped_column(String(255), comment="具体的接收账号(邮箱号/微信号/Webhook)")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, comment="用户是否临时关闭了该渠道")
|
||||
# 高可用容灾:比如 1 代表必须先发微信,如果报错了,再去找 priority=2 的邮箱补发
|
||||
priority_level: Mapped[int] = mapped_column(Integer, default=1, comment="推送优先级(1最高,用于错误降级重试)")
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class UserTopicPreference(Base):
|
||||
"""用户订阅的兴趣标签库"""
|
||||
"""
|
||||
用户订阅的兴趣标签库
|
||||
当这里的标签和 ExtractedTopic 表里的标签匹配上时,就会触发相关新闻的推送。
|
||||
"""
|
||||
__tablename__ = "user_topic_preferences"
|
||||
__table_args__ = (
|
||||
# 联合防抖限制:防止用户在界面卡顿时连点两次,订阅了两个同样的词
|
||||
UniqueConstraint("user_id", "interested_keyword", name="idx_unique_preference"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("app_users.id"))
|
||||
interested_keyword: Mapped[str] = mapped_column(String(100))
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("app_users.id"), comment="所属用户ID")
|
||||
interested_keyword: Mapped[str] = mapped_column(String(100), comment="用户填写的感兴趣标签(如'马斯克')")
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
class UserDeliverySchedule(Base):
|
||||
"""用户勿扰/定时推送时间表"""
|
||||
"""
|
||||
用户专属的定时推送时间表
|
||||
如果用户设定了早上 08:30,后台的定时任务就会在每天 08:30 精准地把匹配到的聚合新闻发出去。
|
||||
"""
|
||||
__tablename__ = "user_delivery_schedules"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "delivery_time", name="idx_unique_schedule"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("app_users.id"))
|
||||
delivery_time: Mapped[time] = mapped_column(Time, comment="如 08:30:00")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("app_users.id"), comment="所属用户ID")
|
||||
delivery_time: Mapped[time] = mapped_column(Time, comment="每天期望收到推送的具体时间")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否启用此时段")
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
class DeliveryHistory(Base):
|
||||
"""
|
||||
推送历史防刷表
|
||||
核心用途:一旦给某个用户推送过某条新闻/事件,就记录在这里。
|
||||
下次再触发推荐时,检查这个表,防止给同一个用户反复发送相同的内容。
|
||||
推送历史防刷表 (推送系统的绝对底线)
|
||||
核心业务逻辑:一旦大事件或者文章通过某个渠道成功发给用户,就记一笔帐。
|
||||
明天如果这条旧新闻还在热搜上,系统查一下这个表,发现发过了,直接抛弃,绝不轰炸用户。
|
||||
"""
|
||||
__tablename__ = "delivery_history"
|
||||
__table_args__ = (
|
||||
# 终极去重约束:一个用户,针对同一篇新闻,永远只允许存在一条记录
|
||||
UniqueConstraint("user_id", "target_type", "target_id", name="idx_prevent_duplicate_push"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigIntType, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("app_users.id"))
|
||||
target_type: Mapped[TargetType] = mapped_column(Enum(TargetType))
|
||||
target_id: Mapped[int] = mapped_column(BigIntType)
|
||||
status: Mapped[TaskStatus] = mapped_column(Enum(TaskStatus))
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("app_users.id"), comment="接收推送的用户")
|
||||
target_type: Mapped[TargetType] = mapped_column(Enum(TargetType), comment="推送出去的具体内容类型")
|
||||
target_id: Mapped[int] = mapped_column(BigIntType, comment="推送内容的主键ID")
|
||||
# 记录这次推送是彻底成功了,还是由于渠道网络问题失败了
|
||||
status: Mapped[TaskStatus] = mapped_column(Enum(TaskStatus), comment="最终推送结果状态")
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow,
|
||||
comment="记录或实际推送的准确时间")
|
||||
|
||||
|
||||
# ==========================================
|
||||
@@ -345,15 +426,17 @@ class DeliveryHistory(Base):
|
||||
# ==========================================
|
||||
class DataSyncTask(Base):
|
||||
"""
|
||||
数据同步健康度监控表
|
||||
这就是爬虫脚本每次运行都要写入记录的地方,用于后台 Dashboard 监控爬虫健康状态和错误堆栈。
|
||||
数据同步健康度监控表 (运维巡检专用)
|
||||
爬虫每跑完一个平台的轮询,就在这里打卡上报。
|
||||
方便后台画出爬虫成功率的饼图,一旦 error_trace 堆积,能迅速报警排查。
|
||||
"""
|
||||
__tablename__ = "data_sync_tasks"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigIntType, primary_key=True, autoincrement=True)
|
||||
source_id: Mapped[int] = mapped_column(ForeignKey("info_sources.id"))
|
||||
items_fetched: Mapped[int] = mapped_column(Integer, default=0)
|
||||
task_status: Mapped[TaskStatus] = mapped_column(Enum(TaskStatus))
|
||||
error_trace: Mapped[Optional[str]] = mapped_column(Text)
|
||||
source_id: Mapped[int] = mapped_column(ForeignKey("info_sources.id"), comment="本次运行爬取的哪个源")
|
||||
items_fetched: Mapped[int] = mapped_column(Integer, default=0, comment="本次爬虫成功插入或更新的新闻条数")
|
||||
task_status: Mapped[TaskStatus] = mapped_column(Enum(TaskStatus), comment="该平台的宏观抓取状态")
|
||||
# 如果代码意外崩溃、或是遭遇403/502,把 Python的 traceback 堆栈原封不动存进这里
|
||||
error_trace: Mapped[Optional[str]] = mapped_column(Text, comment="若失败则保存完整报错堆栈")
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, comment="任务执行的发生时间")
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
SUMMARY_SYSTEM_PROMPT = "你是一个输出严格 JSON 格式的后台引擎。"
|
||||
|
||||
SUMMARY_USER_PROMPT_TEMPLATE = """
|
||||
你是一个专业的新闻聚合编辑。请根据以下同一个大事件在不同平台的热搜标题,
|
||||
为该事件生成一个客观、吸睛的【统一大标题】,以及一段【多平台视角的综合摘要】。
|
||||
|
||||
要求:
|
||||
1. 摘要结构类似:"该事件在多平台发酵。微博侧重讨论...,知乎硬核解析...,科技媒体关注..."。
|
||||
2. 提炼出各平台的讨论侧重点,不要简单罗列标题。
|
||||
3. 必须以严格的 JSON 格式返回,只包含 "unified_title" 和 "ai_comprehensive_summary" 两个字段,不要有多余的说明。
|
||||
|
||||
各平台热搜标题数据:
|
||||
{platform_data_text}
|
||||
"""
|
||||
@@ -0,0 +1,56 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.models import GenderType
|
||||
|
||||
|
||||
EMAIL_PATTERN = r"^[^@\s]+@[^@\s]+\.[^@\s]+$"
|
||||
|
||||
|
||||
class RegisterCodeSendRequest(BaseModel):
|
||||
email: str = Field(..., max_length=150, pattern=EMAIL_PATTERN)
|
||||
|
||||
|
||||
class LoginCodeSendRequest(BaseModel):
|
||||
email: str = Field(..., max_length=150, pattern=EMAIL_PATTERN)
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: str = Field(..., max_length=150, pattern=EMAIL_PATTERN)
|
||||
password: str = Field(..., min_length=8, max_length=128)
|
||||
verification_code: str = Field(..., pattern=r"^\d{6}$")
|
||||
nickname: Optional[str] = Field(default=None, max_length=100)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: str = Field(..., max_length=150, pattern=EMAIL_PATTERN)
|
||||
password: str = Field(..., min_length=8, max_length=128)
|
||||
|
||||
|
||||
class LoginWithCodeRequest(BaseModel):
|
||||
email: str = Field(..., max_length=150, pattern=EMAIL_PATTERN)
|
||||
verification_code: str = Field(..., pattern=r"^\d{6}$")
|
||||
|
||||
|
||||
class UserProfileResponse(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
nickname: Optional[str]
|
||||
avatar_url: Optional[str]
|
||||
gender: GenderType
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class AuthTokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
user: UserProfileResponse
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
message: str
|
||||
@@ -0,0 +1,23 @@
|
||||
# app/schemas/event_schema.py
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class PlatformTrendResponse(BaseModel):
|
||||
source_id: int
|
||||
platform_name: str # 平台名称,如 "微博热搜"
|
||||
headline: str # 平台对应的具体热搜标题
|
||||
url: Optional[str] # 跳转链接
|
||||
current_ranking: Optional[int] # 当前排名
|
||||
ranking_history: List[int] # 排名历史轨迹,如 [50, 45, 20, 5, 1],供 ApexCharts 渲染
|
||||
|
||||
|
||||
class UnifiedEventResponse(BaseModel):
|
||||
event_id: int
|
||||
unified_title: str # AI 生成的统一大标题
|
||||
summary: Optional[str] # AI 生成的摘要
|
||||
hot_score: int # 总热度值
|
||||
created_at: datetime # 事件发现时间
|
||||
platforms: List[PlatformTrendResponse] # 挂载的各个平台子热搜
|
||||
# tags: List[str] = [] # 如果后续打通了 ExtractedTopic,可以在这里返回标签
|
||||
@@ -1,4 +1,4 @@
|
||||
# app/schemas/schemas.py
|
||||
# app/schemas/source_schema.py
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
@@ -1,62 +1,222 @@
|
||||
# app/services/fetcher_service.py
|
||||
import os
|
||||
import hashlib
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
from datetime import timedelta
|
||||
|
||||
import httpx
|
||||
import json
|
||||
import numpy as np
|
||||
from dotenv import load_dotenv
|
||||
from sklearn.metrics.pairwise import cosine_similarity
|
||||
from sentence_transformers import SentenceTransformer
|
||||
from app.database import SessionLocal
|
||||
from app.models.models import (
|
||||
InfoSource, TrendingEvent, NewsArticle, DataSyncTask, TaskStatus,
|
||||
HeadlineRevision, RankingLog, SourceType, utcnow
|
||||
HeadlineRevision, RankingLog, SourceType, utcnow, UnifiedEvent
|
||||
)
|
||||
|
||||
# ==========================================
|
||||
# 环境变量与全局配置
|
||||
# ==========================================
|
||||
# 加载环境变量
|
||||
load_dotenv()
|
||||
|
||||
# 从环境变量获取 API 基础地址,如果没有配置则提供默认回退地址
|
||||
hf_token = os.getenv("HF_TOKEN")
|
||||
SIMILARITY_THRESHOLD = float(os.getenv("SIMILARITY_THRESHOLD", 0.72))
|
||||
API_BASE_URL = os.getenv("API_BASE_URL", "https://newsnow.busiyi.world/api/s")
|
||||
EMBEDDING_MODEL_PATH = os.getenv("EMBEDDING_MODEL_PATH", "")
|
||||
|
||||
print("正在加载 BAAI/bge-m3 向量模型...")
|
||||
# 全局单例
|
||||
embedder_model = SentenceTransformer(EMBEDDING_MODEL_PATH, local_files_only=True, device="cuda")
|
||||
print("模型加载完成。")
|
||||
|
||||
|
||||
def generate_md5(text: str) -> str:
|
||||
"""
|
||||
生成32位MD5哈希值
|
||||
维护说明:
|
||||
各个平台(微博、知乎、微信等)返回的原始 ID 格式千奇百怪(有长数字、有UUID、有URL)。
|
||||
为了方便数据库建立统一的高性能唯一索引(UniqueConstraint),
|
||||
我们统一将其转为长度固定的 32 位 MD5 字符串作为 external_id。
|
||||
"""
|
||||
"""生成32位MD5哈希值作为全局唯一指纹"""
|
||||
return hashlib.md5(text.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def generate_embedding_json(text: str) -> str:
|
||||
"""辅助函数:调用大模型生成向量,并序列化为 JSON 字符串"""
|
||||
raw_vec = embedder_model.encode([text], normalize_embeddings=True)[0]
|
||||
truncated_vec = [round(float(x), 5) for x in raw_vec]
|
||||
return json.dumps(truncated_vec, separators=(',', ':'))
|
||||
|
||||
|
||||
def match_or_create_unified_event(db, title: str, embedding_json: str) -> int:
|
||||
"""
|
||||
辅助函数:大事件聚类中枢。
|
||||
拿着新计算的向量去数据库里碰,碰到了就返回老 ID,碰不到就建新的。
|
||||
"""
|
||||
# 提取刚算出来的向量
|
||||
new_vec = np.array(json.loads(embedding_json))
|
||||
|
||||
# 只取最近 3 天的活跃大事件进行比对
|
||||
three_days_ago = utcnow() - timedelta(days=3)
|
||||
recent_events = db.query(UnifiedEvent).filter(
|
||||
UnifiedEvent.created_at >= three_days_ago
|
||||
).order_by(UnifiedEvent.created_at.desc()).limit(200).all()
|
||||
|
||||
if recent_events:
|
||||
valid_events = [ev for ev in recent_events if ev.center_embedding]
|
||||
if valid_events:
|
||||
event_vectors = [json.loads(ev.center_embedding) for ev in valid_events]
|
||||
|
||||
# 批量矩阵计算相似度
|
||||
sim_scores = cosine_similarity([new_vec], event_vectors)[0]
|
||||
max_idx = np.argmax(sim_scores)
|
||||
|
||||
if sim_scores[max_idx] >= SIMILARITY_THRESHOLD:
|
||||
matched_event = valid_events[max_idx]
|
||||
|
||||
matched_event.hot_score += 1
|
||||
return matched_event.id
|
||||
|
||||
# 没匹配到,创建一个新的统一大事件
|
||||
new_unified = UnifiedEvent(
|
||||
unified_title=title,
|
||||
center_embedding=embedding_json,
|
||||
hot_score=1 # 初始热度
|
||||
)
|
||||
db.add(new_unified)
|
||||
db.flush() # 获取自增的主键 ID
|
||||
return new_unified.id
|
||||
|
||||
|
||||
def process_hot_trend_item(db, source, item, index: int, external_id: str):
|
||||
"""
|
||||
处理【热搜/短新闻】的业务逻辑,现已加入 AI 聚类功能
|
||||
"""
|
||||
title = item.get("title")
|
||||
item_url = item.get("url", "")
|
||||
|
||||
existing_event = db.query(TrendingEvent).filter(
|
||||
TrendingEvent.source_id == source.id,
|
||||
TrendingEvent.external_id == external_id
|
||||
).first()
|
||||
|
||||
event_to_log = None
|
||||
|
||||
# 核心逻辑:查重后再决定是否调用模型
|
||||
if existing_event:
|
||||
# 场景 A1:老熟人
|
||||
if existing_event.current_headline != title:
|
||||
# 标题被暗改,此时需要重新算一次 Embedding
|
||||
new_embedding_json = generate_embedding_json(title)
|
||||
|
||||
revision = HeadlineRevision(
|
||||
event_id=existing_event.id,
|
||||
previous_headline=existing_event.current_headline,
|
||||
revised_headline=title
|
||||
)
|
||||
db.add(revision)
|
||||
existing_event.current_headline = title
|
||||
existing_event.title_embedding = new_embedding_json # 更新为新标题的语义向量
|
||||
# 注:这里不改变它所属的 unified_event_id,因为大体还是同一件事
|
||||
|
||||
existing_event.current_ranking = index
|
||||
existing_event.event_url = item_url
|
||||
event_to_log = existing_event
|
||||
|
||||
else:
|
||||
# 场景 A2:这是一条彻底的全新热搜
|
||||
# 1. 计算向量
|
||||
new_embedding_json = generate_embedding_json(title)
|
||||
|
||||
# 2. 扔进聚类中枢找归宿
|
||||
matched_event_id = match_or_create_unified_event(db, title, new_embedding_json)
|
||||
|
||||
# 3. 落库
|
||||
new_event = TrendingEvent(
|
||||
source_id=source.id,
|
||||
external_id=external_id,
|
||||
current_headline=title,
|
||||
event_url=item_url,
|
||||
current_ranking=index,
|
||||
title_embedding=new_embedding_json, # 存入向量
|
||||
unified_event_id=matched_event_id # 挂载到大事件下
|
||||
)
|
||||
db.add(new_event)
|
||||
db.flush()
|
||||
event_to_log = new_event
|
||||
|
||||
# 强制记录排名轨迹
|
||||
rank_log = RankingLog(
|
||||
event_id=event_to_log.id,
|
||||
ranking_position=index
|
||||
)
|
||||
db.add(rank_log)
|
||||
|
||||
|
||||
def process_rss_feed_item(db, source, item, external_id: str):
|
||||
"""
|
||||
处理【长文章/传统订阅】分支的核心业务逻辑 (写入 NewsArticle 表)
|
||||
"""
|
||||
title = item.get("title")
|
||||
item_url = item.get("url", "")
|
||||
|
||||
existing_article = db.query(NewsArticle).filter(
|
||||
NewsArticle.source_id == source.id,
|
||||
NewsArticle.external_id == external_id
|
||||
).first()
|
||||
|
||||
if existing_article:
|
||||
# 文章若存在,仅更新基础字段
|
||||
existing_article.article_title = title
|
||||
existing_article.article_url = item_url
|
||||
else:
|
||||
# 全新文章入库
|
||||
new_article = NewsArticle(
|
||||
source_id=source.id,
|
||||
external_id=external_id,
|
||||
article_title=title,
|
||||
article_url=item_url,
|
||||
)
|
||||
db.add(new_article)
|
||||
|
||||
|
||||
def process_source_data(db, source, items: list) -> int:
|
||||
"""
|
||||
数据清洗与路由分发层:
|
||||
遍历 API 返回的 items,生成唯一指纹,并路由到不同的处理模块。
|
||||
返回成功处理的条目数量。
|
||||
"""
|
||||
saved_count = 0
|
||||
platform_id = source.home_url
|
||||
|
||||
for index, item in enumerate(items, 1):
|
||||
title = item.get("title")
|
||||
if not title:
|
||||
continue
|
||||
|
||||
item_url = item.get("url", "")
|
||||
|
||||
# ID 兜底策略:接口ID -> URL -> Title
|
||||
raw_id = item.get("id") or item_url or title
|
||||
external_id = generate_md5(f"{platform_id}_{raw_id}")
|
||||
|
||||
# 核心路由分流
|
||||
if source.source_type in (SourceType.HOT_TREND, SourceType.API):
|
||||
process_hot_trend_item(db, source, item, index, external_id)
|
||||
elif source.source_type == SourceType.RSS_FEED:
|
||||
process_rss_feed_item(db, source, item, external_id)
|
||||
|
||||
saved_count += 1
|
||||
|
||||
return saved_count
|
||||
|
||||
|
||||
async def fetch_and_save_trending_data():
|
||||
"""
|
||||
核心定时任务:从数据库读取信息源 -> 抓取API -> 解析 -> 根据业务类型分流存入对应的数据库表
|
||||
|
||||
执行流程:
|
||||
1. 查询所有配置为“已启用”的信息源 (is_enabled == True)。
|
||||
2. 伪装 HTTP 请求头,规避目标服务器的反爬机制。
|
||||
3. 遍历解析数据,生成 MD5 唯一指纹进行全局去重。
|
||||
4. 核心路由分流:
|
||||
- 若源为 HOT_TREND/API,按热搜逻辑处理(记录名次轨迹、标题变更)。
|
||||
- 若源为 RSS_FEED,按长文章逻辑处理(忽略名次,直接落库)。
|
||||
5. 严格的事务管理:成功则统一提交,报错则回滚业务数据并独立提交错误日志。
|
||||
调度层:负责网络请求、数据库事务管理和异常监控隔离。
|
||||
"""
|
||||
print(f"[{utcnow()}] 开始执行定时抓取任务...")
|
||||
|
||||
# 使用上下文管理器确保数据库连接池正确获取和归还连接
|
||||
with SessionLocal() as db:
|
||||
# 1. 动态获取抓取源。
|
||||
# 优势:在后台修改数据库的信息源开关,下一次定时任务立刻生效,无需重启服务。
|
||||
# 获取启用的信息源
|
||||
sources = db.query(InfoSource).filter(InfoSource.is_enabled == True).all()
|
||||
if not sources:
|
||||
print("没有找到启用的信息源,任务结束。")
|
||||
return
|
||||
|
||||
# 2. 伪装成真实的浏览器 HTTP 请求头
|
||||
# 维护注意:如果抓取接口返回 403 Forbidden,通常是这里的反爬策略失效了,需要更新 User-Agent 或 Cookie
|
||||
# 伪装请求头,规避反爬
|
||||
custom_headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
@@ -64,157 +224,40 @@ async def fetch_and_save_trending_data():
|
||||
"Origin": "https://newsnow.busiyi.world"
|
||||
}
|
||||
|
||||
# 复用异步 HTTP 客户端,比每次循环新建 Client 性能更高
|
||||
async with httpx.AsyncClient(timeout=15.0, headers=custom_headers) as client:
|
||||
for source in sources:
|
||||
# platform_id 对应第三方接口的入参标识,如 "weibo", "zhihu" 等
|
||||
platform_id = source.home_url
|
||||
if not platform_id:
|
||||
continue
|
||||
|
||||
# ==========================================
|
||||
# 【技术债预警 / TODO】
|
||||
# 目前无论 source_type 是什么,都统一请求了这个 JSON API。
|
||||
# 未来如果加入了真正的外部 RSS 订阅源(返回的是 XML 格式),
|
||||
# 这里需要增加判断逻辑:如果是 RSS_FEED,应当使用 feedparser 库去解析 XML,而不是用 httpx 获取 JSON。
|
||||
# ==========================================
|
||||
url = f"{API_BASE_URL}?id={platform_id}&latest"
|
||||
|
||||
# 初始化本次特定信息源抓取任务的系统监控日志
|
||||
# 初始化监控日志
|
||||
task_log = DataSyncTask(source_id=source.id, items_fetched=0)
|
||||
|
||||
try:
|
||||
# 发起请求并校验 HTTP 状态码 (非 2xx 会抛出异常进入 except 块)
|
||||
# 发起网络请求
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
data_json = response.json()
|
||||
|
||||
items = data_json.get("items", [])
|
||||
saved_count = 0
|
||||
|
||||
for index, item in enumerate(items, 1):
|
||||
title = item.get("title")
|
||||
if not title:
|
||||
continue
|
||||
# 调用数据处理层
|
||||
saved_count = process_source_data(db, source, items)
|
||||
|
||||
item_url = item.get("url", "")
|
||||
|
||||
# 3. ID 兜底与去重策略
|
||||
# 优先用接口自带的 ID -> 没有则用 URL 代替 -> 最差情况用 title 兜底
|
||||
raw_id = item.get("id") or item_url or title
|
||||
# 组合“平台标识+原始ID”算出全局唯一的 MD5 外部标识
|
||||
external_id = generate_md5(f"{platform_id}_{raw_id}")
|
||||
|
||||
# ==========================================
|
||||
# 4. 核心数据分流路由
|
||||
# 根据信息源的业务类型,将数据推入不同的物理表
|
||||
# ==========================================
|
||||
|
||||
if source.source_type in (SourceType.HOT_TREND, SourceType.API):
|
||||
# --------------------------------------------------
|
||||
# 分支 A:热搜/短新闻逻辑 -> 写入 TrendingEvent 表
|
||||
# --------------------------------------------------
|
||||
existing_event = db.query(TrendingEvent).filter(
|
||||
TrendingEvent.source_id == source.id,
|
||||
TrendingEvent.external_id == external_id
|
||||
).first()
|
||||
|
||||
event_to_log = None # 临时指针,用于后续绑定排名轨迹
|
||||
|
||||
if existing_event:
|
||||
# 场景 A1:该热搜已经在数据库中
|
||||
|
||||
# 监控并记录“标题暗改”(常见于热搜公关介入)
|
||||
if existing_event.current_headline != title:
|
||||
revision = HeadlineRevision(
|
||||
event_id=existing_event.id,
|
||||
previous_headline=existing_event.current_headline,
|
||||
revised_headline=title
|
||||
)
|
||||
db.add(revision)
|
||||
existing_event.current_headline = title # 覆盖为主表最新标题
|
||||
|
||||
# 更新当前的实时排名和 URL
|
||||
existing_event.current_ranking = index
|
||||
existing_event.event_url = item_url
|
||||
event_to_log = existing_event
|
||||
else:
|
||||
# 场景 A2:发现全新热搜
|
||||
new_event = TrendingEvent(
|
||||
source_id=source.id,
|
||||
external_id=external_id,
|
||||
current_headline=title,
|
||||
event_url=item_url,
|
||||
current_ranking=index,
|
||||
)
|
||||
db.add(new_event)
|
||||
# db.flush() 是关键:它将数据推给数据库生成了自增的主键 ID,但尚未最终 commit。
|
||||
# 拿到合法的 event_to_log.id
|
||||
db.flush()
|
||||
event_to_log = new_event
|
||||
|
||||
# 排名轨迹强制记录
|
||||
# 只要抓到了热搜(无论新旧),必须打点记录当前名次,用于前端绘制排名趋势图
|
||||
rank_log = RankingLog(
|
||||
event_id=event_to_log.id,
|
||||
ranking_position=index
|
||||
)
|
||||
db.add(rank_log)
|
||||
|
||||
elif source.source_type == SourceType.RSS_FEED:
|
||||
# --------------------------------------------------
|
||||
# 分支 B:长文章/传统订阅逻辑 -> 写入 NewsArticle 表
|
||||
# --------------------------------------------------
|
||||
existing_article = db.query(NewsArticle).filter(
|
||||
NewsArticle.source_id == source.id,
|
||||
NewsArticle.external_id == external_id
|
||||
).first()
|
||||
|
||||
if existing_article:
|
||||
# 文章如果已存在,通常只需要更新基础字段(文章一般不涉及排名起伏)
|
||||
existing_article.article_title = title
|
||||
existing_article.article_url = item_url
|
||||
# 预留位置:如果以后接口返回了摘要,可以在这里 update existing_article.original_summary
|
||||
else:
|
||||
# 全新文章入库
|
||||
new_article = NewsArticle(
|
||||
source_id=source.id,
|
||||
external_id=external_id,
|
||||
article_title=title,
|
||||
article_url=item_url,
|
||||
# original_summary=item.get("desc", ""),
|
||||
# author_name=item.get("author", "")
|
||||
)
|
||||
db.add(new_article)
|
||||
|
||||
|
||||
|
||||
saved_count += 1
|
||||
|
||||
# --------------------------------------------------
|
||||
# 5. 业务事务成功提交
|
||||
# --------------------------------------------------
|
||||
# 只有当前平台(source)的所有 item 都顺畅走完,才标记成功
|
||||
# 业务事务成功提交
|
||||
task_log.items_fetched = saved_count
|
||||
task_log.task_status = TaskStatus.SUCCESS
|
||||
db.add(task_log)
|
||||
|
||||
# 统一将当前信息源爬取的所有业务数据持久化到硬盘
|
||||
db.commit()
|
||||
print(f"[{source.source_name}] ({source.source_type}) 成功抓取并更新了 {saved_count} 条数据")
|
||||
|
||||
except Exception as e:
|
||||
# --------------------------------------------------
|
||||
# 6. 异常拦截与错误隔离机制
|
||||
# --------------------------------------------------
|
||||
# 回滚本次抓取的全部脏数据,
|
||||
# 异常拦截与错误隔离
|
||||
db.rollback()
|
||||
|
||||
# 错误日志记下来
|
||||
task_log.task_status = TaskStatus.ERROR
|
||||
task_log.error_trace = str(e)
|
||||
db.add(task_log)
|
||||
|
||||
|
||||
db.commit()
|
||||
print(f"[{source.source_name}] 抓取失败: {e}")
|
||||
print(f"[{source.source_name}] 抓取失败: {e}")
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# app/services/summary_service.py
|
||||
import os
|
||||
import json
|
||||
from datetime import timedelta
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models.models import UnifiedEvent, TrendingEvent, InfoSource, utcnow
|
||||
from app.prompts.summary_prompts import (
|
||||
SUMMARY_SYSTEM_PROMPT,
|
||||
SUMMARY_USER_PROMPT_TEMPLATE,
|
||||
)
|
||||
|
||||
HOT_SCORE_THRESHOLD = int(os.getenv("HOT_SCORE_THRESHOLD", 3))
|
||||
AI_API_KEY = os.getenv("AI_API_KEY", '')
|
||||
|
||||
# 1. 初始化异步客户端 (全局复用)
|
||||
deepseek_client = AsyncOpenAI(
|
||||
api_key=AI_API_KEY,
|
||||
base_url="https://api.deepseek.com"
|
||||
)
|
||||
|
||||
|
||||
async def call_llm_for_summary(platform_data_text: str) -> dict:
|
||||
"""调用 DeepSeek 生成统一标题和多平台视角摘要"""
|
||||
prompt = SUMMARY_USER_PROMPT_TEMPLATE.format(
|
||||
platform_data_text=platform_data_text
|
||||
)
|
||||
|
||||
# await
|
||||
response = await deepseek_client.chat.completions.create(
|
||||
model="deepseek-chat",
|
||||
messages=[
|
||||
{"role": "system", "content": SUMMARY_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
response_format={"type": "json_object"},
|
||||
temperature=1
|
||||
)
|
||||
|
||||
result_text = response.choices[0].message.content
|
||||
return json.loads(result_text)
|
||||
|
||||
|
||||
async def generate_unified_summaries():
|
||||
"""定时任务:扫描高热度事件并生成/更新摘要"""
|
||||
print(f"[{utcnow()}] 开始执行 DeepSeek 摘要生成任务...")
|
||||
|
||||
with SessionLocal() as db:
|
||||
recent_threshold = utcnow() - timedelta(days=3)
|
||||
|
||||
# 必须满足:热度达标 AND (当前热度 > 上次生成摘要时的热度) AND 近期活跃
|
||||
events = db.query(UnifiedEvent).filter(
|
||||
UnifiedEvent.hot_score >= HOT_SCORE_THRESHOLD,
|
||||
UnifiedEvent.hot_score > UnifiedEvent.last_summarized_trends_count,
|
||||
UnifiedEvent.created_at >= recent_threshold
|
||||
).all()
|
||||
|
||||
if not events:
|
||||
print("当前没有需要更新摘要的大事件,任务结束。")
|
||||
return
|
||||
|
||||
for event in events:
|
||||
# 联合查询获取该事件在各平台的子新闻
|
||||
trends = db.query(TrendingEvent, InfoSource.source_name) \
|
||||
.join(InfoSource, TrendingEvent.source_id == InfoSource.id) \
|
||||
.filter(TrendingEvent.unified_event_id == event.id) \
|
||||
.all()
|
||||
|
||||
if not trends:
|
||||
continue
|
||||
|
||||
# 按平台归类标题并去重
|
||||
platform_dict = {}
|
||||
for trend_record, source_name in trends:
|
||||
if source_name not in platform_dict:
|
||||
platform_dict[source_name] = set()
|
||||
platform_dict[source_name].add(trend_record.current_headline)
|
||||
|
||||
# 组装给大模型的 Prompt 数据
|
||||
prompt_lines = [f"【{platform}】: {', '.join(headlines)}" for platform, headlines in platform_dict.items()]
|
||||
platform_data_text = "\n".join(prompt_lines)
|
||||
|
||||
try:
|
||||
# 调用封装好的异步函数
|
||||
llm_result = await call_llm_for_summary(platform_data_text)
|
||||
|
||||
if "unified_title" in llm_result:
|
||||
event.unified_title = llm_result["unified_title"]
|
||||
if "ai_comprehensive_summary" in llm_result:
|
||||
event.ai_comprehensive_summary = llm_result["ai_comprehensive_summary"]
|
||||
|
||||
# 成功后更新水位线
|
||||
# 将最后一次总结时的热搜数量,更新为当前最新的 hot_score
|
||||
event.last_summarized_trends_count = event.hot_score
|
||||
|
||||
print(f"成功更新大事件 ID {event.id} 的深度摘要 (当前热度: {event.hot_score})。")
|
||||
|
||||
except Exception as e:
|
||||
print(f"大事件 ID {event.id} 摘要生成失败: {e}")
|
||||
continue
|
||||
|
||||
# 提交事务
|
||||
db.commit()
|
||||
@@ -0,0 +1,49 @@
|
||||
# app/utils/email_utils.py
|
||||
import os
|
||||
from email.message import EmailMessage
|
||||
import aiosmtplib
|
||||
from dotenv import load_dotenv
|
||||
import asyncio
|
||||
|
||||
load_dotenv()
|
||||
|
||||
SMTP_HOST = os.getenv("SMTP_HOST", "smtp.qiye.aliyun.com")
|
||||
SMTP_PORT = int(os.getenv("SMTP_PORT", 465))
|
||||
SMTP_USER = os.getenv("SMTP_USER", "noreply@yourdomain.com")
|
||||
SMTP_PASS = os.getenv("SMTP_PASS", "your_password")
|
||||
|
||||
|
||||
async def send_html_email(
|
||||
to_email: str,
|
||||
subject: str,
|
||||
html_content: str,
|
||||
sender_name: str = "AI 新闻早报",
|
||||
sender_email: str = None
|
||||
) -> bool:
|
||||
"""底层纯异步发送邮件工具"""
|
||||
# 如果未指定发送者邮箱,默认使用环境配置中的认证邮箱
|
||||
if sender_email is None:
|
||||
sender_email = SMTP_USER
|
||||
|
||||
message = EmailMessage()
|
||||
# 动态拼接 From 字段
|
||||
message["From"] = f"{sender_name} <{sender_email}>"
|
||||
message["To"] = to_email
|
||||
message["Subject"] = subject
|
||||
|
||||
# 设定内容为 HTML
|
||||
message.set_content(html_content, subtype="html")
|
||||
|
||||
try:
|
||||
await aiosmtplib.send(
|
||||
message,
|
||||
hostname=SMTP_HOST,
|
||||
port=SMTP_PORT,
|
||||
username=SMTP_USER,
|
||||
password=SMTP_PASS,
|
||||
use_tls=True,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"邮件发送至 {to_email} 失败: {str(e)}")
|
||||
return False
|
||||
+2
-2
@@ -2,11 +2,11 @@
|
||||
import uvicorn
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 调用 uvicorn.run() 启动服务
|
||||
# 启动服务
|
||||
uvicorn.run(
|
||||
app="app.main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=True,
|
||||
# reload=True,
|
||||
workers=1
|
||||
)
|
||||
|
||||
+15
-76
@@ -1,85 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, RouterView } from 'vue-router'
|
||||
import HelloWorld from './components/HelloWorld.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header>
|
||||
<img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" />
|
||||
|
||||
<div class="wrapper">
|
||||
<HelloWorld msg="You did it!" />
|
||||
|
||||
<nav>
|
||||
<RouterLink to="/">Home</RouterLink>
|
||||
<RouterLink to="/about">About</RouterLink>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<RouterView />
|
||||
<RouterView v-slot="{ Component }">
|
||||
<transition name="page-fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</RouterView>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
header {
|
||||
line-height: 1.5;
|
||||
max-height: 100vh;
|
||||
<style>
|
||||
.page-fade-enter-active,
|
||||
.page-fade-leave-active {
|
||||
transition: opacity 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
margin: 0 auto 2rem;
|
||||
.page-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-15px);
|
||||
}
|
||||
|
||||
nav {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
nav a.router-link-exact-active {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
nav a.router-link-exact-active:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
nav a {
|
||||
display: inline-block;
|
||||
padding: 0 1rem;
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
nav a:first-of-type {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
header {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
padding-right: calc(var(--section-gap) / 2);
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin: 0 2rem 0 0;
|
||||
}
|
||||
|
||||
header .wrapper {
|
||||
display: flex;
|
||||
place-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
nav {
|
||||
text-align: left;
|
||||
margin-left: -1rem;
|
||||
font-size: 1rem;
|
||||
|
||||
padding: 1rem 0;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.page-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(15px);
|
||||
}
|
||||
</style>
|
||||
|
||||
+229
-24
@@ -1,35 +1,240 @@
|
||||
@import './base.css';
|
||||
@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 风格主题变量
|
||||
========================================= */
|
||||
:root {
|
||||
/* 明亮模式 - 极简白与浅灰 */
|
||||
--bg-base: #f8fafc;
|
||||
--bg-surface: #ffffff;
|
||||
--bg-input: #f1f5f9;
|
||||
|
||||
--border-subtle: #e2e8f0;
|
||||
--border-strong: #cbd5e1;
|
||||
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #64748b;
|
||||
--text-placeholder: #94a3b8;
|
||||
|
||||
--brand-primary: #4f46e5;
|
||||
--brand-primary-hover: #4338ca;
|
||||
--brand-primary-alpha: rgba(79, 70, 229, 0.1);
|
||||
|
||||
--status-error: #ef4444;
|
||||
--status-success: #10b981;
|
||||
|
||||
/* 现代柔和扩散阴影 */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.05), 0 8px 10px -6px rgba(0, 0, 0, 0.01);
|
||||
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
/* 暗黑模式 - 深邃黑与暗石板色 */
|
||||
--bg-base: #020617;
|
||||
--bg-surface: #0f172a;
|
||||
--bg-input: #1e293b;
|
||||
|
||||
--border-subtle: #1e293b;
|
||||
--border-strong: #334155;
|
||||
|
||||
--text-primary: #f8fafc;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-placeholder: #475569;
|
||||
|
||||
--brand-primary: #6366f1;
|
||||
--brand-primary-hover: #818cf8;
|
||||
--brand-primary-alpha: rgba(99, 102, 241, 0.15);
|
||||
|
||||
--status-error: #f87171;
|
||||
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.2);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
2. 全局重置与排版基准
|
||||
========================================= */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
font-family: 'Inter', 'Noto Sans SC', sans-serif;
|
||||
background-color: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-weight: normal;
|
||||
min-height: 100vh;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
a,
|
||||
.green {
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
transition: 0.4s;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
a:hover {
|
||||
background-color: hsla(160, 100%, 37%, 0.2);
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
高级背景环境光与数据网格
|
||||
========================================= */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -2;
|
||||
/* 绘制点阵网格 */
|
||||
background-image: radial-gradient(var(--border-strong) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
/* 使用遮罩让网格在四周自然淡出,避免边缘生硬 */
|
||||
mask-image: radial-gradient(ellipse 80% 80% at 50% -20%, black 20%, transparent 80%);
|
||||
-webkit-mask-image: radial-gradient(ellipse 80% 80% at 50% -20%, black 20%, transparent 80%);
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
html.dark body::before {
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
/* 极弱的雷达扫射呼吸环境光 */
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: -50%;
|
||||
left: -20%;
|
||||
right: -20%;
|
||||
height: 100vh;
|
||||
z-index: -3;
|
||||
background: radial-gradient(ellipse at bottom, var(--brand-primary-alpha) 0%, transparent 60%);
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
animation: ambient-pulse 8s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes ambient-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.4;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
body {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
/* =========================================
|
||||
3. 现代表单控件体系
|
||||
========================================= */
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
background-color: var(--bg-input);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.input-field::placeholder {
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.input-field:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.input-field:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand-primary);
|
||||
box-shadow: 0 0 0 3px var(--brand-primary-alpha);
|
||||
background-color: var(--bg-surface);
|
||||
}
|
||||
|
||||
.input-action-btn {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--brand-primary);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.input-action-btn:hover:not(:disabled) {
|
||||
background: var(--brand-primary-alpha);
|
||||
}
|
||||
|
||||
.input-action-btn:disabled {
|
||||
color: var(--text-placeholder);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background-color: var(--brand-primary);
|
||||
color: #ffffff;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-md);
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: var(--brand-primary-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.btn-primary:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type {
|
||||
AuthTokenResponse,
|
||||
LoginPayload,
|
||||
LoginWithCodePayload,
|
||||
MessageResponse,
|
||||
RegisterPayload,
|
||||
} from './auth.types'
|
||||
|
||||
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? '/api/v1'
|
||||
|
||||
type JsonValue = object | null
|
||||
|
||||
const MESSAGE_MAP: Record<string, string> = {
|
||||
'Email is already registered': '该邮箱已注册',
|
||||
'Email is not registered': '该邮箱未注册',
|
||||
'Failed to send verification code': '验证码发送失败,请稍后重试',
|
||||
'Verification code sent': '验证码已发送',
|
||||
'Verification code does not exist or expired': '验证码不存在或已过期',
|
||||
'Invalid verification code': '验证码错误',
|
||||
'Invalid email or password': '邮箱或密码错误',
|
||||
'Invalid email or verification code': '邮箱或验证码错误',
|
||||
}
|
||||
|
||||
function localizeMessage(message: string): string {
|
||||
return MESSAGE_MAP[message] ?? message
|
||||
}
|
||||
|
||||
async function request<T>(path: string, payload: JsonValue): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
})
|
||||
|
||||
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('请求失败,请稍后重试')
|
||||
}
|
||||
|
||||
if (typeof data.message === 'string') {
|
||||
data.message = localizeMessage(data.message)
|
||||
}
|
||||
|
||||
return data as T
|
||||
}
|
||||
|
||||
export function sendRegisterCode(email: string): Promise<MessageResponse> {
|
||||
return request<MessageResponse>('/auth/register/send-code', { email })
|
||||
}
|
||||
|
||||
export function sendLoginCode(email: string): Promise<MessageResponse> {
|
||||
return request<MessageResponse>('/auth/login/send-code', { email })
|
||||
}
|
||||
|
||||
export function register(payload: RegisterPayload): Promise<AuthTokenResponse> {
|
||||
return request<AuthTokenResponse>('/auth/register', payload)
|
||||
}
|
||||
|
||||
export function login(payload: LoginPayload): Promise<AuthTokenResponse> {
|
||||
return request<AuthTokenResponse>('/auth/login', payload)
|
||||
}
|
||||
|
||||
export function loginWithCode(payload: LoginWithCodePayload): Promise<AuthTokenResponse> {
|
||||
return request<AuthTokenResponse>('/auth/login/code', payload)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface UserProfile {
|
||||
id: number
|
||||
email: string
|
||||
nickname: string | null
|
||||
avatar_url: string | null
|
||||
gender: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AuthTokenResponse {
|
||||
access_token: string
|
||||
token_type: string
|
||||
expires_in: number
|
||||
user: UserProfile
|
||||
}
|
||||
|
||||
export interface MessageResponse {
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface LoginPayload {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginWithCodePayload {
|
||||
email: string
|
||||
verification_code: string
|
||||
}
|
||||
|
||||
export interface RegisterPayload {
|
||||
email: string
|
||||
password: string
|
||||
verification_code: string
|
||||
nickname?: string
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div class="brand-logo-container">
|
||||
<svg class="insight-logo" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle
|
||||
class="radar-ring outer"
|
||||
cx="16"
|
||||
cy="16"
|
||||
r="14"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
stroke-dasharray="4 8"
|
||||
opacity="0.4"
|
||||
/>
|
||||
|
||||
<circle
|
||||
class="radar-ring inner"
|
||||
cx="16"
|
||||
cy="16"
|
||||
r="9"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-dasharray="12 4"
|
||||
opacity="0.6"
|
||||
/>
|
||||
|
||||
<path
|
||||
class="data-link"
|
||||
d="M16 16 L25 7 M16 16 L7 22 L5 20 M16 16 L23 25"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
opacity="0.3"
|
||||
/>
|
||||
|
||||
<circle class="data-node" cx="25" cy="7" r="1.5" fill="currentColor" opacity="0.7" />
|
||||
<circle class="data-node" cx="7" cy="22" r="1.5" fill="currentColor" opacity="0.7" />
|
||||
<circle class="data-node" cx="23" cy="25" r="1" fill="currentColor" opacity="0.5" />
|
||||
|
||||
<circle class="ai-core" cx="16" cy="16" r="3.5" fill="currentColor" />
|
||||
<circle class="ai-core-glow" cx="16" cy="16" r="3.5" fill="currentColor" opacity="0.4" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.brand-logo-container {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.2em;
|
||||
height: 2.2em;
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.insight-logo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* 核心呼吸灯动画 */
|
||||
.ai-core-glow {
|
||||
transform-origin: center;
|
||||
animation: core-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
/* 雷达圈旋转动画 */
|
||||
.radar-ring {
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.radar-ring.outer {
|
||||
animation: spin-reverse 20s linear infinite;
|
||||
}
|
||||
|
||||
.radar-ring.inner {
|
||||
animation: spin 12s linear infinite;
|
||||
}
|
||||
|
||||
/* 数据连线光流效果 (通过 dashoffset 实现虚线流动) */
|
||||
.data-link {
|
||||
stroke-dasharray: 4;
|
||||
animation: flow 3s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes core-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
transform: scale(2.2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin-reverse {
|
||||
from {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flow {
|
||||
from {
|
||||
stroke-dashoffset: 8;
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,41 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
msg: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="greetings">
|
||||
<h1 class="green">{{ msg }}</h1>
|
||||
<h3>
|
||||
You’ve successfully created a project with
|
||||
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>. What's next?
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 {
|
||||
font-weight: 500;
|
||||
font-size: 2.6rem;
|
||||
position: relative;
|
||||
top: -10px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.greetings h1,
|
||||
.greetings h3 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.greetings h1,
|
||||
.greetings h3 {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,95 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import WelcomeItem from './WelcomeItem.vue'
|
||||
import DocumentationIcon from './icons/IconDocumentation.vue'
|
||||
import ToolingIcon from './icons/IconTooling.vue'
|
||||
import EcosystemIcon from './icons/IconEcosystem.vue'
|
||||
import CommunityIcon from './icons/IconCommunity.vue'
|
||||
import SupportIcon from './icons/IconSupport.vue'
|
||||
|
||||
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<DocumentationIcon />
|
||||
</template>
|
||||
<template #heading>Documentation</template>
|
||||
|
||||
Vue’s
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
|
||||
provides you with all information you need to get started.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<ToolingIcon />
|
||||
</template>
|
||||
<template #heading>Tooling</template>
|
||||
|
||||
This project is served and bundled with
|
||||
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
|
||||
recommended IDE setup is
|
||||
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
|
||||
+
|
||||
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener"
|
||||
>Vue - Official</a
|
||||
>. If you need to test your components and web pages, check out
|
||||
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
|
||||
and
|
||||
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
|
||||
/
|
||||
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
|
||||
|
||||
<br />
|
||||
|
||||
More instructions are available in
|
||||
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
|
||||
>.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<EcosystemIcon />
|
||||
</template>
|
||||
<template #heading>Ecosystem</template>
|
||||
|
||||
Get official tools and libraries for your project:
|
||||
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
|
||||
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
|
||||
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
|
||||
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
|
||||
you need more resources, we suggest paying
|
||||
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
|
||||
a visit.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<CommunityIcon />
|
||||
</template>
|
||||
<template #heading>Community</template>
|
||||
|
||||
Got stuck? Ask your question on
|
||||
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
|
||||
(our official Discord server), or
|
||||
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
|
||||
>StackOverflow</a
|
||||
>. You should also follow the official
|
||||
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
|
||||
Bluesky account or the
|
||||
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
|
||||
X account for latest news in the Vue world.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<SupportIcon />
|
||||
</template>
|
||||
<template #heading>Support Vue</template>
|
||||
|
||||
As an independent project, Vue relies on community backing for its sustainability. You can help
|
||||
us by
|
||||
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
|
||||
</WelcomeItem>
|
||||
</template>
|
||||
@@ -0,0 +1,392 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const isAnimating = ref(false)
|
||||
|
||||
function handleToggle(event: MouseEvent) {
|
||||
const root = document.documentElement
|
||||
root.style.setProperty('--theme-flash-x', `${event.clientX}px`)
|
||||
root.style.setProperty('--theme-flash-y', `${event.clientY}px`)
|
||||
|
||||
isAnimating.value = true
|
||||
themeStore.toggleTheme()
|
||||
|
||||
window.setTimeout(() => {
|
||||
isAnimating.value = false
|
||||
}, 520)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="theme-toggle"
|
||||
:class="{ 'is-dark': themeStore.isDark, 'is-animating': isAnimating }"
|
||||
type="button"
|
||||
:aria-label="themeStore.isDark ? '切换到浅色模式' : '切换到暗黑模式'"
|
||||
@click="handleToggle"
|
||||
>
|
||||
<span class="toggle-track">
|
||||
<span class="track-glow"></span>
|
||||
<span class="track-stars"></span>
|
||||
<span class="spark-layer">
|
||||
<span class="spark"></span>
|
||||
<span class="spark"></span>
|
||||
<span class="spark"></span>
|
||||
</span>
|
||||
<span class="toggle-thumb">
|
||||
<svg class="icon icon-sun" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M12 5.25a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0V6a.75.75 0 0 1 .75-.75ZM7.227 7.227a.75.75 0 0 1 1.06 0l1.06 1.06a.75.75 0 1 1-1.06 1.06l-1.06-1.06a.75.75 0 0 1 0-1.06Zm9.426 0a.75.75 0 0 1 1.06 1.06l-1.06 1.06a.75.75 0 1 1-1.06-1.06l1.06-1.06ZM12 9a3 3 0 1 1 0 6 3 3 0 0 1 0-6Zm-6.75 3a.75.75 0 0 1 .75-.75h1.5a.75.75 0 0 1 0 1.5H6a.75.75 0 0 1-.75-.75Zm11.25 0a.75.75 0 0 1 .75-.75h1.5a.75.75 0 0 1 0 1.5h-1.5a.75.75 0 0 1-.75-.75Zm-8.213 3.653a.75.75 0 0 1 1.06 0l1.06 1.06a.75.75 0 0 1-1.06 1.06l-1.06-1.06a.75.75 0 0 1 0-1.06Zm7.426 0a.75.75 0 0 1 1.06 1.06l-1.06 1.06a.75.75 0 0 1-1.06-1.06l1.06-1.06ZM12 16.5a.75.75 0 0 1 .75.75v1.5a.75.75 0 1 1-1.5 0v-1.5a.75.75 0 0 1 .75-.75Z"
|
||||
/>
|
||||
</svg>
|
||||
<svg class="icon icon-moon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M14.5 2.25a.75.75 0 0 1 .604 1.195 7.95 7.95 0 0 0 4.6 12.395.75.75 0 0 1 .194 1.387 10.5 10.5 0 1 1-6.592-15.052.75.75 0 0 1 .194 1.387 8.954 8.954 0 0 0-1.75 16.693 9.002 9.002 0 0 0 6.074-2.04 9.45 9.45 0 0 1-4.822-8.253 9.44 9.44 0 0 1 1.305-4.82.75.75 0 0 1 .194-1.202Z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
<span class="toggle-text">{{ themeStore.isDark ? '浅色模式' : '暗黑模式' }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.theme-toggle {
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: 14px;
|
||||
padding: 6px 10px 6px 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: transform 220ms ease, border-color 220ms ease, box-shadow 220ms ease;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 8px 24px color-mix(in srgb, var(--primary) 24%, transparent);
|
||||
}
|
||||
|
||||
.theme-toggle:active {
|
||||
transform: translateY(0) scale(0.98);
|
||||
}
|
||||
|
||||
.toggle-track {
|
||||
width: 58px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(120deg, #d4e4ff, #b2c6ff);
|
||||
border: 1px solid rgba(255, 255, 255, 0.45);
|
||||
padding: 3px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: background 360ms ease;
|
||||
}
|
||||
|
||||
.theme-toggle.is-dark .toggle-track {
|
||||
background: linear-gradient(120deg, #0f1731, #1f2e62);
|
||||
}
|
||||
|
||||
.track-glow {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
left: 8px;
|
||||
top: 5px;
|
||||
background: rgba(255, 244, 170, 0.75);
|
||||
filter: blur(2px);
|
||||
opacity: 0.85;
|
||||
transition: left 360ms cubic-bezier(0.22, 1, 0.36, 1), opacity 300ms ease, background 300ms ease;
|
||||
}
|
||||
|
||||
.theme-toggle.is-dark .track-glow {
|
||||
left: 30px;
|
||||
opacity: 0.4;
|
||||
background: rgba(166, 195, 255, 0.7);
|
||||
}
|
||||
|
||||
.track-stars {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 260ms ease;
|
||||
}
|
||||
|
||||
.track-stars::before,
|
||||
.track-stars::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: rgba(226, 235, 255, 0.95);
|
||||
}
|
||||
|
||||
.track-stars::before {
|
||||
left: 16px;
|
||||
top: 9px;
|
||||
box-shadow: 16px 6px 0 rgba(226, 235, 255, 0.75), 22px -2px 0 rgba(226, 235, 255, 0.55);
|
||||
}
|
||||
|
||||
.track-stars::after {
|
||||
left: 28px;
|
||||
top: 18px;
|
||||
box-shadow: -12px 5px 0 rgba(226, 235, 255, 0.65), 8px -8px 0 rgba(226, 235, 255, 0.75);
|
||||
}
|
||||
|
||||
.theme-toggle.is-dark .track-stars {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.spark-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.spark {
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.spark:nth-child(1) {
|
||||
left: 22px;
|
||||
top: 14px;
|
||||
}
|
||||
|
||||
.spark:nth-child(2) {
|
||||
left: 30px;
|
||||
top: 11px;
|
||||
}
|
||||
|
||||
.spark:nth-child(3) {
|
||||
left: 26px;
|
||||
top: 19px;
|
||||
}
|
||||
|
||||
.theme-toggle.is-animating .spark:nth-child(1) {
|
||||
animation: spark-burst-1 480ms ease-out;
|
||||
}
|
||||
|
||||
.theme-toggle.is-animating .spark:nth-child(2) {
|
||||
animation: spark-burst-2 480ms ease-out;
|
||||
}
|
||||
|
||||
.theme-toggle.is-animating .spark:nth-child(3) {
|
||||
animation: spark-burst-3 480ms ease-out;
|
||||
}
|
||||
|
||||
.toggle-thumb {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(145deg, #ffffff, #f0f4ff);
|
||||
box-shadow: 0 3px 10px rgba(37, 49, 89, 0.26);
|
||||
position: relative;
|
||||
transform: translateX(0);
|
||||
transition: transform 360ms cubic-bezier(0.22, 1, 0.36, 1), background 320ms ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.theme-toggle.is-dark .toggle-thumb {
|
||||
transform: translateX(26px);
|
||||
background: linear-gradient(145deg, #d5e0ff, #b7c9ff);
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
inset: 5px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
fill: #526ab0;
|
||||
transition: opacity 240ms ease, transform 360ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.icon-sun {
|
||||
opacity: 1;
|
||||
transform: rotate(0deg) scale(1);
|
||||
}
|
||||
|
||||
.icon-moon {
|
||||
opacity: 0;
|
||||
transform: rotate(-40deg) scale(0.45);
|
||||
}
|
||||
|
||||
.theme-toggle.is-dark .icon-sun {
|
||||
opacity: 0;
|
||||
transform: rotate(70deg) scale(0.4);
|
||||
}
|
||||
|
||||
.theme-toggle.is-dark .icon-moon {
|
||||
opacity: 1;
|
||||
transform: rotate(0deg) scale(1);
|
||||
}
|
||||
|
||||
.toggle-text {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.theme-toggle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--primary) 36%, transparent);
|
||||
left: 34px;
|
||||
top: 14px;
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.theme-toggle.is-animating::after {
|
||||
animation: click-ripple 520ms ease-out;
|
||||
}
|
||||
|
||||
.theme-toggle.is-animating .toggle-thumb {
|
||||
animation: thumb-pop 520ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.theme-toggle.is-dark.is-animating .toggle-thumb {
|
||||
animation-name: thumb-pop-dark;
|
||||
}
|
||||
|
||||
.theme-toggle.is-animating .toggle-track {
|
||||
animation: track-flare 520ms ease;
|
||||
}
|
||||
|
||||
@keyframes click-ripple {
|
||||
0% {
|
||||
transform: scale(0.2);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(4.2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes thumb-pop {
|
||||
0% {
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: translateX(13px) scale(1.1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(26px) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes thumb-pop-dark {
|
||||
0% {
|
||||
transform: translateX(26px) scale(1);
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: translateX(13px) scale(1.1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes track-flare {
|
||||
0% {
|
||||
box-shadow: 0 0 0 rgba(255, 255, 255, 0);
|
||||
}
|
||||
|
||||
45% {
|
||||
box-shadow: 0 0 16px rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 rgba(255, 255, 255, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spark-burst-1 {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(0.3);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(-12px, -8px) scale(1.1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spark-burst-2 {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(0.3);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(10px, -10px) scale(1.15);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spark-burst-3 {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(0.3);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(2px, 12px) scale(1.2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.toggle-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
padding-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.theme-toggle,
|
||||
.toggle-track,
|
||||
.toggle-thumb,
|
||||
.track-glow,
|
||||
.icon {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.theme-toggle.is-animating::after,
|
||||
.theme-toggle.is-animating .toggle-thumb,
|
||||
.theme-toggle.is-dark.is-animating .toggle-thumb,
|
||||
.theme-toggle.is-animating .toggle-track,
|
||||
.theme-toggle.is-animating .spark {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,87 +0,0 @@
|
||||
<template>
|
||||
<div class="item">
|
||||
<i>
|
||||
<slot name="icon"></slot>
|
||||
</i>
|
||||
<div class="details">
|
||||
<h3>
|
||||
<slot name="heading"></slot>
|
||||
</h3>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.item {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.details {
|
||||
flex: 1;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
i {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
place-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.item {
|
||||
margin-top: 0;
|
||||
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
|
||||
}
|
||||
|
||||
i {
|
||||
top: calc(50% - 25px);
|
||||
left: -26px;
|
||||
position: absolute;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
border-radius: 8px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.item:before {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:after {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:first-of-type:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.item:last-of-type:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,14 +1,18 @@
|
||||
import './assets/main.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { pinia } from './stores'
|
||||
import { useThemeStore } from './stores/theme'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(pinia)
|
||||
const themeStore = useThemeStore(pinia)
|
||||
themeStore.initTheme()
|
||||
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomeView from '../views/HomeView.vue'
|
||||
|
||||
import { pinia } from '@/stores'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import HomeView from '@/views/HomeView.vue'
|
||||
import LoginView from '@/views/LoginView.vue'
|
||||
import RegisterView from '@/views/RegisterView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
@@ -8,16 +13,47 @@ const router = createRouter({
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: HomeView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/about',
|
||||
name: 'about',
|
||||
// route level code-splitting
|
||||
// this generates a separate chunk (About.[hash].js) for this route
|
||||
// which is lazy-loaded when the route is visited.
|
||||
component: () => import('../views/AboutView.vue'),
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: LoginView,
|
||||
meta: {
|
||||
guestOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'register',
|
||||
component: RegisterView,
|
||||
meta: {
|
||||
guestOnly: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const authStore = useAuthStore(pinia)
|
||||
authStore.restore()
|
||||
|
||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
return {
|
||||
name: 'login',
|
||||
query: {
|
||||
redirect: to.fullPath,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (to.meta.guestOnly && authStore.isAuthenticated) {
|
||||
return { name: 'home' }
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
import 'vue-router'
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
requiresAuth?: boolean
|
||||
guestOnly?: boolean
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { login, loginWithCode, register, sendLoginCode, sendRegisterCode } from '@/auth.api'
|
||||
import type { LoginPayload, LoginWithCodePayload, RegisterPayload, UserProfile } from '@/auth.types'
|
||||
|
||||
interface PersistedAuthState {
|
||||
accessToken: string
|
||||
expiresAt: number
|
||||
user: UserProfile
|
||||
}
|
||||
|
||||
const AUTH_STORAGE_KEY = 'insight-radar-auth'
|
||||
|
||||
function loadPersistedState(): PersistedAuthState | null {
|
||||
const raw = localStorage.getItem(AUTH_STORAGE_KEY)
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as PersistedAuthState
|
||||
if (!parsed.accessToken || !parsed.expiresAt || !parsed.user) {
|
||||
return null
|
||||
}
|
||||
return parsed
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const persisted = loadPersistedState()
|
||||
|
||||
const accessToken = ref<string | null>(persisted?.accessToken ?? null)
|
||||
const expiresAt = ref<number | null>(persisted?.expiresAt ?? null)
|
||||
const user = ref<UserProfile | null>(persisted?.user ?? null)
|
||||
const loading = ref(false)
|
||||
|
||||
const isExpired = computed(() => {
|
||||
if (!expiresAt.value) {
|
||||
return true
|
||||
}
|
||||
return Date.now() >= expiresAt.value
|
||||
})
|
||||
|
||||
const isAuthenticated = computed(() => Boolean(accessToken.value && user.value && !isExpired.value))
|
||||
|
||||
function persist() {
|
||||
if (!accessToken.value || !expiresAt.value || !user.value) {
|
||||
localStorage.removeItem(AUTH_STORAGE_KEY)
|
||||
return
|
||||
}
|
||||
|
||||
const payload: PersistedAuthState = {
|
||||
accessToken: accessToken.value,
|
||||
expiresAt: expiresAt.value,
|
||||
user: user.value,
|
||||
}
|
||||
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(payload))
|
||||
}
|
||||
|
||||
function setSession(params: { accessToken: string; expiresIn: number; user: UserProfile }) {
|
||||
accessToken.value = params.accessToken
|
||||
expiresAt.value = Date.now() + params.expiresIn * 1000
|
||||
user.value = params.user
|
||||
persist()
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
accessToken.value = null
|
||||
expiresAt.value = null
|
||||
user.value = null
|
||||
persist()
|
||||
}
|
||||
|
||||
async function loginWithPassword(payload: LoginPayload) {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await login(payload)
|
||||
setSession({
|
||||
accessToken: data.access_token,
|
||||
expiresIn: data.expires_in,
|
||||
user: data.user,
|
||||
})
|
||||
return data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loginWithVerificationCode(payload: LoginWithCodePayload) {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await loginWithCode(payload)
|
||||
setSession({
|
||||
accessToken: data.access_token,
|
||||
expiresIn: data.expires_in,
|
||||
user: data.user,
|
||||
})
|
||||
return data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function registerAccount(payload: RegisterPayload) {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await register(payload)
|
||||
setSession({
|
||||
accessToken: data.access_token,
|
||||
expiresIn: data.expires_in,
|
||||
user: data.user,
|
||||
})
|
||||
return data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function sendCode(email: string) {
|
||||
loading.value = true
|
||||
try {
|
||||
return await sendRegisterCode(email)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function sendLoginVerificationCode(email: string) {
|
||||
loading.value = true
|
||||
try {
|
||||
return await sendLoginCode(email)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function restore() {
|
||||
if (isExpired.value) {
|
||||
clearSession()
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
clearSession()
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
expiresAt,
|
||||
user,
|
||||
loading,
|
||||
isAuthenticated,
|
||||
loginWithPassword,
|
||||
loginWithVerificationCode,
|
||||
registerAccount,
|
||||
sendCode,
|
||||
sendLoginVerificationCode,
|
||||
restore,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
export const pinia = createPinia()
|
||||
@@ -0,0 +1,66 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
type ThemeMode = 'dark' | 'light'
|
||||
|
||||
const THEME_STORAGE_KEY = 'insight-radar-theme'
|
||||
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const mode = ref<ThemeMode>('light')
|
||||
const initialized = ref(false)
|
||||
let transitionTimer: number | null = null
|
||||
|
||||
const isDark = computed(() => mode.value === 'dark')
|
||||
|
||||
function applyTheme(nextMode: ThemeMode) {
|
||||
mode.value = nextMode
|
||||
document.documentElement.classList.toggle('dark', nextMode === 'dark')
|
||||
localStorage.setItem(THEME_STORAGE_KEY, nextMode)
|
||||
}
|
||||
|
||||
function runTransition(nextMode: ThemeMode) {
|
||||
const root = document.documentElement
|
||||
root.dataset.themeTransition = nextMode === 'dark' ? 'to-dark' : 'to-light'
|
||||
root.classList.add('theme-switching')
|
||||
|
||||
if (transitionTimer) {
|
||||
window.clearTimeout(transitionTimer)
|
||||
}
|
||||
|
||||
transitionTimer = window.setTimeout(() => {
|
||||
root.classList.remove('theme-switching')
|
||||
delete root.dataset.themeTransition
|
||||
transitionTimer = null
|
||||
}, 760)
|
||||
}
|
||||
|
||||
function initTheme() {
|
||||
if (initialized.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY)
|
||||
if (savedTheme === 'dark' || savedTheme === 'light') {
|
||||
applyTheme(savedTheme)
|
||||
initialized.value = true
|
||||
return
|
||||
}
|
||||
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
applyTheme(prefersDark ? 'dark' : 'light')
|
||||
initialized.value = true
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const nextMode: ThemeMode = isDark.value ? 'light' : 'dark'
|
||||
runTransition(nextMode)
|
||||
applyTheme(nextMode)
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
isDark,
|
||||
initTheme,
|
||||
toggleTheme,
|
||||
}
|
||||
})
|
||||
@@ -1,9 +1,300 @@
|
||||
<script setup lang="ts">
|
||||
import TheWelcome from '../components/TheWelcome.vue'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import ThemeToggle from '@/components/ThemeToggle.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import BrandLogo from '@/components/BrandLogo.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const displayName = computed(() => authStore.user?.nickname || authStore.user?.email || '用户')
|
||||
const tokenExpiryText = computed(() => {
|
||||
if (!authStore.expiresAt) {
|
||||
return '未知'
|
||||
}
|
||||
return new Date(authStore.expiresAt).toLocaleString('zh-CN')
|
||||
})
|
||||
|
||||
async function handleLogout() {
|
||||
authStore.logout()
|
||||
await router.replace('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<TheWelcome />
|
||||
</main>
|
||||
<div class="dashboard-layout">
|
||||
<header class="top-nav">
|
||||
<div class="nav-container">
|
||||
<div class="nav-brand">
|
||||
<div class="logo">
|
||||
<BrandLogo />
|
||||
InsightRadar
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-actions">
|
||||
<ThemeToggle />
|
||||
<div class="divider"></div>
|
||||
<button class="btn-ghost" type="button" @click="handleLogout">退出登录</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="dashboard-main">
|
||||
<div class="page-header">
|
||||
<h1>概览</h1>
|
||||
<p>欢迎回来,{{ displayName }}。这里是您的全局事件中心。</p>
|
||||
</div>
|
||||
|
||||
<div class="bento-grid">
|
||||
<article class="bento-card">
|
||||
<div class="card-icon">
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="card-label">当前账户</p>
|
||||
<h2 class="card-value">{{ displayName }}</h2>
|
||||
<p class="card-meta">{{ authStore.user?.email }}</p>
|
||||
</article>
|
||||
|
||||
<article class="bento-card">
|
||||
<div class="card-icon" :class="authStore.isAuthenticated ? 'text-success' : ''">
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="card-label">会话状态</p>
|
||||
<h2 class="card-value">{{ authStore.isAuthenticated ? '安全连接中' : '未登录' }}</h2>
|
||||
<p class="card-meta">有效期至:{{ tokenExpiryText }}</p>
|
||||
</article>
|
||||
|
||||
<article class="bento-card col-span-full feature-card">
|
||||
<div class="feature-content">
|
||||
<p class="card-label">开发者接入</p>
|
||||
<h2 class="card-value">认证体系已就绪</h2>
|
||||
<p class="card-meta">在请求您的业务接口时,请在 Headers 中携带如下凭证:</p>
|
||||
<div class="code-snippet">
|
||||
<code>Authorization: Bearer {token}</code>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-layout {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 顶部导航 */
|
||||
.top-nav {
|
||||
background-color: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.logo-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: var(--brand-primary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 23px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background-color: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
.dashboard-main {
|
||||
flex: 1;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px 0;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Bento Grid */
|
||||
.bento-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(1, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.bento-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.col-span-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.bento-card {
|
||||
background-color: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 24px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition:
|
||||
box-shadow 0.3s ease,
|
||||
border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.bento-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: var(--bg-input);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.card-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: var(--status-success);
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
.card-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.card-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 强调卡片 */
|
||||
.feature-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.feature-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, var(--brand-primary), #818cf8);
|
||||
}
|
||||
|
||||
.code-snippet {
|
||||
margin-top: 16px;
|
||||
background-color: var(--bg-input);
|
||||
border: 1px solid var(--border-subtle);
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, reactive, ref, watch } 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'
|
||||
|
||||
type LoginMode = 'password' | 'code'
|
||||
|
||||
const CODE_RESEND_SECONDS = 60
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loginMode = ref<LoginMode>('password')
|
||||
const showPassword = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const successMessage = ref('')
|
||||
const countdown = ref(0)
|
||||
|
||||
const form = reactive({
|
||||
email: '',
|
||||
password: '',
|
||||
verificationCode: '',
|
||||
})
|
||||
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const canSendCode = computed(() => {
|
||||
if (authStore.loading || countdown.value > 0) {
|
||||
return false
|
||||
}
|
||||
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(form.email)
|
||||
})
|
||||
|
||||
watch(loginMode, () => {
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
})
|
||||
|
||||
function startCooldown() {
|
||||
countdown.value = CODE_RESEND_SECONDS
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
}
|
||||
|
||||
countdownTimer = setInterval(() => {
|
||||
countdown.value -= 1
|
||||
if (countdown.value <= 0 && countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function validateForm(): string {
|
||||
if (!form.email.trim()) {
|
||||
return '请输入邮箱'
|
||||
}
|
||||
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(form.email)) {
|
||||
return '邮箱格式不正确'
|
||||
}
|
||||
|
||||
if (loginMode.value === 'password') {
|
||||
if (form.password.length < 8) {
|
||||
return '密码长度至少 8 位'
|
||||
}
|
||||
} else if (!/^\d{6}$/.test(form.verificationCode)) {
|
||||
return '验证码必须为 6 位数字'
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
async function handleSendLoginCode() {
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
|
||||
if (!canSendCode.value) {
|
||||
errorMessage.value = '请先输入有效邮箱'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authStore.sendLoginVerificationCode(form.email.trim())
|
||||
successMessage.value = result.message || '验证码已发送'
|
||||
startCooldown()
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '验证码发送失败,请稍后重试'
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
|
||||
const validationError = validateForm()
|
||||
if (validationError) {
|
||||
errorMessage.value = validationError
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (loginMode.value === 'password') {
|
||||
await authStore.loginWithPassword({
|
||||
email: form.email.trim(),
|
||||
password: form.password,
|
||||
})
|
||||
} else {
|
||||
await authStore.loginWithVerificationCode({
|
||||
email: form.email.trim(),
|
||||
verification_code: form.verificationCode,
|
||||
})
|
||||
}
|
||||
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
|
||||
await router.replace(redirect)
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '登录失败,请稍后重试'
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="split-layout">
|
||||
<aside class="brand-panel">
|
||||
<div class="brand-content">
|
||||
<div class="logo">
|
||||
<BrandLogo />
|
||||
InsightRadar
|
||||
</div>
|
||||
<h1 class="brand-title">洞察全网热点<br />让信息更聚焦</h1>
|
||||
<p class="brand-desc">
|
||||
聚合多平台趋势,自动完成热点归并与摘要。你可以用密码登录,也可以直接使用邮箱验证码快速登录。
|
||||
</p>
|
||||
</div>
|
||||
<div class="ambient-glow"></div>
|
||||
</aside>
|
||||
|
||||
<section class="form-panel">
|
||||
<div class="top-actions">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<div class="form-container">
|
||||
<div class="form-header">
|
||||
<h2>欢迎回来</h2>
|
||||
<p>登录后继续查看 InsightRadar 实时动态</p>
|
||||
</div>
|
||||
|
||||
<div class="login-mode-tabs">
|
||||
<button
|
||||
class="mode-btn"
|
||||
:class="{ active: loginMode === 'password' }"
|
||||
type="button"
|
||||
@click="loginMode = 'password'"
|
||||
>
|
||||
密码登录
|
||||
</button>
|
||||
<button
|
||||
class="mode-btn"
|
||||
:class="{ active: loginMode === 'code' }"
|
||||
type="button"
|
||||
@click="loginMode = 'code'"
|
||||
>
|
||||
邮箱验证码登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="auth-form">
|
||||
<div class="input-group">
|
||||
<label class="input-label" for="email">邮箱地址</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model.trim="form.email"
|
||||
class="input-field"
|
||||
type="email"
|
||||
placeholder="hello@example.com"
|
||||
autocomplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="loginMode === 'password'" class="input-group">
|
||||
<label class="input-label" for="password">密码</label>
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
class="input-field"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
placeholder="请输入密码"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<button type="button" class="input-action-btn" @click="showPassword = !showPassword">
|
||||
{{ showPassword ? '隐藏' : '显示' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="input-group">
|
||||
<label class="input-label" for="verification-code">验证码</label>
|
||||
<div class="input-wrapper code-wrapper">
|
||||
<input
|
||||
id="verification-code"
|
||||
v-model.trim="form.verificationCode"
|
||||
class="input-field"
|
||||
type="text"
|
||||
maxlength="6"
|
||||
placeholder="请输入 6 位验证码"
|
||||
inputmode="numeric"
|
||||
/>
|
||||
<button type="button" class="input-action-btn" :disabled="!canSendCode" @click="handleSendLoginCode">
|
||||
{{ countdown > 0 ? `${countdown}s` : '发送验证码' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="message error-msg">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<div v-if="successMessage" class="message success-msg">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.707a1 1 0 00-1.414-1.414L9 10.172 7.707 8.879a1 1 0 10-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{{ successMessage }}
|
||||
</div>
|
||||
|
||||
<button class="btn-primary" :disabled="authStore.loading" type="submit">
|
||||
{{ authStore.loading ? '登录中...' : loginMode === 'password' ? '密码登录' : '邮箱验证码登录' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="form-footer">
|
||||
还没有账号?
|
||||
<RouterLink to="/register" class="link">立即注册</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.split-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.brand-panel {
|
||||
flex: 1;
|
||||
display: none;
|
||||
background-color: var(--bg-surface);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
padding: 60px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.brand-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.brand-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 40px;
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.brand-desc {
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ambient-glow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: -20%;
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
background: radial-gradient(circle, var(--brand-primary-alpha) 0%, transparent 60%);
|
||||
transform: translateY(-50%);
|
||||
filter: blur(60px);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.form-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.top-actions {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
margin: auto;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
.form-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-header h2 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.form-header p {
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.login-mode-tabs {
|
||||
margin-bottom: 18px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
height: 40px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mode-btn.active {
|
||||
border-color: var(--brand-primary);
|
||||
background: var(--brand-primary-alpha);
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.mode-btn:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.code-wrapper .input-field {
|
||||
padding-right: 120px;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.message svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
color: var(--status-error);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.success-msg {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
color: var(--status-success);
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
margin-top: 32px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--brand-primary);
|
||||
font-weight: 500;
|
||||
margin-left: 4px;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,414 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import ThemeToggle from '@/components/ThemeToggle.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import BrandLogo from '@/components/BrandLogo.vue'
|
||||
|
||||
const CODE_RESEND_SECONDS = 60
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const form = reactive({
|
||||
email: '',
|
||||
nickname: '',
|
||||
verificationCode: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
})
|
||||
|
||||
const showPassword = ref(false)
|
||||
const showConfirmPassword = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const successMessage = ref('')
|
||||
const countdown = ref(0)
|
||||
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const canSendCode = computed(() => {
|
||||
if (authStore.loading || countdown.value > 0) return false
|
||||
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(form.email)
|
||||
})
|
||||
|
||||
// === 新增:密码强度计算逻辑 ===
|
||||
const passwordStrength = computed(() => {
|
||||
const pwd = form.password
|
||||
if (!pwd) return 0
|
||||
let score = 0
|
||||
if (pwd.length >= 8) score += 1 // 长度达标
|
||||
if (/[A-Z]/.test(pwd) || /[a-z]/.test(pwd)) score += 1 // 包含字母
|
||||
if (/[0-9]/.test(pwd)) score += 1 // 包含数字
|
||||
if (/[^A-Za-z0-9]/.test(pwd)) score += 1 // 包含特殊字符
|
||||
return score
|
||||
})
|
||||
|
||||
const strengthLabels = ['极弱', '弱', '中等', '强', '极强']
|
||||
const strengthColor = computed(() => {
|
||||
const colors = ['var(--muted)', '#db3a5e', '#f59e0b', '#2e9f5f', '#10b981']
|
||||
return colors[passwordStrength.value]
|
||||
})
|
||||
|
||||
// ==========================
|
||||
|
||||
function startCooldown() {
|
||||
countdown.value = CODE_RESEND_SECONDS
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
countdownTimer = setInterval(() => {
|
||||
countdown.value -= 1
|
||||
if (countdown.value <= 0 && countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function handleSendCode() {
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
if (!canSendCode.value) {
|
||||
errorMessage.value = '请先输入有效邮箱'
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await authStore.sendCode(form.email.trim())
|
||||
successMessage.value = result.message || '验证码已发送'
|
||||
startCooldown()
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '验证码发送失败,请稍后重试'
|
||||
}
|
||||
}
|
||||
|
||||
function validateForm() {
|
||||
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(form.email)) return '请输入有效邮箱'
|
||||
if (!/^\d{6}$/.test(form.verificationCode)) return '验证码必须为 6 位数字'
|
||||
if (form.password.length < 8) return '密码长度至少 8 位'
|
||||
if (form.password !== form.confirmPassword) return '两次密码输入不一致'
|
||||
return ''
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
errorMessage.value = validateForm()
|
||||
successMessage.value = ''
|
||||
if (errorMessage.value) return
|
||||
|
||||
try {
|
||||
await authStore.registerAccount({
|
||||
email: form.email.trim(),
|
||||
password: form.password,
|
||||
verification_code: form.verificationCode,
|
||||
nickname: form.nickname.trim() || undefined,
|
||||
})
|
||||
await router.replace('/')
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '注册失败,请稍后重试'
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="split-layout">
|
||||
<aside class="brand-panel">
|
||||
<div class="brand-content">
|
||||
<div class="logo">
|
||||
<BrandLogo />
|
||||
InsightRadar
|
||||
</div>
|
||||
<h1 class="brand-title">开启智能<br />分析之旅。</h1>
|
||||
<p class="brand-desc">
|
||||
只需几秒钟即可创建您的专属账号,体验下一代全网事件聚合与 AI 洞察服务。
|
||||
</p>
|
||||
</div>
|
||||
<div class="ambient-glow"></div>
|
||||
</aside>
|
||||
|
||||
<section class="form-panel">
|
||||
<div class="top-actions">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<div class="form-container">
|
||||
<div class="form-header">
|
||||
<h2>创建账号</h2>
|
||||
<p>填写以下信息完成注册</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="auth-form">
|
||||
<div class="input-group">
|
||||
<label class="input-label" for="email">邮箱地址</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model.trim="form.email"
|
||||
class="input-field"
|
||||
type="email"
|
||||
placeholder="hello@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label class="input-label" for="verification-code">验证码</label>
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
id="verification-code"
|
||||
v-model.trim="form.verificationCode"
|
||||
class="input-field"
|
||||
type="text"
|
||||
maxlength="6"
|
||||
placeholder="6位数字"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="input-action-btn"
|
||||
:disabled="!canSendCode"
|
||||
@click="handleSendCode"
|
||||
>
|
||||
{{ countdown > 0 ? `${countdown}s 后重发` : '获取验证码' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group" style="margin-bottom: 12px">
|
||||
<label class="input-label" for="password">设置密码</label>
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
class="input-field"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
placeholder="至少 8 位字符"
|
||||
/>
|
||||
<button type="button" class="input-action-btn" @click="showPassword = !showPassword">
|
||||
{{ showPassword ? '隐藏' : '显示' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pwd-strength" v-if="form.password.length > 0">
|
||||
<div class="strength-segments">
|
||||
<div
|
||||
v-for="n in 4"
|
||||
:key="n"
|
||||
class="segment"
|
||||
:style="{
|
||||
backgroundColor: passwordStrength >= n ? strengthColor : 'var(--border-subtle)',
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
<span class="strength-label" :style="{ color: strengthColor }">{{
|
||||
strengthLabels[passwordStrength]
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label class="input-label" for="confirm-password">确认密码</label>
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
id="confirm-password"
|
||||
v-model="form.confirmPassword"
|
||||
class="input-field"
|
||||
:type="showConfirmPassword ? 'text' : 'password'"
|
||||
placeholder="请再次输入密码"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="message error-msg">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
<div v-if="successMessage" class="message success-msg">
|
||||
{{ successMessage }}
|
||||
</div>
|
||||
|
||||
<button class="btn-primary" :disabled="authStore.loading" type="submit">
|
||||
{{ authStore.loading ? '提交中...' : '注册并登录' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="form-footer">
|
||||
已有账号?
|
||||
<RouterLink to="/login" class="link">直接登录</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 大部分样式复用 Login 的 split-layout 体系 */
|
||||
.split-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.brand-panel {
|
||||
flex: 1;
|
||||
display: none;
|
||||
background-color: var(--bg-surface);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
padding: 60px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.brand-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.brand-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.logo-dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: var(--brand-primary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 40px;
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.brand-desc {
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ambient-glow {
|
||||
position: absolute;
|
||||
top: 60%;
|
||||
left: -10%;
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: radial-gradient(circle, var(--brand-primary-alpha) 0%, transparent 60%);
|
||||
transform: translateY(-50%);
|
||||
filter: blur(50px);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.form-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.top-actions {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
margin: auto;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
.form-header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.form-header h2 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.form-header p {
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* 现代密码强度条 */
|
||||
.pwd-strength {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.strength-segments {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.segment {
|
||||
height: 4px;
|
||||
flex: 1;
|
||||
border-radius: 2px;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.strength-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
width: 32px;
|
||||
text-align: right;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
color: var(--status-error);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.success-msg {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
color: var(--status-success);
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
margin-top: 32px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--brand-primary);
|
||||
font-weight: 500;
|
||||
margin-left: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -10,6 +10,14 @@ export default defineConfig({
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
|
||||
Reference in New Issue
Block a user