认证系统更新

This commit is contained in:
aaron 2026-04-24 09:00:46 +08:00
parent 9254b4aede
commit a05ccfd1b4
27 changed files with 1092 additions and 582 deletions

View File

@ -5,3 +5,11 @@ ASTOCK_DEEPSEEK_API_KEY=sk-9f6b56f08796435d988cf202e37f6ee3
ASTOCK_ALERT_ENABLED=true ASTOCK_ALERT_ENABLED=true
ASTOCK_FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/6307668f-10aa-4fc1-8c1e-bad1b6b78d4d ASTOCK_FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/6307668f-10aa-4fc1-8c1e-bad1b6b78d4d
ASTOCK_ALERT_ENVIRONMENT=local ASTOCK_ALERT_ENVIRONMENT=local
ASTOCK_ADMIN_USERNAME=75981230@qq.com
ASTOCK_ADMIN_EMAIL=75981230@qq.com
ASTOCK_ADMIN_PASSWORD=880803
ASTOCK_SMTP_HOST=gz-smtp.qcloudmail.com
ASTOCK_SMTP_PORT=465
ASTOCK_SMTP_USERNAME=noreply@xclaw.ren
ASTOCK_SMTP_PASSWORD=bAgKbd0Zgb10irHqKpoW
ASTOCK_SMTP_SENDER=noreply@xclaw.ren

View File

@ -1,4 +1,11 @@
ASTOCK_TUSHARE_TOKEN=your_tushare_token_here ASTOCK_TUSHARE_TOKEN=your_tushare_token_here
ASTOCK_DEBUG=true ASTOCK_DEBUG=true
ASTOCK_DEEPSEEK_API_KEY=your_deepseek_api_key_here
ASTOCK_DEEPSEEK_API_KEY=sk-9f6b56f08796435d988cf202e37f6ee3 ASTOCK_ADMIN_USERNAME=admin@example.com
ASTOCK_ADMIN_EMAIL=admin@example.com
ASTOCK_ADMIN_PASSWORD=change_me
ASTOCK_SMTP_HOST=gz-smtp.qcloudmail.com
ASTOCK_SMTP_PORT=465
ASTOCK_SMTP_USERNAME=noreply@example.com
ASTOCK_SMTP_PASSWORD=your_smtp_password
ASTOCK_SMTP_SENDER=noreply@example.com

View File

@ -1,238 +1,437 @@
"""认证 API """认证 API
登录密码修改用户管理管理员数据重置管理员 邮箱+密码登录
邀请码 + 邮箱验证码 + 密码注册
""" """
import secrets from __future__ import annotations
import logging import logging
import random
import string
import re
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from sqlalchemy import select, update, text, func from sqlalchemy import select, update, text, func, delete
from app.config import settings
from app.core.auth import hash_password, verify_password, create_access_token from app.core.auth import hash_password, verify_password, create_access_token
from app.core.deps import get_current_user, get_current_admin from app.core.deps import get_current_user, get_current_admin
from app.core.email import send_email, build_register_code_email
from app.db.database import get_db from app.db.database import get_db
from app.db.tables import users_table from app.db.tables import users_table, email_verification_codes_table, invite_codes_table
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/auth", tags=["auth"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
# ---------- Request/Response Models ----------
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
username: str email: str
password: str password: str
class SendRegisterCodeRequest(BaseModel):
email: str
invite_code: str = Field(min_length=4, max_length=64)
class RegisterRequest(BaseModel):
email: str
invite_code: str = Field(min_length=4, max_length=64)
email_code: str = Field(min_length=6, max_length=6)
password: str = Field(min_length=6)
class ChangePasswordRequest(BaseModel): class ChangePasswordRequest(BaseModel):
old_password: str old_password: str
new_password: str new_password: str = Field(min_length=6)
class CreateUserRequest(BaseModel): class CreateInviteCodeRequest(BaseModel):
username: str code: str = Field(min_length=4, max_length=64)
role: str = "user" description: str = ""
max_uses: int = 1
class DataResetRequest(BaseModel): class DataResetRequest(BaseModel):
mode: str # "all", "recommendations", "date_range", "low_score" mode: str
before_date: str | None = None # for date_range mode, e.g. "2026-04-10" before_date: str | None = None
min_score: int | None = None # for low_score mode, default 60 min_score: int | None = None
# ---------- Public Endpoints ---------- def _normalize_email(email: str) -> str:
return str(email or "").strip().lower()
def _validate_email(email: str) -> str:
value = _normalize_email(email)
if not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", value):
raise HTTPException(status_code=400, detail="邮箱格式错误")
return value
def _validate_password(password: str) -> None:
if len(password or "") < settings.auth_min_password_length:
raise HTTPException(status_code=400, detail=f"密码至少 {settings.auth_min_password_length}")
def _build_username_from_email(email: str) -> str:
return _normalize_email(email)
def _generate_email_code() -> str:
return "".join(random.choices(string.digits, k=6))
def _coerce_naive_datetime(value) -> datetime | None:
if value is None:
return None
if isinstance(value, datetime):
return value.replace(tzinfo=None)
if isinstance(value, str):
return datetime.fromisoformat(value.replace("Z", "+00:00")).replace(tzinfo=None)
return None
async def _get_user_by_email(email: str) -> dict | None:
async with get_db() as db:
result = await db.execute(
select(users_table).where(users_table.c.email == _normalize_email(email))
)
user = result.mappings().first()
return dict(user) if user else None
async def _get_invite_code(code: str) -> dict | None:
async with get_db() as db:
result = await db.execute(
select(invite_codes_table).where(invite_codes_table.c.code == code.strip())
)
row = result.mappings().first()
return dict(row) if row else None
def _assert_invite_code_valid(invite_row: dict | None) -> None:
if not settings.invite_code_required:
return
if not invite_row:
raise HTTPException(status_code=400, detail="邀请码无效")
if not invite_row["is_active"]:
raise HTTPException(status_code=400, detail="邀请码已停用")
if invite_row["max_uses"] is not None and invite_row["used_count"] >= invite_row["max_uses"]:
raise HTTPException(status_code=400, detail="邀请码已用完")
expires_at = _coerce_naive_datetime(invite_row.get("expires_at"))
if expires_at and expires_at < datetime.utcnow():
raise HTTPException(status_code=400, detail="邀请码已过期")
async def _consume_invite_code(code: str) -> None:
if not settings.invite_code_required:
return
async with get_db() as db:
await db.execute(
update(invite_codes_table)
.where(invite_codes_table.c.code == code.strip())
.values(
used_count=invite_codes_table.c.used_count + 1,
updated_at=func.now(),
)
)
await db.commit()
async def _save_email_code(email: str, code: str, purpose: str) -> None:
expires_at = datetime.utcnow() + timedelta(minutes=settings.email_code_expiry_minutes)
async with get_db() as db:
await db.execute(
delete(email_verification_codes_table).where(
email_verification_codes_table.c.email == email,
email_verification_codes_table.c.purpose == purpose,
)
)
await db.execute(
email_verification_codes_table.insert().values(
email=email,
code=code,
purpose=purpose,
expires_at=expires_at,
used=False,
)
)
await db.commit()
async def _assert_email_code_valid(email: str, code: str, purpose: str) -> None:
async with get_db() as db:
result = await db.execute(
select(email_verification_codes_table)
.where(
email_verification_codes_table.c.email == email,
email_verification_codes_table.c.code == code,
email_verification_codes_table.c.purpose == purpose,
email_verification_codes_table.c.used == False, # noqa: E712
)
.order_by(email_verification_codes_table.c.id.desc())
)
row = result.mappings().first()
if not row:
raise HTTPException(status_code=400, detail="邮箱验证码错误")
if row["expires_at"] < datetime.utcnow():
raise HTTPException(status_code=400, detail="邮箱验证码已过期")
async def _mark_email_code_used(email: str, code: str, purpose: str) -> None:
async with get_db() as db:
await db.execute(
update(email_verification_codes_table)
.where(
email_verification_codes_table.c.email == email,
email_verification_codes_table.c.code == code,
email_verification_codes_table.c.purpose == purpose,
)
.values(used=True)
)
await db.commit()
@router.post("/login") @router.post("/login")
async def login(req: LoginRequest): async def login(req: LoginRequest):
"""用户登录,返回 JWT token""" email = _validate_email(req.email)
async with get_db() as db: user = await _get_user_by_email(email)
result = await db.execute(
select(users_table).where(users_table.c.username == req.username)
)
user = result.mappings().first()
if user is None or not user["is_active"]: if user is None or not user["is_active"]:
raise HTTPException(status_code=401, detail="用户名或密码错误") raise HTTPException(status_code=401, detail="邮箱或密码错误")
if not verify_password(req.password, user["password_hash"]): if not verify_password(req.password, user["password_hash"]):
raise HTTPException(status_code=401, detail="用户名或密码错误") raise HTTPException(status_code=401, detail="邮箱或密码错误")
token = create_access_token({"sub": str(user["id"]), "role": user["role"]}) token = create_access_token({"sub": str(user["id"]), "role": user["role"]})
return { return {
"token": token, "token": token,
"user": { "user": {
"id": user["id"], "id": user["id"],
"username": user["username"], "username": user["username"],
"email": user["email"],
"role": user["role"], "role": user["role"],
}, },
} }
# ---------- Authenticated Endpoints ---------- @router.post("/send-register-code")
async def send_register_code(req: SendRegisterCodeRequest):
email = _validate_email(req.email)
if await _get_user_by_email(email):
raise HTTPException(status_code=400, detail="邮箱已注册")
invite_row = await _get_invite_code(req.invite_code)
_assert_invite_code_valid(invite_row)
async with get_db() as db:
result = await db.execute(
select(email_verification_codes_table)
.where(
email_verification_codes_table.c.email == email,
email_verification_codes_table.c.purpose == "register",
)
.order_by(email_verification_codes_table.c.id.desc())
)
last_code = result.mappings().first()
if last_code and last_code["created_at"]:
created_at = _coerce_naive_datetime(last_code["created_at"])
delta = datetime.utcnow() - created_at if created_at else timedelta.max
if delta.total_seconds() < settings.email_code_cooldown_seconds:
raise HTTPException(status_code=429, detail=f"发送过于频繁,请 {settings.email_code_cooldown_seconds} 秒后再试")
code = _generate_email_code()
subject, html, text = build_register_code_email(code)
try:
send_email(subject=subject, to_email=email, html=html, text=text)
except Exception as e:
logger.error("发送注册验证码失败: %s", e)
raise HTTPException(status_code=500, detail="验证码发送失败")
await _save_email_code(email, code, "register")
return {"message": "验证码已发送,请查收邮箱"}
@router.post("/register")
async def register(req: RegisterRequest):
email = _validate_email(req.email)
_validate_password(req.password)
if await _get_user_by_email(email):
raise HTTPException(status_code=400, detail="邮箱已注册")
invite_row = await _get_invite_code(req.invite_code)
_assert_invite_code_valid(invite_row)
await _assert_email_code_valid(email, req.email_code.strip(), "register")
username = _build_username_from_email(email)
async with get_db() as db:
await db.execute(
users_table.insert().values(
username=username,
email=email,
password_hash=hash_password(req.password),
role="user",
is_active=True,
invite_code_used=req.invite_code.strip(),
)
)
await db.commit()
await _mark_email_code_used(email, req.email_code.strip(), "register")
await _consume_invite_code(req.invite_code)
return {"message": "注册成功,请使用邮箱和密码登录"}
@router.get("/me") @router.get("/me")
async def get_me(current_user: dict = Depends(get_current_user)): async def get_me(current_user: dict = Depends(get_current_user)):
"""获取当前用户信息"""
return { return {
"id": current_user["id"], "id": current_user["id"],
"username": current_user["username"], "username": current_user["username"],
"email": current_user["email"],
"role": current_user["role"], "role": current_user["role"],
"is_active": current_user["is_active"], "is_active": current_user["is_active"],
} }
@router.post("/change-password") @router.post("/change-password")
async def change_password( async def change_password(req: ChangePasswordRequest, current_user: dict = Depends(get_current_user)):
req: ChangePasswordRequest, _validate_password(req.new_password)
current_user: dict = Depends(get_current_user),
):
"""修改自己的密码"""
if not verify_password(req.old_password, current_user["password_hash"]): if not verify_password(req.old_password, current_user["password_hash"]):
raise HTTPException(status_code=400, detail="旧密码错误") raise HTTPException(status_code=400, detail="旧密码错误")
new_hash = hash_password(req.new_password)
async with get_db() as db: async with get_db() as db:
await db.execute( await db.execute(
update(users_table) update(users_table)
.where(users_table.c.id == current_user["id"]) .where(users_table.c.id == current_user["id"])
.values(password_hash=new_hash) .values(password_hash=hash_password(req.new_password), updated_at=func.now())
) )
await db.commit() await db.commit()
return {"message": "密码修改成功"} return {"message": "密码修改成功"}
# ---------- Admin Endpoints ----------
@router.get("/users") @router.get("/users")
async def list_users(admin: dict = Depends(get_current_admin)): async def list_users(admin: dict = Depends(get_current_admin)):
"""列出所有用户(管理员)"""
async with get_db() as db: async with get_db() as db:
result = await db.execute( result = await db.execute(select(users_table).order_by(users_table.c.id))
select(users_table).order_by(users_table.c.id)
)
rows = result.mappings().all() rows = result.mappings().all()
return [ return [
{ {
"id": r["id"], "id": r["id"],
"username": r["username"], "username": r["username"],
"email": r["email"],
"role": r["role"], "role": r["role"],
"is_active": r["is_active"], "is_active": r["is_active"],
"invite_code_used": r.get("invite_code_used") or "",
"created_at": r["created_at"].isoformat() if r["created_at"] else None, "created_at": r["created_at"].isoformat() if r["created_at"] else None,
} }
for r in rows for r in rows
] ]
@router.post("/users")
async def create_user(req: CreateUserRequest, admin: dict = Depends(get_current_admin)):
"""创建新用户(管理员),自动生成随机密码"""
# 检查用户名是否已存在
async with get_db() as db:
result = await db.execute(
select(users_table).where(users_table.c.username == req.username)
)
if result.first():
raise HTTPException(status_code=400, detail="用户名已存在")
if req.role not in ("admin", "user"):
raise HTTPException(status_code=400, detail="角色必须是 admin 或 user")
# 生成 12 位随机密码
raw_password = secrets.token_urlsafe(9)
password_hash = hash_password(raw_password)
await db.execute(
users_table.insert().values(
username=req.username,
password_hash=password_hash,
role=req.role,
)
)
await db.commit()
logger.info(f"管理员 {admin['username']} 创建了用户 {req.username} ({req.role})")
return {
"username": req.username,
"password": raw_password,
"role": req.role,
"message": "请妥善保管密码,此密码仅显示一次",
}
@router.delete("/users/{user_id}") @router.delete("/users/{user_id}")
async def disable_user(user_id: int, admin: dict = Depends(get_current_admin)): async def disable_user(user_id: int, admin: dict = Depends(get_current_admin)):
"""禁用用户(软删除)"""
if user_id == admin["id"]: if user_id == admin["id"]:
raise HTTPException(status_code=400, detail="不能禁用自己") raise HTTPException(status_code=400, detail="不能禁用自己")
async with get_db() as db: async with get_db() as db:
result = await db.execute( result = await db.execute(select(users_table).where(users_table.c.id == user_id))
select(users_table).where(users_table.c.id == user_id)
)
user = result.mappings().first() user = result.mappings().first()
if not user: if not user:
raise HTTPException(status_code=404, detail="用户不存在") raise HTTPException(status_code=404, detail="用户不存在")
await db.execute( await db.execute(
update(users_table) update(users_table).where(users_table.c.id == user_id).values(is_active=False, updated_at=func.now())
.where(users_table.c.id == user_id)
.values(is_active=False)
) )
await db.commit() await db.commit()
return {"message": f"用户 {user['email']} 已禁用"}
return {"message": f"用户 {user['username']} 已禁用"}
@router.post("/users/{user_id}/reset-password") @router.post("/users/{user_id}/reset-password")
async def reset_password(user_id: int, admin: dict = Depends(get_current_admin)): async def reset_password(user_id: int, admin: dict = Depends(get_current_admin)):
"""重置用户密码(管理员),生成新的随机密码""" new_password = "".join(random.choices(string.ascii_letters + string.digits, k=10))
async with get_db() as db: async with get_db() as db:
result = await db.execute( result = await db.execute(select(users_table).where(users_table.c.id == user_id))
select(users_table).where(users_table.c.id == user_id)
)
user = result.mappings().first() user = result.mappings().first()
if not user: if not user:
raise HTTPException(status_code=404, detail="用户不存在") raise HTTPException(status_code=404, detail="用户不存在")
raw_password = secrets.token_urlsafe(9)
password_hash = hash_password(raw_password)
await db.execute( await db.execute(
update(users_table) update(users_table)
.where(users_table.c.id == user_id) .where(users_table.c.id == user_id)
.values(password_hash=password_hash) .values(password_hash=hash_password(new_password), updated_at=func.now())
) )
await db.commit() await db.commit()
return { return {
"username": user["username"], "email": user["email"],
"password": raw_password, "password": new_password,
"message": "请妥善保管新密码,此密码仅显示一次", "message": "请妥善保管新密码,此密码仅显示一次",
} }
# ---------- Data Reset Endpoints (Admin Only) ---------- @router.get("/invite-codes")
async def list_invite_codes(admin: dict = Depends(get_current_admin)):
async with get_db() as db:
result = await db.execute(select(invite_codes_table).order_by(invite_codes_table.c.id.desc()))
rows = result.mappings().all()
return [
{
"id": r["id"],
"code": r["code"],
"description": r["description"] or "",
"is_active": r["is_active"],
"max_uses": r["max_uses"],
"used_count": r["used_count"],
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
}
for r in rows
]
@router.post("/invite-codes")
async def create_invite_code(req: CreateInviteCodeRequest, admin: dict = Depends(get_current_admin)):
async with get_db() as db:
result = await db.execute(select(invite_codes_table).where(invite_codes_table.c.code == req.code.strip()))
if result.first():
raise HTTPException(status_code=400, detail="邀请码已存在")
await db.execute(
invite_codes_table.insert().values(
code=req.code.strip(),
description=req.description.strip(),
max_uses=max(1, req.max_uses),
used_count=0,
is_active=True,
created_by=admin["id"],
)
)
await db.commit()
return {"message": "邀请码创建成功", "code": req.code.strip()}
@router.post("/invite-codes/{invite_id}/toggle")
async def toggle_invite_code(invite_id: int, admin: dict = Depends(get_current_admin)):
async with get_db() as db:
result = await db.execute(select(invite_codes_table).where(invite_codes_table.c.id == invite_id))
row = result.mappings().first()
if not row:
raise HTTPException(status_code=404, detail="邀请码不存在")
await db.execute(
update(invite_codes_table)
.where(invite_codes_table.c.id == invite_id)
.values(is_active=not row["is_active"], updated_at=func.now())
)
await db.commit()
return {"message": "邀请码状态已更新"}
@router.get("/data-stats") @router.get("/data-stats")
async def get_data_stats(admin: dict = Depends(get_current_admin)): async def get_data_stats(admin: dict = Depends(get_current_admin)):
"""获取数据统计(管理员)"""
async with get_db() as db: async with get_db() as db:
rec_count = (await db.execute(text("SELECT COUNT(*) FROM recommendations"))).scalar() or 0 rec_count = (await db.execute(text("SELECT COUNT(*) FROM recommendations"))).scalar() or 0
track_count = (await db.execute(text("SELECT COUNT(*) FROM recommendation_tracking"))).scalar() or 0 track_count = (await db.execute(text("SELECT COUNT(*) FROM recommendation_tracking"))).scalar() or 0
sector_count = (await db.execute(text("SELECT COUNT(*) FROM sector_heat"))).scalar() or 0 sector_count = (await db.execute(text("SELECT COUNT(*) FROM sector_heat"))).scalar() or 0
temp_count = (await db.execute(text("SELECT COUNT(*) FROM market_temperature"))).scalar() or 0 temp_count = (await db.execute(text("SELECT COUNT(*) FROM market_temperature"))).scalar() or 0
low_score = (await db.execute(text("SELECT COUNT(*) FROM recommendations WHERE score < 60"))).scalar() or 0 low_score = (await db.execute(text("SELECT COUNT(*) FROM recommendations WHERE score < 60"))).scalar() or 0
# 最新日期
latest_rec = (await db.execute(text("SELECT MAX(date(created_at)) FROM recommendations"))).scalar() or "" latest_rec = (await db.execute(text("SELECT MAX(date(created_at)) FROM recommendations"))).scalar() or ""
earliest_rec = (await db.execute(text("SELECT MIN(date(created_at)) FROM recommendations"))).scalar() or "" earliest_rec = (await db.execute(text("SELECT MIN(date(created_at)) FROM recommendations"))).scalar() or ""
return { return {
"recommendations": rec_count, "recommendations": rec_count,
"tracking": track_count, "tracking": track_count,
@ -246,85 +445,39 @@ async def get_data_stats(admin: dict = Depends(get_current_admin)):
@router.post("/data-reset") @router.post("/data-reset")
async def data_reset(req: DataResetRequest, admin: dict = Depends(get_current_admin)): async def data_reset(req: DataResetRequest, admin: dict = Depends(get_current_admin)):
"""数据重置(管理员) deleted: dict[str, int] = {}
mode:
- "all": 清除所有业务数据推荐跟踪板块热度市场温度诊断
- "recommendations": 清除推荐记录和跟踪数据保留板块和市场温度
- "date_range": 清除指定日期之前的数据
- "low_score": 清除低分推荐score < min_score和过期跟踪数据
"""
deleted = {}
async with get_db() as db: async with get_db() as db:
if req.mode == "all": if req.mode == "all":
# 清除所有业务数据(保留用户) for table in ["recommendation_tracking", "recommendations", "sector_heat", "market_temperature", "stock_diagnoses"]:
result = await db.execute(text("DELETE FROM recommendation_tracking")) result = await db.execute(text(f"DELETE FROM {table}"))
deleted["tracking"] = result.rowcount or 0 deleted[table] = result.rowcount or 0
result = await db.execute(text("DELETE FROM recommendations"))
deleted["recommendations"] = result.rowcount or 0
result = await db.execute(text("DELETE FROM sector_heat"))
deleted["sector_heat"] = result.rowcount or 0
result = await db.execute(text("DELETE FROM market_temperature"))
deleted["market_temperature"] = result.rowcount or 0
result = await db.execute(text("DELETE FROM stock_diagnoses"))
deleted["stock_diagnoses"] = result.rowcount or 0
elif req.mode == "recommendations": elif req.mode == "recommendations":
result = await db.execute(text("DELETE FROM recommendation_tracking")) for table in ["recommendation_tracking", "recommendations"]:
deleted["tracking"] = result.rowcount or 0 result = await db.execute(text(f"DELETE FROM {table}"))
result = await db.execute(text("DELETE FROM recommendations")) deleted[table] = result.rowcount or 0
deleted["recommendations"] = result.rowcount or 0
elif req.mode == "date_range": elif req.mode == "date_range":
if not req.before_date: if not req.before_date:
raise HTTPException(status_code=400, detail="date_range 模式需要 before_date 参数") raise HTTPException(status_code=400, detail="date_range 模式需要 before_date 参数")
# 删除 before_date 之前的推荐和跟踪 result = await db.execute(text("DELETE FROM recommendation_tracking WHERE track_date < :bd"), {"bd": req.before_date})
result = await db.execute(
text("DELETE FROM recommendation_tracking WHERE track_date < :bd"),
{"bd": req.before_date}
)
deleted["tracking"] = result.rowcount or 0 deleted["tracking"] = result.rowcount or 0
result = await db.execute( result = await db.execute(text("DELETE FROM recommendations WHERE date(created_at) < :bd"), {"bd": req.before_date})
text("DELETE FROM recommendations WHERE date(created_at) < :bd"),
{"bd": req.before_date}
)
deleted["recommendations"] = result.rowcount or 0 deleted["recommendations"] = result.rowcount or 0
result = await db.execute( result = await db.execute(text("DELETE FROM sector_heat WHERE trade_date < :bd"), {"bd": req.before_date})
text("DELETE FROM sector_heat WHERE trade_date < :bd"),
{"bd": req.before_date}
)
deleted["sector_heat"] = result.rowcount or 0 deleted["sector_heat"] = result.rowcount or 0
result = await db.execute( result = await db.execute(text("DELETE FROM market_temperature WHERE trade_date < :bd"), {"bd": req.before_date})
text("DELETE FROM market_temperature WHERE trade_date < :bd"),
{"bd": req.before_date}
)
deleted["market_temperature"] = result.rowcount or 0 deleted["market_temperature"] = result.rowcount or 0
elif req.mode == "low_score": elif req.mode == "low_score":
threshold = req.min_score or 60 threshold = req.min_score or 60
# 删除低分推荐及其跟踪数据
result = await db.execute( result = await db.execute(
text("DELETE FROM recommendation_tracking WHERE recommendation_id IN " text("DELETE FROM recommendation_tracking WHERE recommendation_id IN (SELECT id FROM recommendations WHERE score < :ms)"),
"(SELECT id FROM recommendations WHERE score < :ms)"), {"ms": threshold},
{"ms": threshold}
) )
deleted["tracking"] = result.rowcount or 0 deleted["tracking"] = result.rowcount or 0
result = await db.execute( result = await db.execute(text("DELETE FROM recommendations WHERE score < :ms"), {"ms": threshold})
text("DELETE FROM recommendations WHERE score < :ms"),
{"ms": threshold}
)
deleted["recommendations"] = result.rowcount or 0 deleted["recommendations"] = result.rowcount or 0
else: else:
raise HTTPException(status_code=400, detail=f"不支持的模式: {req.mode}") raise HTTPException(status_code=400, detail=f"不支持的模式: {req.mode}")
await db.commit() await db.commit()
logger.info(f"管理员 {admin['username']} 执行数据重置: mode={req.mode}, deleted={deleted}") logger.info("管理员 %s 执行数据重置: mode=%s deleted=%s", admin["email"], req.mode, deleted)
return {"status": "ok", "mode": req.mode, "deleted": deleted}
return {
"status": "ok",
"mode": req.mode,
"deleted": deleted,
}

View File

@ -76,8 +76,23 @@ class Settings(BaseSettings):
jwt_algorithm: str = "HS256" jwt_algorithm: str = "HS256"
# 默认管理员(首次启动自动创建) # 默认管理员(首次启动自动创建)
admin_username: str = "admin" admin_username: str = "75981230@qq.com"
admin_password: str = "admin123" admin_email: str = "75981230@qq.com"
admin_password: str = "880803"
# 认证体系
auth_min_password_length: int = 6
invite_code_required: bool = True
email_code_expiry_minutes: int = 10
email_code_cooldown_seconds: int = 60
# SMTP
smtp_host: str = ""
smtp_port: int = 465
smtp_username: str = ""
smtp_password: str = ""
smtp_sender: str = ""
smtp_use_ssl: bool = True
model_config = {"env_file": ".env", "env_prefix": "ASTOCK_"} model_config = {"env_file": ".env", "env_prefix": "ASTOCK_"}

50
backend/app/core/email.py Normal file
View File

@ -0,0 +1,50 @@
"""SMTP 邮件发送工具。"""
from __future__ import annotations
import smtplib
from email.message import EmailMessage
from app.config import settings
def send_email(subject: str, to_email: str, html: str, text: str | None = None) -> None:
if not settings.smtp_host or not settings.smtp_sender or not settings.smtp_username or not settings.smtp_password:
raise RuntimeError("SMTP 配置不完整")
message = EmailMessage()
message["Subject"] = subject
message["From"] = settings.smtp_sender
message["To"] = to_email
message.set_content(text or subject)
message.add_alternative(html, subtype="html")
if settings.smtp_use_ssl:
with smtplib.SMTP_SSL(settings.smtp_host, settings.smtp_port) as server:
server.login(settings.smtp_username, settings.smtp_password)
server.send_message(message)
else:
with smtplib.SMTP(settings.smtp_host, settings.smtp_port) as server:
server.starttls()
server.login(settings.smtp_username, settings.smtp_password)
server.send_message(message)
def build_register_code_email(code: str) -> tuple[str, str]:
subject = "AStock Agent 注册验证码"
html = f"""
<html>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #111827;">
<div style="max-width: 520px; margin: 0 auto; padding: 24px;">
<h2 style="margin: 0 0 12px;">注册验证码</h2>
<p style="margin: 0 0 16px; color: #4b5563;">你正在注册 AStock Agent请使用以下验证码完成注册</p>
<div style="font-size: 28px; font-weight: 700; letter-spacing: 6px; padding: 14px 18px; background: #fff7ed; color: #b45309; border-radius: 12px; display: inline-block;">
{code}
</div>
<p style="margin: 16px 0 0; color: #6b7280;">验证码 {settings.email_code_expiry_minutes} 分钟内有效请勿泄露给他人</p>
</div>
</body>
</html>
"""
text = f"你正在注册 AStock Agent验证码为{code}{settings.email_code_expiry_minutes} 分钟内有效。"
return subject, html, text

View File

@ -77,6 +77,8 @@ async def init_db():
"ALTER TABLE recommendation_tracking ADD COLUMN days_since_recommendation INTEGER DEFAULT 0", "ALTER TABLE recommendation_tracking ADD COLUMN days_since_recommendation INTEGER DEFAULT 0",
"ALTER TABLE recommendation_tracking ADD COLUMN close_reason TEXT DEFAULT ''", "ALTER TABLE recommendation_tracking ADD COLUMN close_reason TEXT DEFAULT ''",
"ALTER TABLE recommendation_tracking ADD COLUMN review_note TEXT DEFAULT ''", "ALTER TABLE recommendation_tracking ADD COLUMN review_note TEXT DEFAULT ''",
"ALTER TABLE users ADD COLUMN email TEXT",
"ALTER TABLE users ADD COLUMN invite_code_used TEXT DEFAULT ''",
"ALTER TABLE stock_diagnoses ADD COLUMN diagnosis_mode TEXT DEFAULT 'entry'", "ALTER TABLE stock_diagnoses ADD COLUMN diagnosis_mode TEXT DEFAULT 'entry'",
"ALTER TABLE user_watchlists ADD COLUMN note TEXT DEFAULT ''", "ALTER TABLE user_watchlists ADD COLUMN note TEXT DEFAULT ''",
"ALTER TABLE user_watchlists ADD COLUMN watch_group TEXT DEFAULT 'observe'", "ALTER TABLE user_watchlists ADD COLUMN watch_group TEXT DEFAULT 'observe'",
@ -98,3 +100,12 @@ async def init_db():
) )
except Exception: except Exception:
pass # 列已存在,忽略 pass # 列已存在,忽略
try:
await conn.execute(
__import__("sqlalchemy").text(
"UPDATE users SET email = username WHERE email IS NULL OR email = ''"
)
)
except Exception:
pass

View File

@ -110,9 +110,36 @@ users_table = Table(
"users", metadata, "users", metadata,
Column("id", Integer, primary_key=True, autoincrement=True), Column("id", Integer, primary_key=True, autoincrement=True),
Column("username", Text, nullable=False, unique=True), Column("username", Text, nullable=False, unique=True),
Column("email", Text, nullable=False, unique=True),
Column("password_hash", Text, nullable=False), Column("password_hash", Text, nullable=False),
Column("role", Text, default="user"), Column("role", Text, default="user"),
Column("is_active", Boolean, default=True), Column("is_active", Boolean, default=True),
Column("invite_code_used", Text, default=""),
Column("created_at", DateTime, server_default=func.now()),
Column("updated_at", DateTime, server_default=func.now()),
)
email_verification_codes_table = Table(
"email_verification_codes", metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("email", Text, nullable=False),
Column("code", Text, nullable=False),
Column("purpose", Text, nullable=False, default="register"),
Column("expires_at", DateTime, nullable=False),
Column("used", Boolean, default=False),
Column("created_at", DateTime, server_default=func.now()),
)
invite_codes_table = Table(
"invite_codes", metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("code", Text, nullable=False, unique=True),
Column("description", Text, default=""),
Column("is_active", Boolean, default=True),
Column("max_uses", Integer, default=1),
Column("used_count", Integer, default=0),
Column("expires_at", DateTime, default=None),
Column("created_by", Integer, default=None),
Column("created_at", DateTime, server_default=func.now()), Column("created_at", DateTime, server_default=func.now()),
Column("updated_at", DateTime, server_default=func.now()), Column("updated_at", DateTime, server_default=func.now()),
) )

View File

@ -22,27 +22,56 @@ logger = logging.getLogger(__name__)
async def ensure_admin_exists(): async def ensure_admin_exists():
"""如果没有管理员用户,自动创建默认管理员""" """确保配置中的管理员账号存在并可用。"""
from sqlalchemy import select, insert from sqlalchemy import select, insert, update
from app.db.database import get_db from app.db.database import get_db
from app.db.tables import users_table from app.db.tables import users_table, invite_codes_table
from app.core.auth import hash_password from app.core.auth import hash_password
async with get_db() as db: async with get_db() as db:
result = await db.execute( configured_admin = await db.execute(
select(users_table).where(users_table.c.role == "admin") select(users_table).where(users_table.c.email == settings.admin_email)
) )
if result.first() is None: admin_row = configured_admin.mappings().first()
if admin_row is None:
await db.execute( await db.execute(
insert(users_table).values( insert(users_table).values(
username=settings.admin_username, username=settings.admin_username,
email=settings.admin_email,
password_hash=hash_password(settings.admin_password), password_hash=hash_password(settings.admin_password),
role="admin", role="admin",
is_active=True, is_active=True,
) )
) )
await db.commit() await db.commit()
logger.info(f"默认管理员用户 '{settings.admin_username}' 已创建") logger.info(f"默认管理员用户 '{settings.admin_email}' 已创建")
elif admin_row["role"] != "admin" or not admin_row["is_active"]:
await db.execute(
update(users_table)
.where(users_table.c.id == admin_row["id"])
.values(
role="admin",
is_active=True,
)
)
await db.commit()
logger.info("默认管理员用户 '%s' 已修正为可用管理员", settings.admin_email)
invite = await db.execute(
select(invite_codes_table).where(invite_codes_table.c.code == "ASTOCK-ACCESS")
)
if invite.first() is None:
await db.execute(
insert(invite_codes_table).values(
code="ASTOCK-ACCESS",
description="默认注册邀请码",
is_active=True,
max_uses=999999,
used_count=0,
)
)
await db.commit()
@asynccontextmanager @asynccontextmanager

Binary file not shown.

View File

@ -1,9 +1,14 @@
{ {
"pages": { "pages": {
"/(auth)/layout": [ "/(public)/page": [
"static/chunks/webpack.js", "static/chunks/webpack.js",
"static/chunks/main-app.js", "static/chunks/main-app.js",
"static/chunks/app/(auth)/layout.js" "static/chunks/app/(public)/page.js"
],
"/(public)/layout": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/(public)/layout.js"
], ],
"/layout": [ "/layout": [
"static/chunks/webpack.js", "static/chunks/webpack.js",
@ -11,45 +16,25 @@
"static/css/app/layout.css", "static/css/app/layout.css",
"static/chunks/app/layout.js" "static/chunks/app/layout.js"
], ],
"/(public)/login/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/(public)/login/page.js"
],
"/(auth)/dashboard/page": [ "/(auth)/dashboard/page": [
"static/chunks/webpack.js", "static/chunks/webpack.js",
"static/chunks/main-app.js", "static/chunks/main-app.js",
"static/chunks/app/(auth)/dashboard/page.js" "static/chunks/app/(auth)/dashboard/page.js"
], ],
"/(auth)/recommendations/page": [ "/(auth)/layout": [
"static/chunks/webpack.js", "static/chunks/webpack.js",
"static/chunks/main-app.js", "static/chunks/main-app.js",
"static/chunks/app/(auth)/recommendations/page.js" "static/chunks/app/(auth)/layout.js"
],
"/(auth)/stock/[code]/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/(auth)/stock/[code]/page.js"
],
"/(auth)/strategy/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/(auth)/strategy/page.js"
],
"/(auth)/sectors/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/(auth)/sectors/page.js"
], ],
"/(auth)/settings/page": [ "/(auth)/settings/page": [
"static/chunks/webpack.js", "static/chunks/webpack.js",
"static/chunks/main-app.js", "static/chunks/main-app.js",
"static/chunks/app/(auth)/settings/page.js" "static/chunks/app/(auth)/settings/page.js"
],
"/(auth)/watchlists/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/(auth)/watchlists/page.js"
],
"/_not-found/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/_not-found/page.js"
] ]
} }
} }

View File

@ -2,9 +2,7 @@
"polyfillFiles": [ "polyfillFiles": [
"static/chunks/polyfills.js" "static/chunks/polyfills.js"
], ],
"devFiles": [ "devFiles": [],
"static/chunks/react-refresh.js"
],
"ampDevFiles": [], "ampDevFiles": [],
"lowPriorityFiles": [ "lowPriorityFiles": [
"static/development/_buildManifest.js", "static/development/_buildManifest.js",
@ -15,16 +13,7 @@
"static/chunks/main-app.js" "static/chunks/main-app.js"
], ],
"pages": { "pages": {
"/_app": [ "/_app": []
"static/chunks/webpack.js",
"static/chunks/main.js",
"static/chunks/pages/_app.js"
],
"/_error": [
"static/chunks/webpack.js",
"static/chunks/main.js",
"static/chunks/pages/_error.js"
]
}, },
"ampFirstPages": [] "ampFirstPages": []
} }

View File

@ -1,20 +1 @@
{ {}
"app/(auth)/sectors/page.tsx -> echarts": {
"id": "app/(auth)/sectors/page.tsx -> echarts",
"files": [
"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js"
]
},
"components/capital-flow.tsx -> echarts": {
"id": "components/capital-flow.tsx -> echarts",
"files": [
"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js"
]
},
"components/kline-chart.tsx -> echarts": {
"id": "components/kline-chart.tsx -> echarts",
"files": [
"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js"
]
}
}

View File

@ -1,8 +1,6 @@
{ {
"/_not-found/page": "app/_not-found/page.js",
"/(auth)/dashboard/page": "app/(auth)/dashboard/page.js",
"/(auth)/recommendations/page": "app/(auth)/recommendations/page.js",
"/(auth)/settings/page": "app/(auth)/settings/page.js", "/(auth)/settings/page": "app/(auth)/settings/page.js",
"/(auth)/stock/[code]/page": "app/(auth)/stock/[code]/page.js", "/(auth)/dashboard/page": "app/(auth)/dashboard/page.js",
"/(auth)/watchlists/page": "app/(auth)/watchlists/page.js" "/(public)/page": "app/(public)/page.js",
"/(public)/login/page": "app/(public)/login/page.js"
} }

View File

@ -2,9 +2,7 @@ self.__BUILD_MANIFEST = {
"polyfillFiles": [ "polyfillFiles": [
"static/chunks/polyfills.js" "static/chunks/polyfills.js"
], ],
"devFiles": [ "devFiles": [],
"static/chunks/react-refresh.js"
],
"ampDevFiles": [], "ampDevFiles": [],
"lowPriorityFiles": [], "lowPriorityFiles": [],
"rootMainFiles": [ "rootMainFiles": [
@ -12,16 +10,7 @@ self.__BUILD_MANIFEST = {
"static/chunks/main-app.js" "static/chunks/main-app.js"
], ],
"pages": { "pages": {
"/_app": [ "/_app": []
"static/chunks/webpack.js",
"static/chunks/main.js",
"static/chunks/pages/_app.js"
],
"/_error": [
"static/chunks/webpack.js",
"static/chunks/main.js",
"static/chunks/pages/_error.js"
]
}, },
"ampFirstPages": [] "ampFirstPages": []
}; };

View File

@ -1 +1 @@
self.__REACT_LOADABLE_MANIFEST="{\"app/(auth)/sectors/page.tsx -> echarts\":{\"id\":\"app/(auth)/sectors/page.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]},\"components/capital-flow.tsx -> echarts\":{\"id\":\"components/capital-flow.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]},\"components/kline-chart.tsx -> echarts\":{\"id\":\"components/kline-chart.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]}}" self.__REACT_LOADABLE_MANIFEST="{}"

View File

@ -1,5 +1 @@
{ {}
"/_app": "pages/_app.js",
"/_error": "pages/_error.js",
"/_document": "pages/_document.js"
}

View File

@ -1,5 +1,5 @@
{ {
"node": {}, "node": {},
"edge": {}, "edge": {},
"encryptionKey": "qGqEEZzUFqZxeEgbiNEfbm7ophOEC3RaVTABPCm+KZ8=" "encryptionKey": "mF8eeADKwF8ZgzkVb17uMLBKIP2Av/vT46Y1sYJ8rsk="
} }

File diff suppressed because one or more lines are too long

View File

@ -4,15 +4,18 @@ import { useState, useEffect, useCallback } from "react";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { import {
listUsersAPI, listUsersAPI,
createUserAPI,
disableUserAPI, disableUserAPI,
resetPasswordAPI, resetPasswordAPI,
listInviteCodesAPI,
createInviteCodeAPI,
toggleInviteCodeAPI,
getDataStatsAPI, getDataStatsAPI,
dataResetAPI, dataResetAPI,
getErrorLogsAPI, getErrorLogsAPI,
clearErrorLogsAPI, clearErrorLogsAPI,
getSystemStatusAPI, getSystemStatusAPI,
type UserItem, type UserItem,
type InviteCodeItem,
type DataStats, type DataStats,
type ErrorLog, type ErrorLog,
type SystemStatus, type SystemStatus,
@ -24,20 +27,23 @@ export default function UsersPage() {
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();
const [tab, setTab] = useState<Tab>("users"); const [tab, setTab] = useState<Tab>("users");
// ── Users state ──
const [users, setUsers] = useState<UserItem[]>([]); const [users, setUsers] = useState<UserItem[]>([]);
const [inviteCodes, setInviteCodes] = useState<InviteCodeItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [inviteLoading, setInviteLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [showCreate, setShowCreate] = useState(false);
const [newUsername, setNewUsername] = useState(""); const [showCreateInvite, setShowCreateInvite] = useState(false);
const [newRole, setNewRole] = useState("user"); const [inviteCode, setInviteCode] = useState("");
const [createLoading, setCreateLoading] = useState(false); const [inviteDescription, setInviteDescription] = useState("");
const [createError, setCreateError] = useState(""); const [inviteMaxUses, setInviteMaxUses] = useState("10");
const [createdResult, setCreatedResult] = useState<{ username: string; password: string } | null>(null); const [createInviteLoading, setCreateInviteLoading] = useState(false);
const [resetResult, setResetResult] = useState<{ username: string; password: string } | null>(null); const [createInviteError, setCreateInviteError] = useState("");
const [createdInviteCode, setCreatedInviteCode] = useState<string | null>(null);
const [resetResult, setResetResult] = useState<{ email: string; password: string } | null>(null);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
// ── Data reset state ──
const [dataStats, setDataStats] = useState<DataStats | null>(null); const [dataStats, setDataStats] = useState<DataStats | null>(null);
const [resetMode, setResetMode] = useState<"all" | "recommendations" | "date_range" | "low_score">("low_score"); const [resetMode, setResetMode] = useState<"all" | "recommendations" | "date_range" | "low_score">("low_score");
const [beforeDate, setBeforeDate] = useState(""); const [beforeDate, setBeforeDate] = useState("");
@ -45,7 +51,6 @@ export default function UsersPage() {
const [resetResultMsg, setResetResultMsg] = useState<string | null>(null); const [resetResultMsg, setResetResultMsg] = useState<string | null>(null);
const [confirmReset, setConfirmReset] = useState(false); const [confirmReset, setConfirmReset] = useState(false);
// ── Logs state ──
const [logs, setLogs] = useState<ErrorLog[]>([]); const [logs, setLogs] = useState<ErrorLog[]>([]);
const [logsTotal, setLogsTotal] = useState(0); const [logsTotal, setLogsTotal] = useState(0);
const [logSources, setLogSources] = useState<string[]>([]); const [logSources, setLogSources] = useState<string[]>([]);
@ -57,8 +62,8 @@ export default function UsersPage() {
const [expandedLogId, setExpandedLogId] = useState<number | null>(null); const [expandedLogId, setExpandedLogId] = useState<number | null>(null);
const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null); const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null);
function copyCredential(username: string, password: string) { function copyCredential(account: string, password: string) {
const text = `用户名:${username}\n密码${password}`; const text = `邮箱:${account}\n密码${password}`;
navigator.clipboard.writeText(text).then(() => { navigator.clipboard.writeText(text).then(() => {
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
@ -76,12 +81,23 @@ export default function UsersPage() {
} }
}, []); }, []);
const fetchInviteCodes = useCallback(async () => {
try {
const data = await listInviteCodesAPI();
setInviteCodes(data);
} catch {
setError("加载邀请码失败");
} finally {
setInviteLoading(false);
}
}, []);
const fetchStats = useCallback(async () => { const fetchStats = useCallback(async () => {
try { try {
const stats = await getDataStatsAPI(); const stats = await getDataStatsAPI();
setDataStats(stats); setDataStats(stats);
} catch { } catch {
// silently fail // ignore
} }
}, []); }, []);
@ -94,7 +110,7 @@ export default function UsersPage() {
setLogSources(result.sources); setLogSources(result.sources);
setLogLevels(result.levels); setLogLevels(result.levels);
} catch { } catch {
// silently fail // ignore
} finally { } finally {
setLogsLoading(false); setLogsLoading(false);
} }
@ -105,17 +121,18 @@ export default function UsersPage() {
const status = await getSystemStatusAPI(); const status = await getSystemStatusAPI();
setSystemStatus(status); setSystemStatus(status);
} catch { } catch {
// silently fail // ignore
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
if (currentUser?.role === "admin") { if (currentUser?.role === "admin") {
fetchUsers(); fetchUsers();
fetchInviteCodes();
fetchStats(); fetchStats();
fetchSystemStatus(); fetchSystemStatus();
} }
}, [currentUser, fetchUsers, fetchStats, fetchSystemStatus]); }, [currentUser, fetchUsers, fetchInviteCodes, fetchStats, fetchSystemStatus]);
useEffect(() => { useEffect(() => {
if (currentUser?.role === "admin" && tab === "logs") { if (currentUser?.role === "admin" && tab === "logs") {
@ -131,24 +148,35 @@ export default function UsersPage() {
); );
} }
async function handleCreate(e: React.FormEvent) { async function handleCreateInvite(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setCreateError(""); setCreateInviteError("");
if (!newUsername.trim()) {
setCreateError("请输入用户名"); const normalizedCode = inviteCode.trim().toUpperCase();
const maxUses = Number(inviteMaxUses);
if (!normalizedCode) {
setCreateInviteError("请输入邀请码");
return; return;
} }
setCreateLoading(true); if (!Number.isFinite(maxUses) || maxUses < 1) {
setCreateInviteError("邀请人数上限至少为 1");
return;
}
setCreateInviteLoading(true);
try { try {
const result = await createUserAPI(newUsername.trim(), newRole); const result = await createInviteCodeAPI(normalizedCode, inviteDescription.trim(), maxUses);
setCreatedResult({ username: result.username, password: result.password }); setCreatedInviteCode(result.code);
setNewUsername(""); setInviteCode("");
setNewRole("user"); setInviteDescription("");
setInviteMaxUses("10");
fetchInviteCodes();
fetchUsers(); fetchUsers();
} catch (err) { } catch (err) {
setCreateError(err instanceof Error ? err.message : "创建失败"); setCreateInviteError(err instanceof Error ? err.message : "创建失败");
} finally { } finally {
setCreateLoading(false); setCreateInviteLoading(false);
} }
} }
@ -164,13 +192,23 @@ export default function UsersPage() {
async function handleResetPassword(userId: number) { async function handleResetPassword(userId: number) {
try { try {
const result = await resetPasswordAPI(userId); const result = await resetPasswordAPI(userId);
setResetResult({ username: result.username, password: result.password }); setCopied(false);
setResetResult({ email: result.email, password: result.password });
fetchUsers(); fetchUsers();
} catch (err) { } catch (err) {
alert(err instanceof Error ? err.message : "操作失败"); alert(err instanceof Error ? err.message : "操作失败");
} }
} }
async function handleToggleInvite(inviteId: number) {
try {
await toggleInviteCodeAPI(inviteId);
fetchInviteCodes();
} catch (err) {
alert(err instanceof Error ? err.message : "操作失败");
}
}
async function handleDataReset() { async function handleDataReset() {
setConfirmReset(false); setConfirmReset(false);
setResetLoading(true); setResetLoading(true);
@ -205,14 +243,13 @@ export default function UsersPage() {
} }
const tabs: { key: Tab; label: string }[] = [ const tabs: { key: Tab; label: string }[] = [
{ key: "users", label: "用户管理" }, { key: "users", label: "用户与邀请码" },
{ key: "data", label: "数据管理" }, { key: "data", label: "数据管理" },
{ key: "logs", label: "系统日志" }, { key: "logs", label: "系统日志" },
]; ];
return ( return (
<div className="max-w-4xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-6"> <div className="max-w-6xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-6">
{/* Header + Tabs */}
<div className="animate-fade-in-up"> <div className="animate-fade-in-up">
<h1 className="text-xl font-semibold tracking-tight"></h1> <h1 className="text-xl font-semibold tracking-tight"></h1>
<div className="flex gap-1.5 mt-4 overflow-x-auto pb-1"> <div className="flex gap-1.5 mt-4 overflow-x-auto pb-1">
@ -232,95 +269,235 @@ export default function UsersPage() {
</div> </div>
</div> </div>
{/* ── Tab: Users ── */}
{tab === "users" && ( {tab === "users" && (
<> <div className="grid grid-cols-1 xl:grid-cols-[1.2fr_0.9fr] gap-6">
<div className="flex items-center justify-between animate-fade-in-up"> <section className="glass-card-static p-4 rounded-2xl space-y-4 animate-fade-in-up">
<button <div className="flex items-center justify-between gap-3">
onClick={() => { setShowCreate(true); setCreateError(""); setCreatedResult(null); }} <div>
className="px-4 py-2 rounded-xl text-sm font-medium bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10 hover:from-amber-500/30 hover:to-amber-600/25 transition-all" <h2 className="text-sm font-semibold text-text-primary"></h2>
> <p className="text-xs text-text-muted mt-1"></p>
+ </div>
</button> <span className="text-xs text-text-muted">{users.length} </span>
</div>
{error && <p className="text-sm text-amber-400/80 animate-fade-in-up">{error}</p>}
{loading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="glass-card-static p-4 animate-shimmer rounded-xl h-16" />
))}
</div> </div>
) : (
<div className="space-y-2 animate-fade-in-up delay-75"> {error && <p className="text-sm text-amber-400/80">{error}</p>}
{users.map((u) => (
<div {loading ? (
key={u.id} <div className="space-y-3">
className={`glass-card p-4 rounded-xl flex items-center justify-between gap-4 ${ {[1, 2, 3].map((i) => (
!u.is_active ? "opacity-50" : "" <div key={i} className="glass-card-static p-4 animate-shimmer rounded-xl h-24" />
}`} ))}
> </div>
<div className="flex items-center gap-3 min-w-0"> ) : users.length === 0 ? (
<div className="w-9 h-9 rounded-full bg-surface-3 border border-border-default flex items-center justify-center text-sm font-medium text-text-secondary shrink-0"> <div className="glass-card-static p-8 rounded-xl text-center">
{u.username.charAt(0).toUpperCase()} <p className="text-sm text-text-muted"></p>
</div> </div>
<div className="min-w-0"> ) : (
<div className="flex items-center gap-2"> <div className="space-y-2">
<span className="text-sm font-medium text-text-primary truncate">{u.username}</span> {users.map((u) => (
<span className={`text-[10px] px-1.5 py-0.5 rounded ${u.role === "admin" ? "bg-amber-500/10 text-amber-400/80" : "bg-surface-3 text-text-muted"}`}>{u.role}</span> <div
{!u.is_active && <span className="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400/80"></span>} key={u.id}
className={`glass-card p-4 rounded-xl flex items-start justify-between gap-4 ${!u.is_active ? "opacity-50" : ""}`}
>
<div className="flex items-start gap-3 min-w-0">
<div className="w-9 h-9 rounded-full bg-surface-3 border border-border-default flex items-center justify-center text-sm font-medium text-text-secondary shrink-0">
{(u.email || u.username).charAt(0).toUpperCase()}
</div>
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-text-primary truncate">{u.email || u.username}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${u.role === "admin" ? "bg-amber-500/10 text-amber-400/80" : "bg-surface-3 text-text-muted"}`}>
{u.role}
</span>
{!u.is_active && <span className="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400/80"></span>}
</div>
<p className="text-xs text-text-muted truncate">
{u.invite_code_used || "系统初始化/手动创建"}
</p>
{u.created_at && (
<p className="text-xs text-text-muted"> {new Date(u.created_at).toLocaleString("zh-CN")}</p>
)}
</div> </div>
{u.created_at && <p className="text-xs text-text-muted mt-0.5"> {new Date(u.created_at).toLocaleDateString("zh-CN")}</p>}
</div> </div>
{u.id !== currentUser!.id && (
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => handleResetPassword(u.id)}
className="px-3 py-1.5 rounded-lg text-xs text-text-secondary hover:text-text-primary bg-surface-2 hover:bg-surface-4 border border-border-subtle transition-all"
>
</button>
{u.is_active ? (
<button
onClick={() => handleDisable(u.id)}
className="px-3 py-1.5 rounded-lg text-xs text-red-400/60 hover:text-red-400 bg-red-500/[0.03] hover:bg-red-500/[0.08] border border-red-500/[0.06] transition-all"
>
</button>
) : (
<span className="text-xs text-text-muted"></span>
)}
</div>
)}
</div> </div>
{u.id !== currentUser!.id && ( ))}
<div className="flex items-center gap-2 shrink-0"> </div>
<button onClick={() => handleResetPassword(u.id)} className="px-3 py-1.5 rounded-lg text-xs text-text-secondary hover:text-text-primary bg-surface-2 hover:bg-surface-4 border border-border-subtle transition-all"></button> )}
{u.is_active ? ( </section>
<button onClick={() => handleDisable(u.id)} className="px-3 py-1.5 rounded-lg text-xs text-red-400/60 hover:text-red-400 bg-red-500/[0.03] hover:bg-red-500/[0.08] border border-red-500/[0.06] transition-all"></button>
) : (
<span className="text-xs text-text-muted"></span>
)}
</div>
)}
</div>
))}
{users.length === 0 && <div className="glass-card-static p-8 rounded-xl text-center"><p className="text-sm text-text-muted"></p></div>}
</div>
)}
{/* Create User Dialog */} <section className="glass-card-static p-4 rounded-2xl space-y-4 animate-fade-in-up delay-75">
{showCreate && ( <div className="flex items-center justify-between gap-3">
<div className="fixed inset-0 z-[60] flex items-center justify-center"> <div>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setShowCreate(false)} /> <h2 className="text-sm font-semibold text-text-primary"></h2>
<div className="relative w-full max-w-sm mx-4 p-6 rounded-2xl bg-bg-card border border-border-default shadow-card"> <p className="text-xs text-text-muted mt-1"></p>
{createdResult ? ( </div>
<div className="space-y-4"> <button
<h3 className="text-base font-semibold text-text-primary"></h3> onClick={() => {
<div className="p-4 rounded-xl bg-surface-1 border border-border-subtle space-y-2"> setShowCreateInvite(true);
<div className="flex justify-between text-sm"><span className="text-text-muted"></span><span className="text-text-primary font-medium">{createdResult.username}</span></div> setCreatedInviteCode(null);
<div className="flex justify-between text-sm items-center"><span className="text-text-muted"></span><span className="text-amber-400 font-mono text-xs">{createdResult.password}</span></div> setCreateInviteError("");
}}
className="px-4 py-2 rounded-xl text-sm font-medium bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10 hover:from-amber-500/30 hover:to-amber-600/25 transition-all"
>
+
</button>
</div>
{inviteLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="glass-card-static p-4 animate-shimmer rounded-xl h-20" />
))}
</div>
) : inviteCodes.length === 0 ? (
<div className="glass-card-static p-8 rounded-xl text-center">
<p className="text-sm text-text-muted"></p>
</div>
) : (
<div className="space-y-2">
{inviteCodes.map((item) => {
const isExhausted = item.max_uses > 0 && item.used_count >= item.max_uses;
return (
<div key={item.id} className="glass-card p-4 rounded-xl space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-text-primary">{item.code}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${item.is_active ? "bg-emerald-500/10 text-emerald-400" : "bg-surface-3 text-text-muted"}`}>
{item.is_active ? "启用中" : "已停用"}
</span>
{isExhausted && <span className="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400"></span>}
</div>
<p className="text-xs text-text-muted mt-1">{item.description || "无说明"}</p>
</div>
<button
onClick={() => handleToggleInvite(item.id)}
className="px-3 py-1.5 rounded-lg text-xs text-text-secondary hover:text-text-primary bg-surface-2 hover:bg-surface-4 border border-border-subtle transition-all"
>
{item.is_active ? "停用" : "启用"}
</button>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="bg-surface-1 rounded-lg px-3 py-2">
<div className="text-[10px] text-text-muted/50"></div>
<div className="text-sm font-mono tabular-nums text-text-primary">{item.used_count}</div>
</div>
<div className="bg-surface-1 rounded-lg px-3 py-2">
<div className="text-[10px] text-text-muted/50"></div>
<div className="text-sm font-mono tabular-nums text-text-secondary">{item.max_uses}</div>
</div>
<div className="bg-surface-1 rounded-lg px-3 py-2">
<div className="text-[10px] text-text-muted/50"></div>
<div className="text-sm font-mono tabular-nums text-amber-400">
{Math.max(item.max_uses - item.used_count, 0)}
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</section>
{showCreateInvite && (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setShowCreateInvite(false)} />
<div className="relative w-full max-w-sm mx-4 p-6 rounded-2xl bg-bg-card border border-border-default shadow-card">
{createdInviteCode ? (
<div className="space-y-4">
<h3 className="text-base font-semibold text-text-primary"></h3>
<div className="p-4 rounded-xl bg-surface-1 border border-border-subtle space-y-2">
<div className="flex justify-between text-sm">
<span className="text-text-muted"></span>
<span className="text-text-primary font-medium">{createdInviteCode}</span>
</div>
</div> </div>
<p className="text-xs text-amber-400/60"></p>
<div className="flex gap-3"> <div className="flex gap-3">
<button onClick={() => copyCredential(createdResult.username, createdResult.password)} className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10 hover:from-amber-500/30 hover:to-amber-600/25 transition-all">{copied ? "已复制" : "一键复制"}</button> <button
<button onClick={() => setShowCreate(false)} className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-surface-3 border border-border-default text-text-secondary hover:text-text-primary hover:bg-surface-4 transition-all"></button> onClick={() => {
navigator.clipboard.writeText(createdInviteCode);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10 hover:from-amber-500/30 hover:to-amber-600/25 transition-all"
>
{copied ? "已复制" : "复制邀请码"}
</button>
<button
onClick={() => {
setShowCreateInvite(false);
setCopied(false);
}}
className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-surface-3 border border-border-default text-text-secondary hover:text-text-primary hover:bg-surface-4 transition-all"
>
</button>
</div> </div>
</div> </div>
) : ( ) : (
<> <>
<h3 className="text-base font-semibold text-text-primary mb-5"></h3> <h3 className="text-base font-semibold text-text-primary mb-5"></h3>
<form onSubmit={handleCreate} className="space-y-3"> <form onSubmit={handleCreateInvite} className="space-y-3">
<input type="text" placeholder="用户名" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 placeholder-text-muted/40" /> <input
<select value={newRole} onChange={(e) => setNewRole(e.target.value)} className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 appearance-none"> type="text"
<option value="user" className="bg-bg-card text-text-primary"></option> placeholder="邀请码,例如 ASTOCK-VIP-01"
<option value="admin" className="bg-bg-card text-text-primary"></option> value={inviteCode}
</select> onChange={(e) => setInviteCode(e.target.value)}
{createError && <p className="text-xs text-amber-400/80">{createError}</p>} className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 placeholder-text-muted/40"
/>
<input
type="text"
placeholder="说明,例如 内测用户"
value={inviteDescription}
onChange={(e) => setInviteDescription(e.target.value)}
className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 placeholder-text-muted/40"
/>
<input
type="number"
min="1"
placeholder="邀请人数上限"
value={inviteMaxUses}
onChange={(e) => setInviteMaxUses(e.target.value)}
className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 placeholder-text-muted/40"
/>
{createInviteError && <p className="text-xs text-amber-400/80">{createInviteError}</p>}
<div className="flex gap-3 pt-2"> <div className="flex gap-3 pt-2">
<button type="button" onClick={() => setShowCreate(false)} className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-surface-3 border border-border-default text-text-secondary hover:text-text-primary hover:bg-surface-4 transition-all"></button> <button
<button type="submit" disabled={createLoading} className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10 hover:from-amber-500/30 hover:to-amber-600/25 transition-all disabled:opacity-50">{createLoading ? "创建中..." : "创建"}</button> type="button"
onClick={() => setShowCreateInvite(false)}
className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-surface-3 border border-border-default text-text-secondary hover:text-text-primary hover:bg-surface-4 transition-all"
>
</button>
<button
type="submit"
disabled={createInviteLoading}
className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10 hover:from-amber-500/30 hover:to-amber-600/25 transition-all disabled:opacity-50"
>
{createInviteLoading ? "创建中..." : "创建"}
</button>
</div> </div>
</form> </form>
</> </>
@ -329,28 +506,42 @@ export default function UsersPage() {
</div> </div>
)} )}
{/* Reset Password Result Dialog */}
{resetResult && ( {resetResult && (
<div className="fixed inset-0 z-[60] flex items-center justify-center"> <div className="fixed inset-0 z-[60] flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setResetResult(null)} /> <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setResetResult(null)} />
<div className="relative w-full max-w-sm mx-4 p-6 rounded-2xl bg-bg-card border border-border-default shadow-card space-y-4"> <div className="relative w-full max-w-sm mx-4 p-6 rounded-2xl bg-bg-card border border-border-default shadow-card space-y-4">
<h3 className="text-base font-semibold text-text-primary"></h3> <h3 className="text-base font-semibold text-text-primary"></h3>
<div className="p-4 rounded-xl bg-surface-1 border border-border-subtle space-y-2"> <div className="p-4 rounded-xl bg-surface-1 border border-border-subtle space-y-2">
<div className="flex justify-between text-sm"><span className="text-text-muted"></span><span className="text-text-primary font-medium">{resetResult.username}</span></div> <div className="flex justify-between text-sm">
<div className="flex justify-between text-sm items-center"><span className="text-text-muted"></span><span className="text-amber-400 font-mono text-xs">{resetResult.password}</span></div> <span className="text-text-muted"></span>
<span className="text-text-primary font-medium">{resetResult.email}</span>
</div>
<div className="flex justify-between text-sm items-center">
<span className="text-text-muted"></span>
<span className="text-amber-400 font-mono text-xs">{resetResult.password}</span>
</div>
</div> </div>
<p className="text-xs text-amber-400/60"></p> <p className="text-xs text-amber-400/60"></p>
<div className="flex gap-3"> <div className="flex gap-3">
<button onClick={() => copyCredential(resetResult.username, resetResult.password)} className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10 hover:from-amber-500/30 hover:to-amber-600/25 transition-all">{copied ? "已复制" : "一键复制"}</button> <button
<button onClick={() => setResetResult(null)} className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-surface-3 border border-border-default text-text-secondary hover:text-text-primary hover:bg-surface-4 transition-all"></button> onClick={() => copyCredential(resetResult.email, resetResult.password)}
className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10 hover:from-amber-500/30 hover:to-amber-600/25 transition-all"
>
{copied ? "已复制" : "一键复制"}
</button>
<button
onClick={() => setResetResult(null)}
className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-surface-3 border border-border-default text-text-secondary hover:text-text-primary hover:bg-surface-4 transition-all"
>
</button>
</div> </div>
</div> </div>
</div> </div>
)} )}
</> </div>
)} )}
{/* ── Tab: Data ── */}
{tab === "data" && dataStats && ( {tab === "data" && dataStats && (
<div className="glass-card-static p-4 rounded-xl animate-fade-in-up"> <div className="glass-card-static p-4 rounded-xl animate-fade-in-up">
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3"> & </h2> <h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3"> & </h2>
@ -395,10 +586,8 @@ export default function UsersPage() {
</div> </div>
)} )}
{/* ── Tab: Logs ── */}
{tab === "logs" && ( {tab === "logs" && (
<div className="space-y-4 animate-fade-in-up"> <div className="space-y-4 animate-fade-in-up">
{/* System Status */}
{systemStatus && ( {systemStatus && (
<div className="glass-card-static p-4 rounded-xl"> <div className="glass-card-static p-4 rounded-xl">
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3"></h2> <h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3"></h2>
@ -427,7 +616,6 @@ export default function UsersPage() {
</div> </div>
)} )}
{/* Filters */}
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<select value={logFilterSource} onChange={(e) => setLogFilterSource(e.target.value)} className="bg-surface-2 border border-border-default rounded-lg px-3 py-1.5 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 appearance-none"> <select value={logFilterSource} onChange={(e) => setLogFilterSource(e.target.value)} className="bg-surface-2 border border-border-default rounded-lg px-3 py-1.5 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 appearance-none">
<option value=""></option> <option value=""></option>
@ -453,7 +641,6 @@ export default function UsersPage() {
<span className="text-xs text-text-muted ml-auto">{logsTotal} </span> <span className="text-xs text-text-muted ml-auto">{logsTotal} </span>
</div> </div>
{/* Error list */}
{logsLoading ? ( {logsLoading ? (
<div className="space-y-2"> <div className="space-y-2">
{[1, 2, 3].map((i) => <div key={i} className="glass-card-static p-4 animate-shimmer rounded-xl h-20" />)} {[1, 2, 3].map((i) => <div key={i} className="glass-card-static p-4 animate-shimmer rounded-xl h-20" />)}
@ -490,4 +677,4 @@ export default function UsersPage() {
)} )}
</div> </div>
); );
} }

View File

@ -1,29 +1,53 @@
"use client"; "use client";
import { useState } from "react"; import { useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { registerAPI, sendRegisterCodeAPI } from "@/lib/api";
type Tab = "login" | "register";
export default function LoginPage() { export default function LoginPage() {
const [username, setUsername] = useState(""); const router = useRouter();
const { login } = useAuth();
const [tab, setTab] = useState<Tab>("login");
const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const { login } = useAuth();
const router = useRouter();
async function handleSubmit(e: React.FormEvent) { const [registerEmail, setRegisterEmail] = useState("");
const [inviteCode, setInviteCode] = useState("");
const [emailCode, setEmailCode] = useState("");
const [registerPassword, setRegisterPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [registerSubmitting, setRegisterSubmitting] = useState(false);
const [registerError, setRegisterError] = useState("");
const [registerSuccess, setRegisterSuccess] = useState("");
const [sendingCode, setSendingCode] = useState(false);
const [cooldown, setCooldown] = useState(0);
useMemo(() => {
if (!cooldown) return;
const timer = window.setTimeout(() => setCooldown((v) => Math.max(v - 1, 0)), 1000);
return () => window.clearTimeout(timer);
}, [cooldown]);
async function handleLoginSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setError(""); setError("");
if (!email.trim() || !password.trim()) {
if (!username.trim() || !password.trim()) { setError("请输入邮箱和密码");
setError("请输入用户名和密码"); return;
}
if (password.length < 6) {
setError("密码至少 6 位");
return; return;
} }
setSubmitting(true); setSubmitting(true);
try { try {
await login(username, password); await login(email.trim(), password);
router.push("/dashboard"); router.push("/dashboard");
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "登录失败,请重试"); setError(err instanceof Error ? err.message : "登录失败,请重试");
@ -32,80 +56,197 @@ export default function LoginPage() {
} }
} }
async function handleSendCode() {
setRegisterError("");
if (!registerEmail.trim() || !inviteCode.trim()) {
setRegisterError("请先填写邀请码和邮箱");
return;
}
setSendingCode(true);
try {
const result = await sendRegisterCodeAPI(registerEmail.trim(), inviteCode.trim());
setRegisterSuccess(result.message);
setCooldown(60);
} catch (err) {
setRegisterError(err instanceof Error ? err.message : "发送失败");
} finally {
setSendingCode(false);
}
}
async function handleRegisterSubmit(e: React.FormEvent) {
e.preventDefault();
setRegisterError("");
setRegisterSuccess("");
if (!inviteCode.trim() || !registerEmail.trim() || !emailCode.trim() || !registerPassword.trim() || !confirmPassword.trim()) {
setRegisterError("请填写完整注册信息");
return;
}
if (registerPassword.length < 6) {
setRegisterError("密码至少 6 位");
return;
}
if (registerPassword !== confirmPassword) {
setRegisterError("两次输入的密码不一致");
return;
}
setRegisterSubmitting(true);
try {
const result = await registerAPI(registerEmail.trim(), inviteCode.trim(), emailCode.trim(), registerPassword);
setRegisterSuccess(result.message);
setTab("login");
setEmail(registerEmail.trim());
setPassword(registerPassword);
setEmailCode("");
} catch (err) {
setRegisterError(err instanceof Error ? err.message : "注册失败");
} finally {
setRegisterSubmitting(false);
}
}
return ( return (
<div className="min-h-screen flex items-center justify-center bg-bg-primary relative overflow-hidden"> <div className="min-h-screen flex items-center justify-center bg-bg-primary relative overflow-hidden">
{/* Ambient glow */}
<div className="fixed top-1/3 right-1/4 w-[500px] h-[500px] bg-amber-500/5 rounded-full blur-3xl pointer-events-none" /> <div className="fixed top-1/3 right-1/4 w-[500px] h-[500px] bg-amber-500/5 rounded-full blur-3xl pointer-events-none" />
<div className="relative z-10 w-full max-w-sm mx-4"> <div className="relative z-10 w-full max-w-md mx-4">
{/* Brand */}
<div className="flex flex-col items-center mb-8"> <div className="flex flex-col items-center mb-8">
<div className="relative mb-5"> <div className="relative mb-5">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center text-lg font-bold text-white shadow-glow" style={{ boxShadow: "0 0 30px rgba(251,191,36,0.25)" }}> <div className="w-12 h-12 rounded-xl bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center text-lg font-bold text-white shadow-glow">
D D
</div> </div>
<div className="absolute inset-0 rounded-xl border border-amber-500/20 animate-pulse-ring" /> <div className="absolute inset-0 rounded-xl border border-amber-500/20 animate-pulse-ring" />
</div> </div>
<h1 className="text-lg font-bold tracking-tight text-text-primary"> <h1 className="text-lg font-bold tracking-tight text-text-primary">Dragon AI Agent</h1>
Dragon AI Agent
</h1>
<p className="text-xs text-text-muted mt-1">A </p> <p className="text-xs text-text-muted mt-1">A </p>
</div> </div>
{/* Login card */}
<div className="glass-card-static p-7 rounded-2xl"> <div className="glass-card-static p-7 rounded-2xl">
<h2 className="text-sm font-semibold text-text-primary mb-5 text-center"></h2> <div className="flex gap-2 mb-5">
{[
{ key: "login", label: "登录" },
{ key: "register", label: "注册" },
].map((item) => (
<button
key={item.key}
onClick={() => setTab(item.key as Tab)}
className={`flex-1 rounded-xl py-2.5 text-sm font-medium transition-all ${
tab === item.key
? "bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10"
: "bg-surface-2 text-text-muted border border-transparent hover:text-text-secondary"
}`}
>
{item.label}
</button>
))}
</div>
<form onSubmit={handleSubmit} className="space-y-4"> {tab === "login" ? (
<div> <form onSubmit={handleLoginSubmit} className="space-y-4">
<label className="text-xs text-text-muted mb-1.5 block"></label> <div>
<label className="text-xs text-text-muted mb-1.5 block"></label>
<input
type="email"
placeholder="输入邮箱"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30"
autoComplete="email"
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1.5 block"></label>
<input
type="password"
placeholder="输入密码(至少 6 位)"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30"
autoComplete="current-password"
/>
</div>
<button
type="submit"
disabled={submitting}
className="w-full bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl py-3 text-sm font-semibold hover:from-amber-400 hover:to-amber-500 transition-all disabled:opacity-50"
>
{submitting ? "登录中..." : "登录"}
</button>
{error ? (
<p className="text-center text-xs text-amber-400/80 bg-amber-500/5 rounded-lg py-2 border border-amber-500/10">{error}</p>
) : null}
</form>
) : (
<form onSubmit={handleRegisterSubmit} className="space-y-3">
<input <input
type="text" type="text"
placeholder="输入用户名" placeholder="邀请码"
value={username} value={inviteCode}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setInviteCode(e.target.value)}
className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 focus:border-amber-500/20 placeholder-text-muted/40 transition-all" className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30"
autoComplete="username"
/> />
</div> <input
<div> type="email"
<label className="text-xs text-text-muted mb-1.5 block"></label> placeholder="邮箱"
value={registerEmail}
onChange={(e) => setRegisterEmail(e.target.value)}
className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30"
autoComplete="email"
/>
<div className="flex gap-2">
<input
type="text"
placeholder="邮箱验证码"
value={emailCode}
onChange={(e) => setEmailCode(e.target.value)}
className="flex-1 bg-surface-2 border border-border-default rounded-xl px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30"
/>
<button
type="button"
onClick={handleSendCode}
disabled={sendingCode || cooldown > 0}
className="px-4 rounded-xl text-xs font-medium bg-surface-2 border border-border-default text-text-secondary hover:text-text-primary disabled:opacity-50"
>
{cooldown > 0 ? `${cooldown}s` : sendingCode ? "发送中" : "发送验证码"}
</button>
</div>
<input <input
type="password" type="password"
placeholder="输入密码" placeholder="设置密码(至少 6 位)"
value={password} value={registerPassword}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setRegisterPassword(e.target.value)}
className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 focus:border-amber-500/20 placeholder-text-muted/40 transition-all" className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30"
autoComplete="current-password" autoComplete="new-password"
/> />
</div> <input
<button type="password"
type="submit" placeholder="确认密码"
disabled={submitting} value={confirmPassword}
className="w-full bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl py-3 text-sm font-semibold hover:from-amber-400 hover:to-amber-500 transition-all duration-200 shadow-glow-sm hover:shadow-glow disabled:opacity-50 disabled:cursor-not-allowed active:scale-95" onChange={(e) => setConfirmPassword(e.target.value)}
> className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30"
{submitting ? ( autoComplete="new-password"
<span className="inline-flex items-center gap-2"> />
<span className="w-3.5 h-3.5 border border-white/40 border-t-white rounded-full animate-spin" /> <button
... type="submit"
</span> disabled={registerSubmitting}
) : "登录"} className="w-full bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl py-3 text-sm font-semibold hover:from-amber-400 hover:to-amber-500 transition-all disabled:opacity-50"
</button> >
</form> {registerSubmitting ? "注册中..." : "完成注册"}
</button>
{/* Error */} {registerError ? (
{error && ( <p className="text-center text-xs text-amber-400/80 bg-amber-500/5 rounded-lg py-2 border border-amber-500/10">{registerError}</p>
<p className="mt-4 text-center text-xs text-amber-400/80 bg-amber-500/5 rounded-lg py-2 border border-amber-500/10"> ) : null}
{error} {registerSuccess ? (
</p> <p className="text-center text-xs text-emerald-400/80 bg-emerald-500/5 rounded-lg py-2 border border-emerald-500/10">{registerSuccess}</p>
) : null}
</form>
)} )}
</div> </div>
{/* Footer link */}
<p className="mt-6 text-center text-xs text-text-muted/40"> <p className="mt-6 text-center text-xs text-text-muted/40">
<a href="/" className="hover:text-text-muted/60 transition-colors"></a> <a href="/" className="hover:text-text-muted/60 transition-colors"></a>
</p> </p>
</div> </div>
</div> </div>
); );
} }

View File

@ -16,7 +16,7 @@ export function UserMenu() {
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-text-muted">{user.username}</span> <span className="text-xs text-text-muted">{user.email || user.username}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-400/80"> <span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-400/80">
{user.role} {user.role}
</span> </span>

View File

@ -6,7 +6,7 @@ import { type AuthUser, loginAPI } from "@/lib/api";
interface AuthContextValue { interface AuthContextValue {
user: AuthUser | null; user: AuthUser | null;
loading: boolean; loading: boolean;
login: (username: string, password: string) => Promise<void>; login: (email: string, password: string) => Promise<void>;
logout: () => void; logout: () => void;
} }
@ -35,8 +35,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setLoading(false); setLoading(false);
}, []); }, []);
const login = useCallback(async (username: string, password: string) => { const login = useCallback(async (email: string, password: string) => {
const res = await loginAPI(username, password); const res = await loginAPI(email, password);
localStorage.setItem("auth_token", res.token); localStorage.setItem("auth_token", res.token);
localStorage.setItem("auth_user", JSON.stringify(res.user)); localStorage.setItem("auth_user", JSON.stringify(res.user));
setUser(res.user); setUser(res.user);

View File

@ -552,6 +552,7 @@ export async function* streamChat(
export interface AuthUser { export interface AuthUser {
id: number; id: number;
username: string; username: string;
email: string;
role: "admin" | "user"; role: "admin" | "user";
} }
@ -560,11 +561,11 @@ export interface LoginResponse {
user: AuthUser; user: AuthUser;
} }
export async function loginAPI(username: string, password: string): Promise<LoginResponse> { export async function loginAPI(email: string, password: string): Promise<LoginResponse> {
const res = await fetch(`${API_BASE}/api/auth/login`, { const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }), body: JSON.stringify({ email, password }),
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
@ -573,25 +574,56 @@ export async function loginAPI(username: string, password: string): Promise<Logi
return res.json(); return res.json();
} }
export async function sendRegisterCodeAPI(email: string, inviteCode: string): Promise<{ message: string }> {
return postAPI<{ message: string }>("/api/auth/send-register-code", {
email,
invite_code: inviteCode,
});
}
export async function registerAPI(
email: string,
inviteCode: string,
emailCode: string,
password: string
): Promise<{ message: string }> {
return postAPI<{ message: string }>("/api/auth/register", {
email,
invite_code: inviteCode,
email_code: emailCode,
password,
});
}
// ---------- User Management ---------- // ---------- User Management ----------
export interface UserItem { export interface UserItem {
id: number; id: number;
username: string; username: string;
email: string;
role: "admin" | "user"; role: "admin" | "user";
is_active: boolean; is_active: boolean;
invite_code_used?: string;
created_at: string | null; created_at: string | null;
} }
export interface CreateUserResult { export interface CreateUserResult {
username: string;
password: string;
role: string;
message: string; message: string;
code: string;
}
export interface InviteCodeItem {
id: number;
code: string;
description: string;
is_active: boolean;
max_uses: number;
used_count: number;
created_at: string | null;
} }
export interface ResetPasswordResult { export interface ResetPasswordResult {
username: string; email: string;
password: string; password: string;
message: string; message: string;
} }
@ -600,10 +632,6 @@ export async function listUsersAPI(): Promise<UserItem[]> {
return fetchAPI<UserItem[]>("/api/auth/users"); return fetchAPI<UserItem[]>("/api/auth/users");
} }
export async function createUserAPI(username: string, role: string): Promise<CreateUserResult> {
return postAPI<CreateUserResult>("/api/auth/users", { username, role });
}
export async function disableUserAPI(userId: number): Promise<{ message: string }> { export async function disableUserAPI(userId: number): Promise<{ message: string }> {
return deleteAPI<{ message: string }>(`/api/auth/users/${userId}`); return deleteAPI<{ message: string }>(`/api/auth/users/${userId}`);
} }
@ -612,6 +640,22 @@ export async function resetPasswordAPI(userId: number): Promise<ResetPasswordRes
return postAPI<ResetPasswordResult>(`/api/auth/users/${userId}/reset-password`); return postAPI<ResetPasswordResult>(`/api/auth/users/${userId}/reset-password`);
} }
export async function listInviteCodesAPI(): Promise<InviteCodeItem[]> {
return fetchAPI<InviteCodeItem[]>("/api/auth/invite-codes");
}
export async function createInviteCodeAPI(code: string, description: string, maxUses: number): Promise<CreateUserResult> {
return postAPI<CreateUserResult>("/api/auth/invite-codes", {
code,
description,
max_uses: maxUses,
});
}
export async function toggleInviteCodeAPI(inviteId: number): Promise<{ message: string }> {
return postAPI<{ message: string }>(`/api/auth/invite-codes/${inviteId}/toggle`);
}
export async function changePasswordAPI(oldPassword: string, newPassword: string): Promise<{ message: string }> { export async function changePasswordAPI(oldPassword: string, newPassword: string): Promise<{ message: string }> {
return postAPI<{ message: string }>("/api/auth/change-password", { return postAPI<{ message: string }>("/api/auth/change-password", {
old_password: oldPassword, old_password: oldPassword,