认证系统更新
This commit is contained in:
parent
9254b4aede
commit
a05ccfd1b4
@ -5,3 +5,11 @@ ASTOCK_DEEPSEEK_API_KEY=sk-9f6b56f08796435d988cf202e37f6ee3
|
||||
ASTOCK_ALERT_ENABLED=true
|
||||
ASTOCK_FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/6307668f-10aa-4fc1-8c1e-bad1b6b78d4d
|
||||
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
|
||||
|
||||
@ -1,4 +1,11 @@
|
||||
ASTOCK_TUSHARE_TOKEN=your_tushare_token_here
|
||||
ASTOCK_DEBUG=true
|
||||
|
||||
ASTOCK_DEEPSEEK_API_KEY=sk-9f6b56f08796435d988cf202e37f6ee3
|
||||
ASTOCK_DEEPSEEK_API_KEY=your_deepseek_api_key_here
|
||||
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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -1,238 +1,437 @@
|
||||
"""认证 API
|
||||
"""认证 API。
|
||||
|
||||
登录、密码修改、用户管理(管理员)、数据重置(管理员)
|
||||
邮箱+密码登录。
|
||||
邀请码 + 邮箱验证码 + 密码注册。
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, update, text, func
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
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.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.tables import users_table
|
||||
from app.db.tables import users_table, email_verification_codes_table, invite_codes_table
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
# ---------- Request/Response Models ----------
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
email: 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):
|
||||
old_password: str
|
||||
new_password: str
|
||||
new_password: str = Field(min_length=6)
|
||||
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
username: str
|
||||
role: str = "user"
|
||||
class CreateInviteCodeRequest(BaseModel):
|
||||
code: str = Field(min_length=4, max_length=64)
|
||||
description: str = ""
|
||||
max_uses: int = 1
|
||||
|
||||
|
||||
class DataResetRequest(BaseModel):
|
||||
mode: str # "all", "recommendations", "date_range", "low_score"
|
||||
before_date: str | None = None # for date_range mode, e.g. "2026-04-10"
|
||||
min_score: int | None = None # for low_score mode, default 60
|
||||
mode: str
|
||||
before_date: str | None = None
|
||||
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")
|
||||
async def login(req: LoginRequest):
|
||||
"""用户登录,返回 JWT token"""
|
||||
async with get_db() as db:
|
||||
result = await db.execute(
|
||||
select(users_table).where(users_table.c.username == req.username)
|
||||
)
|
||||
user = result.mappings().first()
|
||||
|
||||
email = _validate_email(req.email)
|
||||
user = await _get_user_by_email(email)
|
||||
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"]):
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
raise HTTPException(status_code=401, detail="邮箱或密码错误")
|
||||
|
||||
token = create_access_token({"sub": str(user["id"]), "role": user["role"]})
|
||||
|
||||
return {
|
||||
"token": token,
|
||||
"user": {
|
||||
"id": user["id"],
|
||||
"username": user["username"],
|
||||
"email": user["email"],
|
||||
"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")
|
||||
async def get_me(current_user: dict = Depends(get_current_user)):
|
||||
"""获取当前用户信息"""
|
||||
return {
|
||||
"id": current_user["id"],
|
||||
"username": current_user["username"],
|
||||
"email": current_user["email"],
|
||||
"role": current_user["role"],
|
||||
"is_active": current_user["is_active"],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
req: ChangePasswordRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""修改自己的密码"""
|
||||
async def change_password(req: ChangePasswordRequest, current_user: dict = Depends(get_current_user)):
|
||||
_validate_password(req.new_password)
|
||||
if not verify_password(req.old_password, current_user["password_hash"]):
|
||||
raise HTTPException(status_code=400, detail="旧密码错误")
|
||||
|
||||
new_hash = hash_password(req.new_password)
|
||||
async with get_db() as db:
|
||||
await db.execute(
|
||||
update(users_table)
|
||||
.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()
|
||||
|
||||
return {"message": "密码修改成功"}
|
||||
|
||||
|
||||
# ---------- Admin Endpoints ----------
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(admin: dict = Depends(get_current_admin)):
|
||||
"""列出所有用户(管理员)"""
|
||||
async with get_db() as db:
|
||||
result = await db.execute(
|
||||
select(users_table).order_by(users_table.c.id)
|
||||
)
|
||||
result = await db.execute(select(users_table).order_by(users_table.c.id))
|
||||
rows = result.mappings().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": r["id"],
|
||||
"username": r["username"],
|
||||
"email": r["email"],
|
||||
"role": r["role"],
|
||||
"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,
|
||||
}
|
||||
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}")
|
||||
async def disable_user(user_id: int, admin: dict = Depends(get_current_admin)):
|
||||
"""禁用用户(软删除)"""
|
||||
if user_id == admin["id"]:
|
||||
raise HTTPException(status_code=400, detail="不能禁用自己")
|
||||
|
||||
async with get_db() as db:
|
||||
result = await db.execute(
|
||||
select(users_table).where(users_table.c.id == user_id)
|
||||
)
|
||||
result = await db.execute(select(users_table).where(users_table.c.id == user_id))
|
||||
user = result.mappings().first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
await db.execute(
|
||||
update(users_table)
|
||||
.where(users_table.c.id == user_id)
|
||||
.values(is_active=False)
|
||||
update(users_table).where(users_table.c.id == user_id).values(is_active=False, updated_at=func.now())
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {"message": f"用户 {user['username']} 已禁用"}
|
||||
return {"message": f"用户 {user['email']} 已禁用"}
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/reset-password")
|
||||
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:
|
||||
result = await db.execute(
|
||||
select(users_table).where(users_table.c.id == user_id)
|
||||
)
|
||||
result = await db.execute(select(users_table).where(users_table.c.id == user_id))
|
||||
user = result.mappings().first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
raw_password = secrets.token_urlsafe(9)
|
||||
password_hash = hash_password(raw_password)
|
||||
|
||||
await db.execute(
|
||||
update(users_table)
|
||||
.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()
|
||||
|
||||
return {
|
||||
"username": user["username"],
|
||||
"password": raw_password,
|
||||
"email": user["email"],
|
||||
"password": new_password,
|
||||
"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")
|
||||
async def get_data_stats(admin: dict = Depends(get_current_admin)):
|
||||
"""获取数据统计(管理员)"""
|
||||
async with get_db() as db:
|
||||
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
|
||||
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
|
||||
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 ""
|
||||
earliest_rec = (await db.execute(text("SELECT MIN(date(created_at)) FROM recommendations"))).scalar() or ""
|
||||
|
||||
return {
|
||||
"recommendations": rec_count,
|
||||
"tracking": track_count,
|
||||
@ -246,85 +445,39 @@ async def get_data_stats(admin: dict = Depends(get_current_admin)):
|
||||
|
||||
@router.post("/data-reset")
|
||||
async def data_reset(req: DataResetRequest, admin: dict = Depends(get_current_admin)):
|
||||
"""数据重置(管理员)
|
||||
|
||||
mode:
|
||||
- "all": 清除所有业务数据(推荐、跟踪、板块热度、市场温度、诊断)
|
||||
- "recommendations": 清除推荐记录和跟踪数据,保留板块和市场温度
|
||||
- "date_range": 清除指定日期之前的数据
|
||||
- "low_score": 清除低分推荐(score < min_score)和过期跟踪数据
|
||||
"""
|
||||
deleted = {}
|
||||
|
||||
deleted: dict[str, int] = {}
|
||||
async with get_db() as db:
|
||||
if req.mode == "all":
|
||||
# 清除所有业务数据(保留用户)
|
||||
result = await db.execute(text("DELETE FROM recommendation_tracking"))
|
||||
deleted["tracking"] = 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
|
||||
|
||||
for table in ["recommendation_tracking", "recommendations", "sector_heat", "market_temperature", "stock_diagnoses"]:
|
||||
result = await db.execute(text(f"DELETE FROM {table}"))
|
||||
deleted[table] = result.rowcount or 0
|
||||
elif req.mode == "recommendations":
|
||||
result = await db.execute(text("DELETE FROM recommendation_tracking"))
|
||||
deleted["tracking"] = result.rowcount or 0
|
||||
result = await db.execute(text("DELETE FROM recommendations"))
|
||||
deleted["recommendations"] = result.rowcount or 0
|
||||
|
||||
for table in ["recommendation_tracking", "recommendations"]:
|
||||
result = await db.execute(text(f"DELETE FROM {table}"))
|
||||
deleted[table] = result.rowcount or 0
|
||||
elif req.mode == "date_range":
|
||||
if not req.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
|
||||
result = await db.execute(
|
||||
text("DELETE FROM recommendations WHERE date(created_at) < :bd"),
|
||||
{"bd": req.before_date}
|
||||
)
|
||||
result = await db.execute(text("DELETE FROM recommendations WHERE date(created_at) < :bd"), {"bd": req.before_date})
|
||||
deleted["recommendations"] = result.rowcount or 0
|
||||
result = await db.execute(
|
||||
text("DELETE FROM sector_heat WHERE trade_date < :bd"),
|
||||
{"bd": req.before_date}
|
||||
)
|
||||
result = await db.execute(text("DELETE FROM sector_heat WHERE trade_date < :bd"), {"bd": req.before_date})
|
||||
deleted["sector_heat"] = result.rowcount or 0
|
||||
result = await db.execute(
|
||||
text("DELETE FROM market_temperature WHERE trade_date < :bd"),
|
||||
{"bd": req.before_date}
|
||||
)
|
||||
result = await db.execute(text("DELETE FROM market_temperature WHERE trade_date < :bd"), {"bd": req.before_date})
|
||||
deleted["market_temperature"] = result.rowcount or 0
|
||||
|
||||
elif req.mode == "low_score":
|
||||
threshold = req.min_score or 60
|
||||
# 删除低分推荐及其跟踪数据
|
||||
result = await db.execute(
|
||||
text("DELETE FROM recommendation_tracking WHERE recommendation_id IN "
|
||||
"(SELECT id FROM recommendations WHERE score < :ms)"),
|
||||
{"ms": threshold}
|
||||
text("DELETE FROM recommendation_tracking WHERE recommendation_id IN (SELECT id FROM recommendations WHERE score < :ms)"),
|
||||
{"ms": threshold},
|
||||
)
|
||||
deleted["tracking"] = result.rowcount or 0
|
||||
result = await db.execute(
|
||||
text("DELETE FROM recommendations WHERE score < :ms"),
|
||||
{"ms": threshold}
|
||||
)
|
||||
result = await db.execute(text("DELETE FROM recommendations WHERE score < :ms"), {"ms": threshold})
|
||||
deleted["recommendations"] = result.rowcount or 0
|
||||
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的模式: {req.mode}")
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"管理员 {admin['username']} 执行数据重置: mode={req.mode}, deleted={deleted}")
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"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}
|
||||
|
||||
@ -76,8 +76,23 @@ class Settings(BaseSettings):
|
||||
jwt_algorithm: str = "HS256"
|
||||
|
||||
# 默认管理员(首次启动自动创建)
|
||||
admin_username: str = "admin"
|
||||
admin_password: str = "admin123"
|
||||
admin_username: str = "75981230@qq.com"
|
||||
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_"}
|
||||
|
||||
|
||||
50
backend/app/core/email.py
Normal file
50
backend/app/core/email.py
Normal 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
|
||||
Binary file not shown.
Binary file not shown.
@ -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 close_reason 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 user_watchlists ADD COLUMN note TEXT DEFAULT ''",
|
||||
"ALTER TABLE user_watchlists ADD COLUMN watch_group TEXT DEFAULT 'observe'",
|
||||
@ -98,3 +100,12 @@ async def init_db():
|
||||
)
|
||||
except Exception:
|
||||
pass # 列已存在,忽略
|
||||
|
||||
try:
|
||||
await conn.execute(
|
||||
__import__("sqlalchemy").text(
|
||||
"UPDATE users SET email = username WHERE email IS NULL OR email = ''"
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@ -110,9 +110,36 @@ users_table = Table(
|
||||
"users", metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("username", Text, nullable=False, unique=True),
|
||||
Column("email", Text, nullable=False, unique=True),
|
||||
Column("password_hash", Text, nullable=False),
|
||||
Column("role", Text, default="user"),
|
||||
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("updated_at", DateTime, server_default=func.now()),
|
||||
)
|
||||
|
||||
@ -22,27 +22,56 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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.tables import users_table
|
||||
from app.db.tables import users_table, invite_codes_table
|
||||
from app.core.auth import hash_password
|
||||
|
||||
async with get_db() as db:
|
||||
result = await db.execute(
|
||||
select(users_table).where(users_table.c.role == "admin")
|
||||
configured_admin = await db.execute(
|
||||
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(
|
||||
insert(users_table).values(
|
||||
username=settings.admin_username,
|
||||
email=settings.admin_email,
|
||||
password_hash=hash_password(settings.admin_password),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
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
|
||||
|
||||
Binary file not shown.
@ -1,9 +1,14 @@
|
||||
{
|
||||
"pages": {
|
||||
"/(auth)/layout": [
|
||||
"/(public)/page": [
|
||||
"static/chunks/webpack.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": [
|
||||
"static/chunks/webpack.js",
|
||||
@ -11,45 +16,25 @@
|
||||
"static/css/app/layout.css",
|
||||
"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": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/(auth)/dashboard/page.js"
|
||||
],
|
||||
"/(auth)/recommendations/page": [
|
||||
"/(auth)/layout": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/(auth)/recommendations/page.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"
|
||||
"static/chunks/app/(auth)/layout.js"
|
||||
],
|
||||
"/(auth)/settings/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -2,9 +2,7 @@
|
||||
"polyfillFiles": [
|
||||
"static/chunks/polyfills.js"
|
||||
],
|
||||
"devFiles": [
|
||||
"static/chunks/react-refresh.js"
|
||||
],
|
||||
"devFiles": [],
|
||||
"ampDevFiles": [],
|
||||
"lowPriorityFiles": [
|
||||
"static/development/_buildManifest.js",
|
||||
@ -15,16 +13,7 @@
|
||||
"static/chunks/main-app.js"
|
||||
],
|
||||
"pages": {
|
||||
"/_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"
|
||||
]
|
||||
"/_app": []
|
||||
},
|
||||
"ampFirstPages": []
|
||||
}
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
{}
|
||||
@ -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)/stock/[code]/page": "app/(auth)/stock/[code]/page.js",
|
||||
"/(auth)/watchlists/page": "app/(auth)/watchlists/page.js"
|
||||
"/(auth)/dashboard/page": "app/(auth)/dashboard/page.js",
|
||||
"/(public)/page": "app/(public)/page.js",
|
||||
"/(public)/login/page": "app/(public)/login/page.js"
|
||||
}
|
||||
@ -2,9 +2,7 @@ self.__BUILD_MANIFEST = {
|
||||
"polyfillFiles": [
|
||||
"static/chunks/polyfills.js"
|
||||
],
|
||||
"devFiles": [
|
||||
"static/chunks/react-refresh.js"
|
||||
],
|
||||
"devFiles": [],
|
||||
"ampDevFiles": [],
|
||||
"lowPriorityFiles": [],
|
||||
"rootMainFiles": [
|
||||
@ -12,16 +10,7 @@ self.__BUILD_MANIFEST = {
|
||||
"static/chunks/main-app.js"
|
||||
],
|
||||
"pages": {
|
||||
"/_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"
|
||||
]
|
||||
"/_app": []
|
||||
},
|
||||
"ampFirstPages": []
|
||||
};
|
||||
|
||||
@ -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="{}"
|
||||
@ -1,5 +1 @@
|
||||
{
|
||||
"/_app": "pages/_app.js",
|
||||
"/_error": "pages/_error.js",
|
||||
"/_document": "pages/_document.js"
|
||||
}
|
||||
{}
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"node": {},
|
||||
"edge": {},
|
||||
"encryptionKey": "qGqEEZzUFqZxeEgbiNEfbm7ophOEC3RaVTABPCm+KZ8="
|
||||
"encryptionKey": "mF8eeADKwF8ZgzkVb17uMLBKIP2Av/vT46Y1sYJ8rsk="
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@ -4,15 +4,18 @@ import { useState, useEffect, useCallback } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
listUsersAPI,
|
||||
createUserAPI,
|
||||
disableUserAPI,
|
||||
resetPasswordAPI,
|
||||
listInviteCodesAPI,
|
||||
createInviteCodeAPI,
|
||||
toggleInviteCodeAPI,
|
||||
getDataStatsAPI,
|
||||
dataResetAPI,
|
||||
getErrorLogsAPI,
|
||||
clearErrorLogsAPI,
|
||||
getSystemStatusAPI,
|
||||
type UserItem,
|
||||
type InviteCodeItem,
|
||||
type DataStats,
|
||||
type ErrorLog,
|
||||
type SystemStatus,
|
||||
@ -24,20 +27,23 @@ export default function UsersPage() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const [tab, setTab] = useState<Tab>("users");
|
||||
|
||||
// ── Users state ──
|
||||
const [users, setUsers] = useState<UserItem[]>([]);
|
||||
const [inviteCodes, setInviteCodes] = useState<InviteCodeItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [inviteLoading, setInviteLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
const [newRole, setNewRole] = useState("user");
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
const [createError, setCreateError] = useState("");
|
||||
const [createdResult, setCreatedResult] = useState<{ username: string; password: string } | null>(null);
|
||||
const [resetResult, setResetResult] = useState<{ username: string; password: string } | null>(null);
|
||||
|
||||
const [showCreateInvite, setShowCreateInvite] = useState(false);
|
||||
const [inviteCode, setInviteCode] = useState("");
|
||||
const [inviteDescription, setInviteDescription] = useState("");
|
||||
const [inviteMaxUses, setInviteMaxUses] = useState("10");
|
||||
const [createInviteLoading, setCreateInviteLoading] = useState(false);
|
||||
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);
|
||||
|
||||
// ── Data reset state ──
|
||||
const [dataStats, setDataStats] = useState<DataStats | null>(null);
|
||||
const [resetMode, setResetMode] = useState<"all" | "recommendations" | "date_range" | "low_score">("low_score");
|
||||
const [beforeDate, setBeforeDate] = useState("");
|
||||
@ -45,7 +51,6 @@ export default function UsersPage() {
|
||||
const [resetResultMsg, setResetResultMsg] = useState<string | null>(null);
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
|
||||
// ── Logs state ──
|
||||
const [logs, setLogs] = useState<ErrorLog[]>([]);
|
||||
const [logsTotal, setLogsTotal] = useState(0);
|
||||
const [logSources, setLogSources] = useState<string[]>([]);
|
||||
@ -57,8 +62,8 @@ export default function UsersPage() {
|
||||
const [expandedLogId, setExpandedLogId] = useState<number | null>(null);
|
||||
const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null);
|
||||
|
||||
function copyCredential(username: string, password: string) {
|
||||
const text = `用户名:${username}\n密码:${password}`;
|
||||
function copyCredential(account: string, password: string) {
|
||||
const text = `邮箱:${account}\n密码:${password}`;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
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 () => {
|
||||
try {
|
||||
const stats = await getDataStatsAPI();
|
||||
setDataStats(stats);
|
||||
} catch {
|
||||
// silently fail
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
@ -94,7 +110,7 @@ export default function UsersPage() {
|
||||
setLogSources(result.sources);
|
||||
setLogLevels(result.levels);
|
||||
} catch {
|
||||
// silently fail
|
||||
// ignore
|
||||
} finally {
|
||||
setLogsLoading(false);
|
||||
}
|
||||
@ -105,17 +121,18 @@ export default function UsersPage() {
|
||||
const status = await getSystemStatusAPI();
|
||||
setSystemStatus(status);
|
||||
} catch {
|
||||
// silently fail
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser?.role === "admin") {
|
||||
fetchUsers();
|
||||
fetchInviteCodes();
|
||||
fetchStats();
|
||||
fetchSystemStatus();
|
||||
}
|
||||
}, [currentUser, fetchUsers, fetchStats, fetchSystemStatus]);
|
||||
}, [currentUser, fetchUsers, fetchInviteCodes, fetchStats, fetchSystemStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
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();
|
||||
setCreateError("");
|
||||
if (!newUsername.trim()) {
|
||||
setCreateError("请输入用户名");
|
||||
setCreateInviteError("");
|
||||
|
||||
const normalizedCode = inviteCode.trim().toUpperCase();
|
||||
const maxUses = Number(inviteMaxUses);
|
||||
|
||||
if (!normalizedCode) {
|
||||
setCreateInviteError("请输入邀请码");
|
||||
return;
|
||||
}
|
||||
setCreateLoading(true);
|
||||
if (!Number.isFinite(maxUses) || maxUses < 1) {
|
||||
setCreateInviteError("邀请人数上限至少为 1");
|
||||
return;
|
||||
}
|
||||
|
||||
setCreateInviteLoading(true);
|
||||
try {
|
||||
const result = await createUserAPI(newUsername.trim(), newRole);
|
||||
setCreatedResult({ username: result.username, password: result.password });
|
||||
setNewUsername("");
|
||||
setNewRole("user");
|
||||
const result = await createInviteCodeAPI(normalizedCode, inviteDescription.trim(), maxUses);
|
||||
setCreatedInviteCode(result.code);
|
||||
setInviteCode("");
|
||||
setInviteDescription("");
|
||||
setInviteMaxUses("10");
|
||||
fetchInviteCodes();
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
setCreateError(err instanceof Error ? err.message : "创建失败");
|
||||
setCreateInviteError(err instanceof Error ? err.message : "创建失败");
|
||||
} finally {
|
||||
setCreateLoading(false);
|
||||
setCreateInviteLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
@ -164,13 +192,23 @@ export default function UsersPage() {
|
||||
async function handleResetPassword(userId: number) {
|
||||
try {
|
||||
const result = await resetPasswordAPI(userId);
|
||||
setResetResult({ username: result.username, password: result.password });
|
||||
setCopied(false);
|
||||
setResetResult({ email: result.email, password: result.password });
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
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() {
|
||||
setConfirmReset(false);
|
||||
setResetLoading(true);
|
||||
@ -205,14 +243,13 @@ export default function UsersPage() {
|
||||
}
|
||||
|
||||
const tabs: { key: Tab; label: string }[] = [
|
||||
{ key: "users", label: "用户管理" },
|
||||
{ key: "users", label: "用户与邀请码" },
|
||||
{ key: "data", label: "数据管理" },
|
||||
{ key: "logs", label: "系统日志" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-6">
|
||||
{/* Header + Tabs */}
|
||||
<div className="max-w-6xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-6">
|
||||
<div className="animate-fade-in-up">
|
||||
<h1 className="text-xl font-semibold tracking-tight">系统设置</h1>
|
||||
<div className="flex gap-1.5 mt-4 overflow-x-auto pb-1">
|
||||
@ -232,95 +269,235 @@ export default function UsersPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Tab: Users ── */}
|
||||
{tab === "users" && (
|
||||
<>
|
||||
<div className="flex items-center justify-between animate-fade-in-up">
|
||||
<button
|
||||
onClick={() => { setShowCreate(true); setCreateError(""); setCreatedResult(null); }}
|
||||
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>
|
||||
|
||||
{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 className="grid grid-cols-1 xl:grid-cols-[1.2fr_0.9fr] gap-6">
|
||||
<section className="glass-card-static p-4 rounded-2xl space-y-4 animate-fade-in-up">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-primary">用户列表</h2>
|
||||
<p className="text-xs text-text-muted mt-1">已注册用户、角色、邀请码来源与账号状态</p>
|
||||
</div>
|
||||
<span className="text-xs text-text-muted">{users.length} 个用户</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 animate-fade-in-up delay-75">
|
||||
{users.map((u) => (
|
||||
<div
|
||||
key={u.id}
|
||||
className={`glass-card p-4 rounded-xl flex items-center justify-between gap-4 ${
|
||||
!u.is_active ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center 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.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-text-primary truncate">{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>}
|
||||
|
||||
{error && <p className="text-sm text-amber-400/80">{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-24" />
|
||||
))}
|
||||
</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 className="space-y-2">
|
||||
{users.map((u) => (
|
||||
<div
|
||||
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>
|
||||
{u.created_at && <p className="text-xs text-text-muted mt-0.5">创建于 {new Date(u.created_at).toLocaleDateString("zh-CN")}</p>}
|
||||
</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>
|
||||
{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>
|
||||
))}
|
||||
{users.length === 0 && <div className="glass-card-static p-8 rounded-xl text-center"><p className="text-sm text-text-muted">暂无用户</p></div>}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Create User Dialog */}
|
||||
{showCreate && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setShowCreate(false)} />
|
||||
<div className="relative w-full max-w-sm mx-4 p-6 rounded-2xl bg-bg-card border border-border-default shadow-card">
|
||||
{createdResult ? (
|
||||
<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">{createdResult.username}</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">{createdResult.password}</span></div>
|
||||
<section className="glass-card-static p-4 rounded-2xl space-y-4 animate-fade-in-up delay-75">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-primary">邀请码管理</h2>
|
||||
<p className="text-xs text-text-muted mt-1">控制注册入口和每个邀请码的可邀请人数</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCreateInvite(true);
|
||||
setCreatedInviteCode(null);
|
||||
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>
|
||||
<p className="text-xs text-amber-400/60">请妥善保管密码,此密码仅显示一次</p>
|
||||
<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 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
|
||||
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>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-base font-semibold text-text-primary mb-5">创建新用户</h3>
|
||||
<form onSubmit={handleCreate} 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" />
|
||||
<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">
|
||||
<option value="user" className="bg-bg-card text-text-primary">普通用户</option>
|
||||
<option value="admin" className="bg-bg-card text-text-primary">管理员</option>
|
||||
</select>
|
||||
{createError && <p className="text-xs text-amber-400/80">{createError}</p>}
|
||||
<h3 className="text-base font-semibold text-text-primary mb-5">创建邀请码</h3>
|
||||
<form onSubmit={handleCreateInvite} className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="邀请码,例如 ASTOCK-VIP-01"
|
||||
value={inviteCode}
|
||||
onChange={(e) => setInviteCode(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="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">
|
||||
<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 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>
|
||||
<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>
|
||||
</form>
|
||||
</>
|
||||
@ -329,28 +506,42 @@ export default function UsersPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reset Password Result Dialog */}
|
||||
{resetResult && (
|
||||
<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="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>
|
||||
<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 items-center"><span className="text-text-muted">新密码</span><span className="text-amber-400 font-mono text-xs">{resetResult.password}</span></div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<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>
|
||||
<p className="text-xs text-amber-400/60">请妥善保管新密码,此密码仅显示一次</p>
|
||||
<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 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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* ── Tab: Data ── */}
|
||||
{tab === "data" && dataStats && (
|
||||
<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>
|
||||
@ -395,10 +586,8 @@ export default function UsersPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Tab: Logs ── */}
|
||||
{tab === "logs" && (
|
||||
<div className="space-y-4 animate-fade-in-up">
|
||||
{/* System Status */}
|
||||
{systemStatus && (
|
||||
<div className="glass-card-static p-4 rounded-xl">
|
||||
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3">系统状态</h2>
|
||||
@ -427,7 +616,6 @@ export default function UsersPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<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">
|
||||
<option value="">全部来源</option>
|
||||
@ -453,7 +641,6 @@ export default function UsersPage() {
|
||||
<span className="text-xs text-text-muted ml-auto">{logsTotal} 条记录</span>
|
||||
</div>
|
||||
|
||||
{/* Error list */}
|
||||
{logsLoading ? (
|
||||
<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" />)}
|
||||
@ -490,4 +677,4 @@ export default function UsersPage() {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,29 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { registerAPI, sendRegisterCodeAPI } from "@/lib/api";
|
||||
|
||||
type Tab = "login" | "register";
|
||||
|
||||
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 [error, setError] = useState("");
|
||||
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();
|
||||
setError("");
|
||||
|
||||
if (!username.trim() || !password.trim()) {
|
||||
setError("请输入用户名和密码");
|
||||
if (!email.trim() || !password.trim()) {
|
||||
setError("请输入邮箱和密码");
|
||||
return;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
setError("密码至少 6 位");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await login(username, password);
|
||||
await login(email.trim(), password);
|
||||
router.push("/dashboard");
|
||||
} catch (err) {
|
||||
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 (
|
||||
<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="relative z-10 w-full max-w-sm mx-4">
|
||||
{/* Brand */}
|
||||
<div className="relative z-10 w-full max-w-md mx-4">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<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
|
||||
</div>
|
||||
<div className="absolute inset-0 rounded-xl border border-amber-500/20 animate-pulse-ring" />
|
||||
</div>
|
||||
<h1 className="text-lg font-bold tracking-tight text-text-primary">
|
||||
Dragon AI Agent
|
||||
</h1>
|
||||
<h1 className="text-lg font-bold tracking-tight text-text-primary">Dragon AI Agent</h1>
|
||||
<p className="text-xs text-text-muted mt-1">A 股智能筛选引擎</p>
|
||||
</div>
|
||||
|
||||
{/* Login card */}
|
||||
<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">
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1.5 block">用户名</label>
|
||||
{tab === "login" ? (
|
||||
<form onSubmit={handleLoginSubmit} className="space-y-4">
|
||||
<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
|
||||
type="text"
|
||||
placeholder="输入用户名"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(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"
|
||||
autoComplete="username"
|
||||
placeholder="邀请码"
|
||||
value={inviteCode}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1.5 block">密码</label>
|
||||
<input
|
||||
type="email"
|
||||
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
|
||||
type="password"
|
||||
placeholder="输入密码"
|
||||
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 focus:border-amber-500/20 placeholder-text-muted/40 transition-all"
|
||||
autoComplete="current-password"
|
||||
placeholder="设置密码(至少 6 位)"
|
||||
value={registerPassword}
|
||||
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"
|
||||
autoComplete="new-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 duration-200 shadow-glow-sm hover:shadow-glow disabled:opacity-50 disabled:cursor-not-allowed active:scale-95"
|
||||
>
|
||||
{submitting ? (
|
||||
<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" />
|
||||
登录中...
|
||||
</span>
|
||||
) : "登录"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<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">
|
||||
{error}
|
||||
</p>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="确认密码"
|
||||
value={confirmPassword}
|
||||
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"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
{registerSubmitting ? "注册中..." : "完成注册"}
|
||||
</button>
|
||||
{registerError ? (
|
||||
<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>
|
||||
) : null}
|
||||
{registerSuccess ? (
|
||||
<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>
|
||||
|
||||
{/* Footer link */}
|
||||
<p className="mt-6 text-center text-xs text-text-muted/40">
|
||||
<a href="/" className="hover:text-text-muted/60 transition-colors">返回首页</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,7 +16,7 @@ export function UserMenu() {
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<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">
|
||||
{user.role}
|
||||
</span>
|
||||
|
||||
@ -6,7 +6,7 @@ import { type AuthUser, loginAPI } from "@/lib/api";
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
loading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
@ -35,8 +35,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const res = await loginAPI(username, password);
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const res = await loginAPI(email, password);
|
||||
localStorage.setItem("auth_token", res.token);
|
||||
localStorage.setItem("auth_user", JSON.stringify(res.user));
|
||||
setUser(res.user);
|
||||
|
||||
@ -552,6 +552,7 @@ export async function* streamChat(
|
||||
export interface AuthUser {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: "admin" | "user";
|
||||
}
|
||||
|
||||
@ -560,11 +561,11 @@ export interface LoginResponse {
|
||||
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`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
@ -573,25 +574,56 @@ export async function loginAPI(username: string, password: string): Promise<Logi
|
||||
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 ----------
|
||||
|
||||
export interface UserItem {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: "admin" | "user";
|
||||
is_active: boolean;
|
||||
invite_code_used?: string;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface CreateUserResult {
|
||||
username: string;
|
||||
password: string;
|
||||
role: 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 {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
message: string;
|
||||
}
|
||||
@ -600,10 +632,6 @@ export async function listUsersAPI(): Promise<UserItem[]> {
|
||||
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 }> {
|
||||
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`);
|
||||
}
|
||||
|
||||
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 }> {
|
||||
return postAPI<{ message: string }>("/api/auth/change-password", {
|
||||
old_password: oldPassword,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user