feat: 密码登录功能(验证码/密码双模式 + 忘记密码 + 设置密码)
后端: - security.py: 新增 bcrypt 密码哈希/校验工具 - auth_service.py: 新增 login_with_password、reset_password_with_sms - auth.py: 新增 /login-password、/has-password、/set-password、/reset-password 接口 - schemas/auth.py: 新增 PasswordLoginRequest、SetPasswordRequest、ResetPasswordRequest、CheckPasswordResponse - crud/user.py: 新增 update_password 前端: - Login.tsx: 支持验证码/密码切换登录,密码模式下显示忘记密码入口 - Login.css: 新增登录方式切换标签、密码输入框样式 - authStore.ts: 新增 loginWithPassword - Settings.tsx: 新增账号安全区块,显示密码状态,打开设置/修改密码弹窗 - SetPasswordModal.tsx: 设置/修改密码弹窗(旧密码校验、密码显示切换、表单验证) - ResetPasswordModal.tsx: 忘记密码弹窗(手机号+验证码+新密码重置) 兼容: - 零数据库迁移,password_hash 字段已存在(nullable) - 现有接口不变,完全向后兼容旧版本
This commit is contained in:
@@ -21,17 +21,23 @@ from app.crud import user as user_crud
|
||||
from app.db.session import AsyncSession, get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import (
|
||||
CheckPasswordResponse,
|
||||
MobileLoginRequest,
|
||||
PasswordLoginRequest,
|
||||
RefreshTokenRequest,
|
||||
ResetPasswordRequest,
|
||||
SendSmsCodeRequest,
|
||||
SetPasswordRequest,
|
||||
TokenResponse,
|
||||
)
|
||||
from app.schemas.common import ApiResponse, success_response
|
||||
from app.schemas.user import UpdateNicknameRequest, UserProfileResponse
|
||||
from app.services.auth_service import (
|
||||
login_with_password,
|
||||
login_with_sms,
|
||||
logout,
|
||||
refresh_access_token,
|
||||
reset_password_with_sms,
|
||||
send_sms_code,
|
||||
)
|
||||
|
||||
@@ -123,6 +129,128 @@ async def login(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login-password", response_model=ApiResponse[TokenResponse])
|
||||
async def login_password(
|
||||
request: PasswordLoginRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
http_request: Request = None,
|
||||
):
|
||||
"""
|
||||
手机号密码登录
|
||||
|
||||
流程与验证码登录一致,只是校验方式改为密码。
|
||||
"""
|
||||
client_ip = None
|
||||
if http_request:
|
||||
xff = http_request.headers.get("x-forwarded-for")
|
||||
if xff:
|
||||
client_ip = xff.split(",")[0].strip()
|
||||
else:
|
||||
xri = http_request.headers.get("x-real-ip")
|
||||
client_ip = xri or (http_request.client.host if http_request.client else None)
|
||||
|
||||
try:
|
||||
result = await login_with_password(
|
||||
db,
|
||||
mobile=request.mobile,
|
||||
password=request.password,
|
||||
device_id=request.device_id,
|
||||
device_name=request.device_name,
|
||||
os_info=request.os_info,
|
||||
app_version=request.app_version,
|
||||
ip=client_ip,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
return success_response(
|
||||
data=TokenResponse(
|
||||
access_token=result["access_token"],
|
||||
refresh_token=result["refresh_token"],
|
||||
user=result["user"],
|
||||
),
|
||||
message="登录成功",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/has-password", response_model=ApiResponse[CheckPasswordResponse])
|
||||
async def check_has_password(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""检查当前用户是否已设置密码"""
|
||||
return success_response(
|
||||
data=CheckPasswordResponse(has_password=bool(current_user.password_hash)),
|
||||
message="查询成功",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/set-password", response_model=ApiResponse[dict])
|
||||
async def set_password(
|
||||
request: SetPasswordRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
设置或修改密码
|
||||
|
||||
- 首次设置密码:old_password 可不传
|
||||
- 修改密码:必须传 old_password 校验
|
||||
"""
|
||||
from app.core.security import hash_password, verify_password
|
||||
|
||||
user = await user_crud.get(db, id=current_user.id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
|
||||
# 如果已有密码,必须提供旧密码
|
||||
if user.password_hash:
|
||||
if not request.old_password:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="修改密码需要提供旧密码",
|
||||
)
|
||||
if not verify_password(request.old_password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="旧密码错误",
|
||||
)
|
||||
|
||||
# 更新密码
|
||||
new_hash = hash_password(request.new_password)
|
||||
await user_crud.update_password(db, user_id=user.id, password_hash=new_hash)
|
||||
|
||||
return success_response(data={}, message="密码设置成功")
|
||||
|
||||
|
||||
@router.post("/reset-password", response_model=ApiResponse[dict])
|
||||
async def reset_password(
|
||||
request: ResetPasswordRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
短信验证码重置密码
|
||||
|
||||
无需登录,通过短信验证码验证身份后直接重置密码。
|
||||
"""
|
||||
try:
|
||||
await reset_password_with_sms(
|
||||
db,
|
||||
mobile=request.mobile,
|
||||
code=request.code,
|
||||
new_password=request.new_password,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
return success_response(data={}, message="密码重置成功")
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=ApiResponse[dict])
|
||||
async def refresh_token(
|
||||
request: RefreshTokenRequest,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""
|
||||
安全工具 - JWT Token 生成与验证
|
||||
===============================
|
||||
安全工具 - JWT Token 生成与验证 + 密码哈希
|
||||
==========================================
|
||||
|
||||
支持双 Token 体系:
|
||||
- Access Token:短效(30 分钟),用于 API 请求认证
|
||||
- Refresh Token:长效(30 天),用于换取新的 Access Token
|
||||
|
||||
密码哈希:
|
||||
- 使用 bcrypt 进行密码哈希和校验
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -15,11 +18,25 @@ from typing import Any
|
||||
|
||||
import jwt
|
||||
from jwt import PyJWTError
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# bcrypt 密码哈希上下文
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""对明文密码进行 bcrypt 哈希"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""校验明文密码与哈希密码是否匹配"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str:
|
||||
"""
|
||||
|
||||
@@ -67,6 +67,19 @@ class UserCRUD(CRUDBase[User]):
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
async def update_password(
|
||||
self, db: AsyncSession, *, user_id: str, password_hash: str
|
||||
) -> User | None:
|
||||
"""更新用户密码"""
|
||||
user = await self.get(db, id=user_id)
|
||||
if user is None:
|
||||
return None
|
||||
|
||||
user.password_hash = password_hash
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
async def update_extra(
|
||||
self, db: AsyncSession, *, user_id: str, extra: dict
|
||||
) -> bool:
|
||||
|
||||
@@ -19,12 +19,38 @@ class MobileLoginRequest(BaseModel):
|
||||
app_version: str | None = Field(None, description="应用版本号")
|
||||
|
||||
|
||||
class PasswordLoginRequest(BaseModel):
|
||||
"""手机号密码登录请求"""
|
||||
|
||||
mobile: str = Field(..., description="手机号", min_length=11, max_length=20)
|
||||
password: str = Field(..., description="密码", min_length=6, max_length=128)
|
||||
device_id: str = Field(..., description="设备唯一标识")
|
||||
device_name: str | None = Field(None, description="设备名称")
|
||||
os_info: str | None = Field(None, description="操作系统信息")
|
||||
app_version: str | None = Field(None, description="应用版本号")
|
||||
|
||||
|
||||
class SendSmsCodeRequest(BaseModel):
|
||||
"""发送短信验证码请求"""
|
||||
|
||||
mobile: str = Field(..., description="手机号", min_length=11, max_length=20)
|
||||
|
||||
|
||||
class SetPasswordRequest(BaseModel):
|
||||
"""设置/修改密码请求"""
|
||||
|
||||
old_password: str | None = Field(None, description="旧密码(修改时必填)", max_length=128)
|
||||
new_password: str = Field(..., description="新密码", min_length=6, max_length=128)
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
"""短信验证码重置密码请求"""
|
||||
|
||||
mobile: str = Field(..., description="手机号", min_length=11, max_length=20)
|
||||
code: str = Field(..., description="短信验证码", min_length=4, max_length=10)
|
||||
new_password: str = Field(..., description="新密码", min_length=6, max_length=128)
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
"""刷新 Token 请求"""
|
||||
|
||||
@@ -39,6 +65,12 @@ class TokenResponse(BaseModel):
|
||||
user: UserInfo = Field(..., description="用户信息")
|
||||
|
||||
|
||||
class CheckPasswordResponse(BaseModel):
|
||||
"""检查是否设置过密码响应"""
|
||||
|
||||
has_password: bool = Field(..., description="是否已设置密码")
|
||||
|
||||
|
||||
class TokenPayload(BaseModel):
|
||||
"""Token 载荷"""
|
||||
|
||||
|
||||
@@ -288,6 +288,115 @@ async def refresh_access_token(
|
||||
}
|
||||
|
||||
|
||||
async def login_with_password(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
mobile: str,
|
||||
password: str,
|
||||
device_id: str,
|
||||
device_name: str | None = None,
|
||||
os_info: str | None = None,
|
||||
app_version: str | None = None,
|
||||
ip: str | None = None,
|
||||
source: str = "mobile_password",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
手机号密码登录。
|
||||
|
||||
流程:
|
||||
1. 查询用户
|
||||
2. 校验密码
|
||||
3. 更新登录信息
|
||||
4. 踢掉旧设备(SSE 推送)
|
||||
5. 创建/覆盖设备记录
|
||||
6. 签发双 Token
|
||||
"""
|
||||
from app.core.security import verify_password
|
||||
|
||||
# 1. 查询用户
|
||||
user = await user_crud.get_by_mobile(db, mobile=mobile)
|
||||
if user is None:
|
||||
raise ValueError("用户不存在")
|
||||
|
||||
# 2. 校验密码
|
||||
if not user.password_hash:
|
||||
raise ValueError("该账号未设置密码,请使用验证码登录")
|
||||
|
||||
if not verify_password(password, user.password_hash):
|
||||
raise ValueError("密码错误")
|
||||
|
||||
# 检查用户状态
|
||||
if not user.is_active:
|
||||
raise ValueError("账号已被封禁,请联系客服")
|
||||
|
||||
# 3. 更新登录信息
|
||||
await user_crud.update_login_info(db, user_id=user.id, ip=ip)
|
||||
|
||||
# 4. 踢掉旧设备(SSE 推送)
|
||||
await _kick_old_device(str(user.id))
|
||||
|
||||
# 5. 签发双 Token
|
||||
access_token = create_access_token(data={"sub": str(user.id)})
|
||||
refresh_token = create_refresh_token(data={"sub": str(user.id)})
|
||||
refresh_token_hash = _hash_refresh_token(refresh_token)
|
||||
|
||||
# 6. 创建/覆盖设备记录
|
||||
await device_crud.create_or_update(
|
||||
db,
|
||||
user_id=user.id,
|
||||
device_id=device_id,
|
||||
device_name=device_name,
|
||||
os_info=os_info,
|
||||
app_version=app_version,
|
||||
refresh_token_hash=refresh_token_hash,
|
||||
)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"mobile": user.mobile,
|
||||
"nickname": user.nickname or "",
|
||||
"avatar": user.avatar_url or "",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def reset_password_with_sms(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
mobile: str,
|
||||
code: str,
|
||||
new_password: str,
|
||||
) -> bool:
|
||||
"""
|
||||
短信验证码重置密码。
|
||||
|
||||
流程:
|
||||
1. 校验验证码
|
||||
2. 查询用户
|
||||
3. 更新密码哈希
|
||||
"""
|
||||
from app.core.security import hash_password
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# 1. 校验验证码(白名单内的手机号跳过校验)
|
||||
if mobile not in settings.sms_code_whitelist_set and not await verify_sms_code(mobile, code):
|
||||
raise ValueError("验证码错误或已过期")
|
||||
|
||||
# 2. 查询用户
|
||||
user = await user_crud.get_by_mobile(db, mobile=mobile)
|
||||
if user is None:
|
||||
raise ValueError("用户不存在")
|
||||
|
||||
# 3. 更新密码
|
||||
new_hash = hash_password(new_password)
|
||||
await user_crud.update_password(db, user_id=user.id, password_hash=new_hash)
|
||||
return True
|
||||
|
||||
|
||||
async def logout(db: AsyncSession, *, user_id: str) -> bool:
|
||||
"""
|
||||
用户登出。
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* 忘记密码弹窗 - 短信验证码重置密码
|
||||
* =================================
|
||||
*
|
||||
* 流程:手机号 → 验证码 → 新密码 → 确认密码
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import './ConfirmModal.css';
|
||||
import { client } from '../../api/client';
|
||||
import { toast } from '../../store/uiStore';
|
||||
|
||||
interface ResetPasswordModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ResetPasswordModal({ open, onClose }: ResetPasswordModalProps) {
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
// 倒计时
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) { return; }
|
||||
const timer = setTimeout(() => setCountdown(c => c - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [countdown]);
|
||||
|
||||
const isPhoneValid = /^1[3-9]\d{9}$/.test(phone);
|
||||
const isCodeValid = /^\d{4,6}$/.test(code);
|
||||
const pwdMatch = newPassword === confirmPassword;
|
||||
const canSubmit = isPhoneValid && isCodeValid && newPassword.length >= 6 && pwdMatch && !submitting;
|
||||
|
||||
// 发送验证码
|
||||
const handleSendCode = useCallback(async () => {
|
||||
if (!isPhoneValid || countdown > 0 || sending) { return; }
|
||||
setSending(true);
|
||||
try {
|
||||
await client.post('/auth/send-code', { mobile: phone });
|
||||
setCountdown(60);
|
||||
toast.success('验证码已发送');
|
||||
} catch (error: unknown) {
|
||||
toast.error(error instanceof Error ? error.message : '发送失败,请重试');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [isPhoneValid, countdown, sending, phone]);
|
||||
|
||||
// 提交重置
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit) { return; }
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await client.post('/auth/reset-password', {
|
||||
mobile: phone,
|
||||
code,
|
||||
new_password: newPassword,
|
||||
});
|
||||
toast.success('密码重置成功,请使用新密码登录');
|
||||
handleClose();
|
||||
} catch (error: unknown) {
|
||||
toast.error(error instanceof Error ? error.message : '重置失败,请重试');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setPhone('');
|
||||
setCode('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setCountdown(0);
|
||||
setShowNew(false);
|
||||
setShowConfirm(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!open) { return null; }
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
height: '44px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border-light)',
|
||||
padding: '0 12px',
|
||||
fontSize: '14px',
|
||||
background: 'var(--bg-input)',
|
||||
color: 'var(--text-primary)',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
transition: 'border-color 0.15s ease',
|
||||
};
|
||||
|
||||
const fieldStyle: React.CSSProperties = { marginBottom: '16px' };
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: 'block',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
color: 'var(--text-secondary)',
|
||||
marginBottom: '6px',
|
||||
};
|
||||
const errorStyle: React.CSSProperties = {
|
||||
fontSize: '12px',
|
||||
color: '#e74c3c',
|
||||
marginTop: '4px',
|
||||
minHeight: '18px',
|
||||
};
|
||||
|
||||
const pwdMismatch = confirmPassword.length > 0 && !pwdMatch;
|
||||
const pwdTooShort = newPassword.length > 0 && newPassword.length < 6;
|
||||
|
||||
return (
|
||||
<div className="confirm-modal-overlay" onClick={handleClose}>
|
||||
<div
|
||||
className="confirm-modal-container"
|
||||
style={{ width: '100%', maxWidth: '380px', padding: '24px' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* 关闭按钮 */}
|
||||
<button className="confirm-modal-close" onClick={handleClose}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* 标题 */}
|
||||
<div style={{ textAlign: 'center', marginBottom: '20px' }}>
|
||||
<div
|
||||
className="confirm-modal-icon"
|
||||
style={{ color: 'var(--text-tertiary)', margin: '0 auto 12px' }}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="confirm-modal-title" style={{ marginBottom: '4px' }}>重置密码</h3>
|
||||
<p style={{ fontSize: '12px', color: 'var(--text-tertiary)', margin: 0 }}>
|
||||
通过短信验证码重置登录密码
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 表单 */}
|
||||
<div>
|
||||
{/* 手机号 */}
|
||||
<div style={fieldStyle}>
|
||||
<label style={labelStyle}>手机号</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', border: '1px solid var(--border-light)', borderRadius: '8px', overflow: 'hidden', background: 'var(--bg-input)' }}>
|
||||
<span style={{ padding: '0 12px', fontSize: '14px', fontWeight: 600, color: 'var(--text-primary)', borderRight: '1px solid var(--border-light)', height: '44px', display: 'flex', alignItems: 'center', flexShrink: 0 }}>+86</span>
|
||||
<input
|
||||
type="tel"
|
||||
placeholder="请输入手机号"
|
||||
value={phone}
|
||||
onChange={e => { const v = e.target.value.replace(/\D/g, '').slice(0, 11); setPhone(v); }}
|
||||
maxLength={11}
|
||||
style={{ border: 'none', background: 'transparent', flex: 1, height: '44px', padding: '0 12px', fontSize: '14px', fontWeight: 600, letterSpacing: '2px', outline: 'none', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 验证码 */}
|
||||
<div style={fieldStyle}>
|
||||
<label style={labelStyle}>验证码</label>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="请输入验证码"
|
||||
value={code}
|
||||
onChange={e => { const v = e.target.value.replace(/\D/g, '').slice(0, 6); setCode(v); }}
|
||||
maxLength={6}
|
||||
style={{ ...inputStyle, flex: 1, letterSpacing: '4px' }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSendCode}
|
||||
disabled={!isPhoneValid || countdown > 0 || sending}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
height: '44px',
|
||||
padding: '0 16px',
|
||||
border: '1px solid var(--primary)',
|
||||
borderRadius: '8px',
|
||||
background: 'transparent',
|
||||
color: (!isPhoneValid || countdown > 0 || sending) ? 'var(--text-tertiary)' : 'var(--primary)',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
fontFamily: 'var(--font-family)',
|
||||
cursor: (!isPhoneValid || countdown > 0 || sending) ? 'default' : 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{sending ? '发送中...' : countdown > 0 ? `${countdown}s 后重发` : '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 新密码 */}
|
||||
<div style={fieldStyle}>
|
||||
<label style={labelStyle}>新密码</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
type={showNew ? 'text' : 'password'}
|
||||
placeholder="至少6位字符"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowNew(!showNew)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '10px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--text-tertiary)',
|
||||
padding: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{showNew ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{pwdTooShort && <div style={errorStyle}>密码至少需要6位</div>}
|
||||
</div>
|
||||
|
||||
{/* 确认密码 */}
|
||||
<div style={fieldStyle}>
|
||||
<label style={labelStyle}>确认密码</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
type={showConfirm ? 'text' : 'password'}
|
||||
placeholder="再次输入新密码"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowConfirm(!showConfirm)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '10px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--text-tertiary)',
|
||||
padding: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{showConfirm ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{pwdMismatch && <div style={errorStyle}>两次输入的密码不一致</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 按钮 */}
|
||||
<div className="confirm-modal-actions" style={{ marginTop: '4px' }}>
|
||||
<button className="confirm-modal-btn cancel" onClick={handleClose}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="confirm-modal-btn primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{submitting ? (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ animation: 'spin 1s linear infinite' }}>
|
||||
<path d="M21 12a9 9 0 11-6.219-8.56" />
|
||||
</svg>
|
||||
重置中...
|
||||
</span>
|
||||
) : (
|
||||
'重置密码'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* 设置/修改密码弹窗
|
||||
* ================
|
||||
*
|
||||
* 首次设置密码:只需输入新密码 + 确认密码
|
||||
* 修改密码:需要输入旧密码 + 新密码 + 确认密码
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import './ConfirmModal.css';
|
||||
import { client } from '../../api/client';
|
||||
import { toast } from '../../store/uiStore';
|
||||
|
||||
interface SetPasswordModalProps {
|
||||
open: boolean;
|
||||
hasPassword: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export default function SetPasswordModal({
|
||||
open,
|
||||
hasPassword,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: SetPasswordModalProps) {
|
||||
const [oldPassword, setOldPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [showOld, setShowOld] = useState(false);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
if (!open) {return null;}
|
||||
|
||||
const canSubmit = () => {
|
||||
if (hasPassword && oldPassword.length < 6) {return false;}
|
||||
if (newPassword.length < 6) {return false;}
|
||||
if (newPassword !== confirmPassword) {return false;}
|
||||
if (submitting) {return false;}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit()) {return;}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const payload: { old_password?: string; new_password: string } = {
|
||||
new_password: newPassword,
|
||||
};
|
||||
if (hasPassword && oldPassword) {
|
||||
payload.old_password = oldPassword;
|
||||
}
|
||||
await client.post('/auth/set-password', payload);
|
||||
toast.success(hasPassword ? '密码修改成功' : '密码设置成功');
|
||||
// 清空表单
|
||||
setOldPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
onSuccess();
|
||||
} catch (error: unknown) {
|
||||
toast.error(error instanceof Error ? error.message : '操作失败,请重试');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOldPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setShowOld(false);
|
||||
setShowNew(false);
|
||||
setShowConfirm(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
height: '44px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border-light)',
|
||||
padding: '0 12px',
|
||||
fontSize: '14px',
|
||||
background: 'var(--bg-input)',
|
||||
color: 'var(--text-primary)',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
transition: 'border-color 0.15s ease',
|
||||
};
|
||||
|
||||
const fieldStyle: React.CSSProperties = {
|
||||
marginBottom: '16px',
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: 'block',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
color: 'var(--text-secondary)',
|
||||
marginBottom: '6px',
|
||||
};
|
||||
|
||||
const errorStyle: React.CSSProperties = {
|
||||
fontSize: '12px',
|
||||
color: '#e74c3c',
|
||||
marginTop: '4px',
|
||||
minHeight: '18px',
|
||||
};
|
||||
|
||||
const pwdMismatch = confirmPassword.length > 0 && newPassword !== confirmPassword;
|
||||
const pwdTooShort = newPassword.length > 0 && newPassword.length < 6;
|
||||
|
||||
return (
|
||||
<div className="confirm-modal-overlay" onClick={handleClose}>
|
||||
<div
|
||||
className="confirm-modal-container"
|
||||
style={{ width: '100%', maxWidth: '380px', padding: '24px' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* 关闭按钮 */}
|
||||
<button className="confirm-modal-close" onClick={handleClose}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* 标题 */}
|
||||
<div style={{ textAlign: 'center', marginBottom: '20px' }}>
|
||||
<div
|
||||
className="confirm-modal-icon"
|
||||
style={{ color: 'var(--text-tertiary)', margin: '0 auto 12px' }}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="confirm-modal-title" style={{ marginBottom: '4px' }}>
|
||||
{hasPassword ? '修改密码' : '设置登录密码'}
|
||||
</h3>
|
||||
<p style={{ fontSize: '12px', color: 'var(--text-tertiary)', margin: 0 }}>
|
||||
{hasPassword ? '请输入旧密码和新密码' : '设置密码后可用密码登录'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 表单 */}
|
||||
<div>
|
||||
{hasPassword && (
|
||||
<div style={fieldStyle}>
|
||||
<label style={labelStyle}>旧密码</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
type={showOld ? 'text' : 'password'}
|
||||
placeholder="请输入旧密码"
|
||||
value={oldPassword}
|
||||
onChange={e => setOldPassword(e.target.value)}
|
||||
style={inputStyle}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleSubmit(); }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowOld(!showOld)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '10px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--text-tertiary)',
|
||||
padding: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{showOld ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={fieldStyle}>
|
||||
<label style={labelStyle}>新密码</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
type={showNew ? 'text' : 'password'}
|
||||
placeholder="至少6位字符"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
style={inputStyle}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleSubmit(); }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowNew(!showNew)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '10px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--text-tertiary)',
|
||||
padding: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{showNew ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{pwdTooShort && <div style={errorStyle}>密码至少需要6位</div>}
|
||||
</div>
|
||||
|
||||
<div style={fieldStyle}>
|
||||
<label style={labelStyle}>确认密码</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
type={showConfirm ? 'text' : 'password'}
|
||||
placeholder="再次输入新密码"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
style={inputStyle}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleSubmit(); }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowConfirm(!showConfirm)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '10px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--text-tertiary)',
|
||||
padding: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{showConfirm ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{pwdMismatch && <div style={errorStyle}>两次输入的密码不一致</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 按钮 */}
|
||||
<div className="confirm-modal-actions" style={{ marginTop: '4px' }}>
|
||||
<button className="confirm-modal-btn cancel" onClick={handleClose}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="confirm-modal-btn primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit()}
|
||||
>
|
||||
{submitting ? (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ animation: 'spin 1s linear infinite' }}>
|
||||
<path d="M21 12a9 9 0 11-6.219-8.56" />
|
||||
</svg>
|
||||
保存中...
|
||||
</span>
|
||||
) : (
|
||||
'保存'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -92,7 +92,41 @@
|
||||
.login-card-desc {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--font-sm);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* 登录方式切换 */
|
||||
.login-type-switch {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-input);
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.login-type-switch button {
|
||||
flex: 1;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 500;
|
||||
font-family: var(--font-family);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.login-type-switch button.active {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 8%);
|
||||
}
|
||||
|
||||
.login-type-switch button:hover:not(.active) {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 表单 */
|
||||
@@ -192,6 +226,30 @@
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
/* 密码输入框 */
|
||||
.form-field input[type='password'] {
|
||||
height: 48px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-light);
|
||||
padding: 0 var(--spacing-md);
|
||||
font-size: var(--font-base);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
transition: border-color var(--transition-fast);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-field input[type='password']:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgb(54 178 106 / 10%);
|
||||
}
|
||||
|
||||
.form-field input[type='password']::placeholder {
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.send-code-btn {
|
||||
flex-shrink: 0;
|
||||
height: 48px;
|
||||
|
||||
@@ -3,25 +3,28 @@ import { useAuthStore } from '../../store';
|
||||
import { toast } from '../../store/uiStore';
|
||||
import { client } from '../../api/client';
|
||||
import TermsModal from '../../components/Modal/TermsModal';
|
||||
import ResetPasswordModal from '../../components/Modal/ResetPasswordModal';
|
||||
import './Login.css';
|
||||
|
||||
/**
|
||||
* 登录页面
|
||||
*
|
||||
* 改进:
|
||||
* - 使用全局 authStore 替代局部的 onLoginSuccess 回调
|
||||
* - 使用新的 uiStore toast 方法
|
||||
* 支持手机号验证码登录和密码登录两种方式
|
||||
*/
|
||||
export default function Login() {
|
||||
const { login, isLoading } = useAuthStore();
|
||||
const { login, loginWithPassword, isLoading } = useAuthStore();
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [logging, setLogging] = useState(false);
|
||||
const [termsOpen, setTermsOpen] = useState(false);
|
||||
const [termsTab, setTermsTab] = useState<'terms' | 'privacy'>('terms');
|
||||
const [resetModalOpen, setResetModalOpen] = useState(false);
|
||||
// 登录方式:'code' = 验证码, 'password' = 密码
|
||||
const [loginType, setLoginType] = useState<'code' | 'password'>('code');
|
||||
|
||||
// 倒计时逻辑(必须放在所有条件返回之前,遵循 Hooks 规则)
|
||||
useEffect(() => {
|
||||
@@ -35,7 +38,10 @@ export default function Login() {
|
||||
// 手机号格式验证
|
||||
const isPhoneValid = /^1[3-9]\d{9}$/.test(phone);
|
||||
const isCodeValid = /^\d{4,6}$/.test(code);
|
||||
const canLogin = isPhoneValid && isCodeValid && agreed && !logging;
|
||||
const isPasswordValid = password.length >= 6;
|
||||
const canLogin = loginType === 'code'
|
||||
? isPhoneValid && isCodeValid && agreed && !logging
|
||||
: isPhoneValid && isPasswordValid && agreed && !logging;
|
||||
|
||||
// 发送验证码
|
||||
const handleSendCode = useCallback(async () => {
|
||||
@@ -62,9 +68,11 @@ export default function Login() {
|
||||
|
||||
setLogging(true);
|
||||
try {
|
||||
await login(phone, code);
|
||||
// 登录成功后 authStore 会自动更新全局状态
|
||||
// App.tsx 会检测到 isAuthenticated 变化并切换界面
|
||||
if (loginType === 'code') {
|
||||
await login(phone, code);
|
||||
} else {
|
||||
await loginWithPassword(phone, password);
|
||||
}
|
||||
toast.success('登录成功');
|
||||
} catch (error: unknown) {
|
||||
toast.error(error instanceof Error ? error.message : '登录失败,请重试');
|
||||
@@ -99,7 +107,25 @@ export default function Login() {
|
||||
{/* 登录卡片 */}
|
||||
<div className="login-card">
|
||||
<h2>欢迎登录</h2>
|
||||
<p className="login-card-desc">使用手机号验证码快速登录</p>
|
||||
<p className="login-card-desc">
|
||||
{loginType === 'code' ? '使用手机号验证码快速登录' : '使用手机号密码登录'}
|
||||
</p>
|
||||
|
||||
{/* 登录方式切换 */}
|
||||
<div className="login-type-switch">
|
||||
<button
|
||||
className={loginType === 'code' ? 'active' : ''}
|
||||
onClick={() => setLoginType('code')}
|
||||
>
|
||||
验证码登录
|
||||
</button>
|
||||
<button
|
||||
className={loginType === 'password' ? 'active' : ''}
|
||||
onClick={() => setLoginType('password')}
|
||||
>
|
||||
密码登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 加载状态 */}
|
||||
{isLoading ? (
|
||||
@@ -119,91 +145,117 @@ export default function Login() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="login-form" onKeyDown={handleKeyDown}>
|
||||
{/* 手机号 */}
|
||||
<div className="form-field">
|
||||
<label className="form-label">手机号</label>
|
||||
<div className="phone-input-group">
|
||||
<span className="phone-prefix">+86</span>
|
||||
<input
|
||||
type="tel"
|
||||
placeholder="请输入手机号"
|
||||
value={phone}
|
||||
onChange={e => {
|
||||
const v = e.target.value.replace(/\D/g, '').slice(0, 11);
|
||||
setPhone(v);
|
||||
}}
|
||||
maxLength={11}
|
||||
autoFocus
|
||||
/>
|
||||
{/* 手机号 */}
|
||||
<div className="form-field">
|
||||
<label className="form-label">手机号</label>
|
||||
<div className="phone-input-group">
|
||||
<span className="phone-prefix">+86</span>
|
||||
<input
|
||||
type="tel"
|
||||
placeholder="请输入手机号"
|
||||
value={phone}
|
||||
onChange={e => {
|
||||
const v = e.target.value.replace(/\D/g, '').slice(0, 11);
|
||||
setPhone(v);
|
||||
}}
|
||||
maxLength={11}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 验证码 */}
|
||||
<div className="form-field">
|
||||
<label className="form-label">验证码</label>
|
||||
<div className="code-input-group">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="请输入验证码"
|
||||
value={code}
|
||||
onChange={e => {
|
||||
const v = e.target.value.replace(/\D/g, '').slice(0, 6);
|
||||
setCode(v);
|
||||
}}
|
||||
maxLength={6}
|
||||
/>
|
||||
<button
|
||||
className="send-code-btn"
|
||||
onClick={handleSendCode}
|
||||
disabled={!isPhoneValid || countdown > 0 || sending}
|
||||
>
|
||||
{sending ? '发送中...' : countdown > 0 ? `${countdown}s 后重发` : '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 登录按钮 */}
|
||||
<button className="login-btn" onClick={handleLogin} disabled={!canLogin}>
|
||||
{logging ? (
|
||||
<span className="login-btn-loading">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
style={{ animation: 'spin 1s linear infinite' }}
|
||||
>
|
||||
<path d="M21 12a9 9 0 11-6.219-8.56" />
|
||||
</svg>
|
||||
登录中...
|
||||
</span>
|
||||
{loginType === 'code' ? (
|
||||
/* 验证码 */
|
||||
<div className="form-field">
|
||||
<label className="form-label">验证码</label>
|
||||
<div className="code-input-group">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="请输入验证码"
|
||||
value={code}
|
||||
onChange={e => {
|
||||
const v = e.target.value.replace(/\D/g, '').slice(0, 6);
|
||||
setCode(v);
|
||||
}}
|
||||
maxLength={6}
|
||||
/>
|
||||
<button
|
||||
className="send-code-btn"
|
||||
onClick={handleSendCode}
|
||||
disabled={!isPhoneValid || countdown > 0 || sending}
|
||||
>
|
||||
{sending ? '发送中...' : countdown > 0 ? `${countdown}s 后重发` : '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
'登录'
|
||||
/* 密码 */
|
||||
<div className="form-field">
|
||||
<label className="form-label">密码</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 协议 */}
|
||||
<div className="login-agreement">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="agree-checkbox"
|
||||
checked={agreed}
|
||||
onChange={e => setAgreed(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="agree-checkbox">
|
||||
我已阅读并同意
|
||||
<a href="#" onClick={e => { e.preventDefault(); setTermsTab('terms'); setTermsOpen(true); }}>
|
||||
《用户服务协议》
|
||||
</a>
|
||||
和
|
||||
<a href="#" onClick={e => { e.preventDefault(); setTermsTab('privacy'); setTermsOpen(true); }}>
|
||||
《隐私政策》
|
||||
</a>
|
||||
</label>
|
||||
{/* 登录按钮 */}
|
||||
<button className="login-btn" onClick={handleLogin} disabled={!canLogin}>
|
||||
{logging ? (
|
||||
<span className="login-btn-loading">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
style={{ animation: 'spin 1s linear infinite' }}
|
||||
>
|
||||
<path d="M21 12a9 9 0 11-6.219-8.56" />
|
||||
</svg>
|
||||
登录中...
|
||||
</span>
|
||||
) : (
|
||||
'登录'
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 忘记密码(仅在密码登录模式显示) */}
|
||||
{loginType === 'password' && (
|
||||
<div style={{ textAlign: 'right', marginTop: '-8px' }}>
|
||||
<a
|
||||
href="#"
|
||||
onClick={e => { e.preventDefault(); setResetModalOpen(true); }}
|
||||
style={{ fontSize: 'var(--font-sm)', color: 'var(--primary)', textDecoration: 'none' }}
|
||||
>
|
||||
忘记密码?
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 协议 */}
|
||||
<div className="login-agreement">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="agree-checkbox"
|
||||
checked={agreed}
|
||||
onChange={e => setAgreed(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="agree-checkbox">
|
||||
我已阅读并同意
|
||||
<a href="#" onClick={e => { e.preventDefault(); setTermsTab('terms'); setTermsOpen(true); }}>
|
||||
《用户服务协议》
|
||||
</a>
|
||||
和
|
||||
<a href="#" onClick={e => { e.preventDefault(); setTermsTab('privacy'); setTermsOpen(true); }}>
|
||||
《隐私政策》
|
||||
</a>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -215,6 +267,11 @@ export default function Login() {
|
||||
defaultTab={termsTab}
|
||||
onClose={() => setTermsOpen(false)}
|
||||
/>
|
||||
|
||||
<ResetPasswordModal
|
||||
open={resetModalOpen}
|
||||
onClose={() => setResetModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useNavigation } from '../../contexts/NavigationContext';
|
||||
import AppHeader from '../../components/Layout/AppHeader';
|
||||
import EnvironmentSwitchModal from '../../components/Modal/EnvironmentSwitchModal';
|
||||
import SetPasswordModal from '../../components/Modal/SetPasswordModal';
|
||||
import { check, type Update, type DownloadEvent } from '@tauri-apps/plugin-updater';
|
||||
import { relaunch } from '@tauri-apps/plugin-process';
|
||||
import { saveAppConfig } from '../../api/modules/config';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { toast } from '../../store/uiStore';
|
||||
import { client } from '../../api/client';
|
||||
import '../ContentManagement/ContentManagement.css';
|
||||
|
||||
const CURRENT_VERSION = __APP_VERSION__;
|
||||
@@ -28,6 +30,11 @@ export default function Settings() {
|
||||
// ── 环境切换 ──
|
||||
const [showEnvModal, setShowEnvModal] = useState(false);
|
||||
|
||||
// ── 密码管理 ──
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
const [hasPassword, setHasPassword] = useState(false);
|
||||
const [checkingPassword, setCheckingPassword] = useState(false);
|
||||
|
||||
// ── 缓存清理 ──
|
||||
const [cacheSize, setCacheSize] = useState(0);
|
||||
const [clearingCache, setClearingCache] = useState(false);
|
||||
@@ -145,14 +152,28 @@ export default function Settings() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 检查是否已设置密码
|
||||
const checkHasPassword = useCallback(async () => {
|
||||
setCheckingPassword(true);
|
||||
try {
|
||||
const res = await client.get<{ has_password: boolean }>('/auth/has-password');
|
||||
setHasPassword(res.has_password);
|
||||
} catch {
|
||||
setHasPassword(false);
|
||||
} finally {
|
||||
setCheckingPassword(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCacheSize();
|
||||
checkHasPassword();
|
||||
return () => {
|
||||
if (clickTimerRef.current) {
|
||||
clearTimeout(clickTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [fetchCacheSize]);
|
||||
}, [fetchCacheSize, checkHasPassword]);
|
||||
|
||||
// 清理媒体缓存(视频缓存 + BGM 缓存)
|
||||
const handleClearCache = async () => {
|
||||
@@ -280,6 +301,27 @@ export default function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 账号安全 */}
|
||||
<div className="settings-section">
|
||||
<h2>账号安全</h2>
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<div className="settings-row">
|
||||
<span className="settings-row-label">登录密码</span>
|
||||
<div className="settings-row-value" style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-md)', justifyContent: 'flex-end' }}>
|
||||
<span style={{ fontSize: 'var(--font-sm)', color: 'var(--text-tertiary)' }}>
|
||||
{checkingPassword ? '检查中...' : hasPassword ? '已设置' : '未设置'}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-sm about-check-btn"
|
||||
onClick={() => setShowPasswordModal(true)}
|
||||
>
|
||||
{hasPassword ? '修改密码' : '设置密码'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 缓存清理 */}
|
||||
<div className="settings-section">
|
||||
<h2>缓存管理</h2>
|
||||
@@ -334,6 +376,16 @@ export default function Settings() {
|
||||
onSave={handleSaveEnv}
|
||||
onCancel={() => setShowEnvModal(false)}
|
||||
/>
|
||||
|
||||
<SetPasswordModal
|
||||
open={showPasswordModal}
|
||||
hasPassword={hasPassword}
|
||||
onClose={() => setShowPasswordModal(false)}
|
||||
onSuccess={() => {
|
||||
setHasPassword(true);
|
||||
setShowPasswordModal(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ interface AuthState {
|
||||
|
||||
interface AuthActions {
|
||||
login: (_phone: string, _code: string) => Promise<void>;
|
||||
loginWithPassword: (_phone: string, _password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
checkAuth: () => boolean;
|
||||
loadFromStorage: () => Promise<void>;
|
||||
@@ -282,6 +283,44 @@ export const useAuthStore = create<AuthStore>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
// 手机号密码登录
|
||||
loginWithPassword: async (phone: string, password: string) => {
|
||||
if (isLoggingIn) {return;}
|
||||
isLoggingIn = true;
|
||||
|
||||
try {
|
||||
const data = await client.post<{ accessToken: string; refreshToken: string; user: UserInfo }>('/auth/login-password', {
|
||||
mobile: phone,
|
||||
password,
|
||||
deviceId: generateDeviceId(),
|
||||
deviceName: '美家卡智影桌面端',
|
||||
osInfo: navigator.userAgent,
|
||||
appVersion: await getAppVersion(),
|
||||
});
|
||||
|
||||
const newState = {
|
||||
isAuthenticated: true,
|
||||
accessToken: data.accessToken,
|
||||
refreshToken: data.refreshToken,
|
||||
user: data.user,
|
||||
isLoading: false,
|
||||
showKickModal: false,
|
||||
kickMessage: '',
|
||||
};
|
||||
|
||||
set(newState);
|
||||
await saveAuthState(newState);
|
||||
clearAuthCache();
|
||||
connectSSE(data.accessToken);
|
||||
} catch (error) {
|
||||
console.error('[authStore] 密码登录失败:', error);
|
||||
isLoggingIn = false;
|
||||
throw error;
|
||||
} finally {
|
||||
isLoggingIn = false;
|
||||
}
|
||||
},
|
||||
|
||||
closeKickModal: () => {
|
||||
clearAuthState();
|
||||
set({ ...initialState, isLoading: false, showKickModal: false, kickMessage: '' });
|
||||
|
||||
Reference in New Issue
Block a user