认证系统更新
This commit is contained in:
parent
9254b4aede
commit
a05ccfd1b4
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@ -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,
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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
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 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
|
||||||
|
|||||||
@ -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()),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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.
@ -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"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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": []
|
||||||
}
|
}
|
||||||
@ -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)/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"
|
||||||
}
|
}
|
||||||
@ -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": []
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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": {},
|
"node": {},
|
||||||
"edge": {},
|
"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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user