diff --git a/python-api/app/api/v1/auth.py b/python-api/app/api/v1/auth.py index 82abb99..fcecc22 100644 --- a/python-api/app/api/v1/auth.py +++ b/python-api/app/api/v1/auth.py @@ -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, diff --git a/python-api/app/core/security.py b/python-api/app/core/security.py index fe0aed2..9d5a562 100644 --- a/python-api/app/core/security.py +++ b/python-api/app/core/security.py @@ -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: """ diff --git a/python-api/app/crud/user.py b/python-api/app/crud/user.py index 07374fc..a3bf240 100644 --- a/python-api/app/crud/user.py +++ b/python-api/app/crud/user.py @@ -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: diff --git a/python-api/app/schemas/auth.py b/python-api/app/schemas/auth.py index a89cc26..a8c89a2 100644 --- a/python-api/app/schemas/auth.py +++ b/python-api/app/schemas/auth.py @@ -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 载荷""" diff --git a/python-api/app/services/auth_service.py b/python-api/app/services/auth_service.py index dc7805e..5607515 100644 --- a/python-api/app/services/auth_service.py +++ b/python-api/app/services/auth_service.py @@ -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: """ 用户登出。 diff --git a/tauri-app/src/components/Modal/ResetPasswordModal.tsx b/tauri-app/src/components/Modal/ResetPasswordModal.tsx new file mode 100644 index 0000000..0330a7b --- /dev/null +++ b/tauri-app/src/components/Modal/ResetPasswordModal.tsx @@ -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 ( +
+
e.stopPropagation()} + > + {/* 关闭按钮 */} + + + {/* 标题 */} +
+
+ + + + +
+

重置密码

+

+ 通过短信验证码重置登录密码 +

+
+ + {/* 表单 */} +
+ {/* 手机号 */} +
+ +
+ +86 + { 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)' }} + /> +
+
+ + {/* 验证码 */} +
+ +
+ { const v = e.target.value.replace(/\D/g, '').slice(0, 6); setCode(v); }} + maxLength={6} + style={{ ...inputStyle, flex: 1, letterSpacing: '4px' }} + /> + +
+
+ + {/* 新密码 */} +
+ +
+ setNewPassword(e.target.value)} + style={inputStyle} + /> + +
+ {pwdTooShort &&
密码至少需要6位
} +
+ + {/* 确认密码 */} +
+ +
+ setConfirmPassword(e.target.value)} + style={inputStyle} + /> + +
+ {pwdMismatch &&
两次输入的密码不一致
} +
+
+ + {/* 按钮 */} +
+ + +
+
+
+ ); +} diff --git a/tauri-app/src/components/Modal/SetPasswordModal.tsx b/tauri-app/src/components/Modal/SetPasswordModal.tsx new file mode 100644 index 0000000..3d4acb9 --- /dev/null +++ b/tauri-app/src/components/Modal/SetPasswordModal.tsx @@ -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 ( +
+
e.stopPropagation()} + > + {/* 关闭按钮 */} + + + {/* 标题 */} +
+
+ + + + +
+

+ {hasPassword ? '修改密码' : '设置登录密码'} +

+

+ {hasPassword ? '请输入旧密码和新密码' : '设置密码后可用密码登录'} +

+
+ + {/* 表单 */} +
+ {hasPassword && ( +
+ +
+ setOldPassword(e.target.value)} + style={inputStyle} + onKeyDown={e => { if (e.key === 'Enter') handleSubmit(); }} + /> + +
+
+ )} + +
+ +
+ setNewPassword(e.target.value)} + style={inputStyle} + onKeyDown={e => { if (e.key === 'Enter') handleSubmit(); }} + /> + +
+ {pwdTooShort &&
密码至少需要6位
} +
+ +
+ +
+ setConfirmPassword(e.target.value)} + style={inputStyle} + onKeyDown={e => { if (e.key === 'Enter') handleSubmit(); }} + /> + +
+ {pwdMismatch &&
两次输入的密码不一致
} +
+
+ + {/* 按钮 */} +
+ + +
+
+
+ ); +} diff --git a/tauri-app/src/pages/Login/Login.css b/tauri-app/src/pages/Login/Login.css index 3a6c4ee..7e8d520 100644 --- a/tauri-app/src/pages/Login/Login.css +++ b/tauri-app/src/pages/Login/Login.css @@ -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; diff --git a/tauri-app/src/pages/Login/Login.tsx b/tauri-app/src/pages/Login/Login.tsx index 16d3b9d..c31b860 100644 --- a/tauri-app/src/pages/Login/Login.tsx +++ b/tauri-app/src/pages/Login/Login.tsx @@ -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() { {/* 登录卡片 */}

欢迎登录

-

使用手机号验证码快速登录

+

+ {loginType === 'code' ? '使用手机号验证码快速登录' : '使用手机号密码登录'} +

+ + {/* 登录方式切换 */} +
+ + +
{/* 加载状态 */} {isLoading ? ( @@ -119,91 +145,117 @@ export default function Login() {
) : (
- {/* 手机号 */} -
- -
- +86 - { - const v = e.target.value.replace(/\D/g, '').slice(0, 11); - setPhone(v); - }} - maxLength={11} - autoFocus - /> + {/* 手机号 */} +
+ +
+ +86 + { + const v = e.target.value.replace(/\D/g, '').slice(0, 11); + setPhone(v); + }} + maxLength={11} + autoFocus + /> +
-
- {/* 验证码 */} -
- -
- { - const v = e.target.value.replace(/\D/g, '').slice(0, 6); - setCode(v); - }} - maxLength={6} - /> - -
-
- - {/* 登录按钮 */} - +
+
) : ( - '登录' + /* 密码 */ +
+ + setPassword(e.target.value)} + /> +
)} - - {/* 协议 */} -
- setAgreed(e.target.checked)} - /> - + {/* 登录按钮 */} + + + {/* 忘记密码(仅在密码登录模式显示) */} + {loginType === 'password' && ( +
+ { e.preventDefault(); setResetModalOpen(true); }} + style={{ fontSize: 'var(--font-sm)', color: 'var(--primary)', textDecoration: 'none' }} + > + 忘记密码? + +
+ )} + + {/* 协议 */} +
+ setAgreed(e.target.checked)} + /> + +
- )} @@ -215,6 +267,11 @@ export default function Login() { defaultTab={termsTab} onClose={() => setTermsOpen(false)} /> + + setResetModalOpen(false)} + /> ); } diff --git a/tauri-app/src/pages/Settings/Settings.tsx b/tauri-app/src/pages/Settings/Settings.tsx index ee7ae39..9bdfb06 100644 --- a/tauri-app/src/pages/Settings/Settings.tsx +++ b/tauri-app/src/pages/Settings/Settings.tsx @@ -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() { + {/* 账号安全 */} +
+

账号安全

+
+
+ 登录密码 +
+ + {checkingPassword ? '检查中...' : hasPassword ? '已设置' : '未设置'} + + +
+
+
+
+ {/* 缓存清理 */}

缓存管理

@@ -334,6 +376,16 @@ export default function Settings() { onSave={handleSaveEnv} onCancel={() => setShowEnvModal(false)} /> + + setShowPasswordModal(false)} + onSuccess={() => { + setHasPassword(true); + setShowPasswordModal(false); + }} + />
); } diff --git a/tauri-app/src/store/authStore.ts b/tauri-app/src/store/authStore.ts index 0e0c2ff..4aa4d4e 100644 --- a/tauri-app/src/store/authStore.ts +++ b/tauri-app/src/store/authStore.ts @@ -37,6 +37,7 @@ interface AuthState { interface AuthActions { login: (_phone: string, _code: string) => Promise; + loginWithPassword: (_phone: string, _password: string) => Promise; logout: () => Promise; checkAuth: () => boolean; loadFromStorage: () => Promise; @@ -282,6 +283,44 @@ export const useAuthStore = create((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: '' });