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:
小鱼开发
2026-06-09 23:26:50 +08:00
parent c2209dec85
commit 923ff63a3d
11 changed files with 1222 additions and 91 deletions
+128
View File
@@ -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,
+19 -2
View File
@@ -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:
"""
+13
View File
@@ -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:
+32
View File
@@ -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 载荷"""
+109
View File
@@ -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>
);
}
+58
View File
@@ -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;
+145 -88
View File
@@ -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>
);
}
+53 -1
View File
@@ -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>
);
}
+39
View File
@@ -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: '' });