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}
- />
-
-
-
-
- {/* 登录按钮 */}
-
- {/* 协议 */}
-
-
)}
@@ -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 ? '已设置' : '未设置'}
+
+ setShowPasswordModal(true)}
+ >
+ {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: '' });