feat: 邮箱绑定、找回密码、个人中心

- 后端新增邮箱绑定/验证、忘记密码/重置密码、修改密码接口
- users 表新增 nickname、avatar 字段,含迁移脚本
- 新增 PUT /api/auth/me 更新头像和昵称
- 新增 POST /api/auth/change-password 修改密码(需旧密码)
- 前端新增 ProfileModal 个人中心(头像上传、昵称、邀请码、邮箱绑定、修改密码、退出)
- LoginModal 新增忘记密码流程
- UserMenu 点击头像直接打开个人中心
- server/lib/email.ts:nodemailer 邮件发送封装

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MikiVL
2026-05-05 14:01:23 +08:00
co-authored by Claude Sonnet 4.6
parent f2681cb3de
commit b864b2903a
11 changed files with 1138 additions and 89 deletions
+209
View File
@@ -0,0 +1,209 @@
import { useState } from 'react'
import { X, CheckCircle, Mail } from 'lucide-react'
import { apiSendEmailVerify, apiVerifyEmail, apiGetMe } from '../../lib/auth'
import { useAppStore } from '../../stores/appStore'
type Step = 'status' | 'enter-email' | 'enter-code'
export function AccountModal({ onClose }: { onClose: () => void }) {
const { currentUser, setCurrentUser } = useAppStore()
const [step, setStep] = useState<Step>('status')
const [email, setEmail] = useState('')
const [code, setCode] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleSendCode(e: React.FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
try {
await apiSendEmailVerify(email)
setStep('enter-code')
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
async function handleResend() {
setError('')
setLoading(true)
try {
await apiSendEmailVerify(currentUser?.email ?? '')
setStep('enter-code')
setEmail(currentUser?.email ?? '')
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
async function handleVerify(e: React.FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
try {
await apiVerifyEmail(code)
const updated = await apiGetMe()
setCurrentUser(updated)
setStep('status')
setCode('')
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
const inputStyle = {
background: 'var(--bg-muted)',
border: '1px solid var(--border)',
color: 'var(--text)',
width: '100%',
padding: '0.5rem 0.75rem',
borderRadius: '0.5rem',
fontSize: '0.875rem',
outline: 'none',
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={onClose}
>
<div
className="rounded-2xl shadow-2xl p-6 w-80 flex flex-col gap-4"
style={{ background: 'var(--bg)', border: '1px solid var(--border)' }}
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>账号管理</h2>
<button onClick={onClose} className="p-1 rounded" style={{ color: 'var(--text-faint)' }}>
<X size={14} />
</button>
</div>
{step === 'status' && (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
<span className="text-xs font-medium" style={{ color: 'var(--text-faint)' }}>绑定邮箱</span>
{currentUser?.emailVerified ? (
<div className="flex items-center gap-1.5">
<CheckCircle size={13} style={{ color: 'var(--accent)' }} />
<span className="text-sm" style={{ color: 'var(--text)' }}>{currentUser.email}</span>
</div>
) : currentUser?.email ? (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1.5">
<Mail size={13} style={{ color: 'var(--text-faint)' }} />
<span className="text-xs" style={{ color: 'var(--text-faint)' }}>
{currentUser.email}(未验证)
</span>
</div>
{error && <p className="text-xs" style={{ color: '#ef4444' }}>{error}</p>}
<div className="flex gap-2">
<button
onClick={handleResend}
disabled={loading}
className="text-xs"
style={{ color: 'var(--accent)', opacity: loading ? 0.7 : 1 }}
>
{loading ? '发送中…' : '重新发送验证码'}
</button>
<span className="text-xs" style={{ color: 'var(--text-faint)' }}>·</span>
<button
onClick={() => { setEmail(currentUser.email ?? ''); setStep('enter-code') }}
className="text-xs"
style={{ color: 'var(--text-faint)' }}
>
输入验证码
</button>
</div>
</div>
) : (
<button
onClick={() => setStep('enter-email')}
className="text-xs text-left"
style={{ color: 'var(--accent)' }}
>
+ 绑定邮箱
</button>
)}
</div>
</div>
)}
{step === 'enter-email' && (
<form onSubmit={handleSendCode} className="flex flex-col gap-3">
<input
style={inputStyle}
type="email"
placeholder="输入邮箱地址"
value={email}
onChange={e => setEmail(e.target.value)}
autoFocus
/>
{error && <p className="text-xs" style={{ color: '#ef4444' }}>{error}</p>}
<div className="flex gap-2">
<button
type="button"
onClick={() => { setStep('status'); setError('') }}
className="flex-1 py-2 rounded-lg text-sm"
style={{ background: 'var(--bg-muted)', color: 'var(--text-faint)' }}
>
取消
</button>
<button
type="submit"
disabled={loading}
className="flex-1 py-2 rounded-lg text-sm font-medium"
style={{ background: 'var(--accent)', color: '#fff', opacity: loading ? 0.7 : 1 }}
>
{loading ? '发送中…' : '发送验证码'}
</button>
</div>
</form>
)}
{step === 'enter-code' && (
<form onSubmit={handleVerify} className="flex flex-col gap-3">
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>
验证码已发送至 {email || currentUser?.email},10 分钟内有效
</p>
<input
style={inputStyle}
placeholder="输入 6 位验证码"
value={code}
onChange={e => setCode(e.target.value)}
maxLength={6}
autoFocus
/>
{error && <p className="text-xs" style={{ color: '#ef4444' }}>{error}</p>}
<div className="flex gap-2">
<button
type="button"
onClick={() => { setStep('enter-email'); setError(''); setCode('') }}
className="flex-1 py-2 rounded-lg text-sm"
style={{ background: 'var(--bg-muted)', color: 'var(--text-faint)' }}
>
返回
</button>
<button
type="submit"
disabled={loading}
className="flex-1 py-2 rounded-lg text-sm font-medium"
style={{ background: 'var(--accent)', color: '#fff', opacity: loading ? 0.7 : 1 }}
>
{loading ? '验证中…' : '验证'}
</button>
</div>
</form>
)}
</div>
</div>
)
}
+188 -49
View File
@@ -1,9 +1,11 @@
import { useState } from 'react'
import { X } from 'lucide-react'
import { apiLogin, apiRegister, apiActivate, setToken } from '../../lib/auth'
import { apiLogin, apiRegister, apiActivate, setToken, apiForgotPassword, apiResetPassword } from '../../lib/auth'
import { useAppStore } from '../../stores/appStore'
type Tab = 'login' | 'register' | 'activate'
// 忘记密码分两步:输入邮箱 → 输入验证码+新密码
type ForgotStep = 'email' | 'reset'
export function LoginModal({ onClose, initialTab }: { onClose: () => void; initialTab?: Tab }) {
const [tab, setTab] = useState<Tab>(initialTab ?? 'login')
@@ -14,6 +16,50 @@ export function LoginModal({ onClose, initialTab }: { onClose: () => void; initi
const [loading, setLoading] = useState(false)
const { setCurrentUser, syncFromCloud, currentUser } = useAppStore()
// 忘记密码状态
const [forgotMode, setForgotMode] = useState(false)
const [forgotStep, setForgotStep] = useState<ForgotStep>('email')
const [forgotEmail, setForgotEmail] = useState('')
const [forgotCode, setForgotCode] = useState('')
const [forgotPassword, setForgotPassword] = useState('')
function resetForgot() {
setForgotMode(false)
setForgotStep('email')
setForgotEmail('')
setForgotCode('')
setForgotPassword('')
setError('')
}
async function handleForgotSend(e: React.FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
try {
await apiForgotPassword(forgotEmail)
setForgotStep('reset')
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
async function handleForgotReset(e: React.FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
try {
await apiResetPassword(forgotEmail, forgotCode, forgotPassword)
resetForgot()
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
@@ -67,70 +113,163 @@ export function LoginModal({ onClose, initialTab }: { onClose: () => void; initi
>
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
{tab === 'login' ? '登录' : tab === 'register' ? '注册' : '激活云存储'}
{forgotMode
? (forgotStep === 'email' ? '找回密码' : '重置密码')
: (tab === 'login' ? '登录' : tab === 'register' ? '注册' : '激活云存储')}
</h2>
<button onClick={onClose} className="p-1 rounded" style={{ color: 'var(--text-faint)' }}>
<X size={14} />
</button>
</div>
{tab !== 'activate' && (
<div className="flex gap-1 p-1 rounded-lg" style={{ background: 'var(--bg-muted)' }}>
{(['login', 'register'] as Tab[]).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className="flex-1 py-1 rounded text-xs font-medium transition-all"
style={{
background: tab === t ? 'var(--bg)' : 'transparent',
color: tab === t ? 'var(--text)' : 'var(--text-faint)',
}}
>
{t === 'login' ? '登录' : '注册'}
</button>
))}
</div>
)}
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
{tab !== 'activate' ? (
<>
{/* 忘记密码流程 */}
{forgotMode ? (
forgotStep === 'email' ? (
<form onSubmit={handleForgotSend} className="flex flex-col gap-3">
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>
输入绑定的邮箱,我们将发送验证码
</p>
<input
style={inputStyle}
placeholder="用户名"
value={username}
onChange={e => setUsername(e.target.value)}
type="email"
placeholder="邮箱地址"
value={forgotEmail}
onChange={e => setForgotEmail(e.target.value)}
autoFocus
/>
{error && <p className="text-xs" style={{ color: '#ef4444' }}>{error}</p>}
<div className="flex gap-2">
<button
type="button"
onClick={resetForgot}
className="flex-1 py-2 rounded-lg text-sm"
style={{ background: 'var(--bg-muted)', color: 'var(--text-faint)' }}
>
取消
</button>
<button
type="submit"
disabled={loading}
className="flex-1 py-2 rounded-lg text-sm font-medium"
style={{ background: 'var(--accent)', color: '#fff', opacity: loading ? 0.7 : 1 }}
>
{loading ? '发送中…' : '发送验证码'}
</button>
</div>
</form>
) : (
<form onSubmit={handleForgotReset} className="flex flex-col gap-3">
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>
验证码已发送至 {forgotEmail},10 分钟内有效
</p>
<input
style={inputStyle}
placeholder="6 位验证码"
value={forgotCode}
onChange={e => setForgotCode(e.target.value)}
maxLength={6}
autoFocus
/>
<input
style={inputStyle}
type="password"
placeholder="密码(至少 6 位)"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="新密码(至少 6 位)"
value={forgotPassword}
onChange={e => setForgotPassword(e.target.value)}
/>
</>
) : (
<input
style={inputStyle}
placeholder="邀请码(如 MIKI-A7X2-KP9Q)"
value={code}
onChange={e => setCode(e.target.value)}
autoFocus
/>
)}
{error && <p className="text-xs" style={{ color: '#ef4444' }}>{error}</p>}
<div className="flex gap-2">
<button
type="button"
onClick={() => { setForgotStep('email'); setError('') }}
className="flex-1 py-2 rounded-lg text-sm"
style={{ background: 'var(--bg-muted)', color: 'var(--text-faint)' }}
>
返回
</button>
<button
type="submit"
disabled={loading}
className="flex-1 py-2 rounded-lg text-sm font-medium"
style={{ background: 'var(--accent)', color: '#fff', opacity: loading ? 0.7 : 1 }}
>
{loading ? '重置中…' : '重置密码'}
</button>
</div>
</form>
)
) : (
<>
{tab !== 'activate' && (
<div className="flex gap-1 p-1 rounded-lg" style={{ background: 'var(--bg-muted)' }}>
{(['login', 'register'] as Tab[]).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className="flex-1 py-1 rounded text-xs font-medium transition-all"
style={{
background: tab === t ? 'var(--bg)' : 'transparent',
color: tab === t ? 'var(--text)' : 'var(--text-faint)',
}}
>
{t === 'login' ? '登录' : '注册'}
</button>
))}
</div>
)}
{error && <p className="text-xs" style={{ color: '#ef4444' }}>{error}</p>}
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
{tab !== 'activate' ? (
<>
<input
style={inputStyle}
placeholder="用户名"
value={username}
onChange={e => setUsername(e.target.value)}
autoFocus
/>
<input
style={inputStyle}
type="password"
placeholder="密码(至少 6 位)"
value={password}
onChange={e => setPassword(e.target.value)}
/>
</>
) : (
<input
style={inputStyle}
placeholder="邀请码(如 MIKI-A7X2-KP9Q)"
value={code}
onChange={e => setCode(e.target.value)}
autoFocus
/>
)}
<button
type="submit"
disabled={loading}
className="w-full py-2 rounded-lg text-sm font-medium"
style={{ background: 'var(--accent)', color: '#fff', opacity: loading ? 0.7 : 1 }}
>
{loading ? '请稍候…' : tab === 'login' ? '登录' : tab === 'register' ? '注册' : '激活'}
</button>
</form>
{error && <p className="text-xs" style={{ color: '#ef4444' }}>{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full py-2 rounded-lg text-sm font-medium"
style={{ background: 'var(--accent)', color: '#fff', opacity: loading ? 0.7 : 1 }}
>
{loading ? '请稍候…' : tab === 'login' ? '登录' : tab === 'register' ? '注册' : '激活'}
</button>
{tab === 'login' && (
<button
type="button"
onClick={() => { setForgotMode(true); setError('') }}
className="text-xs text-center"
style={{ color: 'var(--text-faint)' }}
>
忘记密码?
</button>
)}
</form>
</>
)}
</div>
</div>
)
+427
View File
@@ -0,0 +1,427 @@
import { useState, useRef } from 'react'
import { X, CheckCircle, Mail, Camera, Key, LogOut, Cloud } from 'lucide-react'
import {
apiUpdateProfile, apiChangePassword,
apiSendEmailVerify, apiVerifyEmail, apiActivate, apiGetMe,
} from '../../lib/auth'
import { useAppStore } from '../../stores/appStore'
type Section = 'main' | 'email' | 'email-code' | 'password' | 'change-password'
export function ProfileModal({ onClose }: { onClose: () => void }) {
const { currentUser, setCurrentUser, logout, syncStatus, syncFromCloud } = useAppStore()
const [section, setSection] = useState<Section>('main')
// 头像/昵称
const [nickname, setNickname] = useState(currentUser?.nickname ?? '')
const [avatarPreview, setAvatarPreview] = useState(currentUser?.avatar ?? null)
const [avatarData, setAvatarData] = useState<string | undefined>(undefined)
const [profileLoading, setProfileLoading] = useState(false)
const [profileMsg, setProfileMsg] = useState('')
const fileRef = useRef<HTMLInputElement>(null)
// 邀请码
const [inviteCode, setInviteCode] = useState('')
const [inviteLoading, setInviteLoading] = useState(false)
const [inviteError, setInviteError] = useState('')
// 邮箱
const [email, setEmail] = useState('')
const [emailCode, setEmailCode] = useState('')
const [emailLoading, setEmailLoading] = useState(false)
const [emailError, setEmailError] = useState('')
// 修改密码
const [oldPwd, setOldPwd] = useState('')
const [newPwd, setNewPwd] = useState('')
const [pwdLoading, setPwdLoading] = useState(false)
const [pwdError, setPwdError] = useState('')
const [pwdOk, setPwdOk] = useState(false)
const inputStyle: React.CSSProperties = {
background: 'var(--bg-muted)',
border: '1px solid var(--border)',
color: 'var(--text)',
width: '100%',
padding: '0.5rem 0.75rem',
borderRadius: '0.5rem',
fontSize: '0.875rem',
outline: 'none',
}
function handleAvatarChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = ev => {
const result = ev.target?.result as string
setAvatarPreview(result)
setAvatarData(result)
}
reader.readAsDataURL(file)
}
async function handleSaveProfile(e: React.FormEvent) {
e.preventDefault()
setProfileLoading(true)
setProfileMsg('')
try {
const updated = await apiUpdateProfile(nickname, avatarData)
setCurrentUser(updated)
setAvatarData(undefined)
setProfileMsg('保存成功')
setTimeout(() => setProfileMsg(''), 2000)
} catch (err: any) {
setProfileMsg(err.message)
} finally {
setProfileLoading(false)
}
}
async function handleActivate(e: React.FormEvent) {
e.preventDefault()
setInviteError('')
setInviteLoading(true)
try {
await apiActivate(inviteCode)
const updated = await apiGetMe()
setCurrentUser(updated)
await syncFromCloud()
setInviteCode('')
} catch (err: any) {
setInviteError(err.message)
} finally {
setInviteLoading(false)
}
}
async function handleSendEmailCode(e: React.FormEvent) {
e.preventDefault()
setEmailError('')
setEmailLoading(true)
try {
await apiSendEmailVerify(email)
setSection('email-code')
} catch (err: any) {
setEmailError(err.message)
} finally {
setEmailLoading(false)
}
}
async function handleVerifyEmail(e: React.FormEvent) {
e.preventDefault()
setEmailError('')
setEmailLoading(true)
try {
await apiVerifyEmail(emailCode)
const updated = await apiGetMe()
setCurrentUser(updated)
setSection('main')
setEmailCode('')
setEmail('')
} catch (err: any) {
setEmailError(err.message)
} finally {
setEmailLoading(false)
}
}
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault()
setPwdError('')
setPwdOk(false)
setPwdLoading(true)
try {
await apiChangePassword(oldPwd, newPwd)
setOldPwd('')
setNewPwd('')
setPwdOk(true)
setTimeout(() => { setPwdOk(false); setSection('main') }, 1500)
} catch (err: any) {
setPwdError(err.message)
} finally {
setPwdLoading(false)
}
}
const displayName = currentUser?.nickname || currentUser?.username || ''
const avatarSrc = avatarPreview || currentUser?.avatar
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={onClose}
>
<div
className="rounded-2xl shadow-2xl p-6 w-96 flex flex-col gap-5 max-h-[90vh] overflow-y-auto"
style={{ background: 'var(--bg)', border: '1px solid var(--border)' }}
onClick={e => e.stopPropagation()}
>
{/* 标题栏 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{section !== 'main' && (
<button
onClick={() => { setSection('main'); setEmailError(''); setPwdError('') }}
className="text-xs"
style={{ color: 'var(--text-faint)' }}
>
←
</button>
)}
<h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
{section === 'main' && '个人中心'}
{section === 'email' && '绑定邮箱'}
{section === 'email-code' && '验证邮箱'}
{section === 'password' && '修改密码'}
{section === 'change-password' && '修改密码'}
</h2>
</div>
<button onClick={onClose} className="p-1 rounded" style={{ color: 'var(--text-faint)' }}>
<X size={14} />
</button>
</div>
{/* 主界面 */}
{section === 'main' && (
<>
{/* 头像 + 昵称 */}
<form onSubmit={handleSaveProfile} className="flex flex-col gap-3">
<div className="flex items-center gap-4">
<div className="relative">
<div
className="w-16 h-16 rounded-full flex items-center justify-center text-xl font-bold overflow-hidden cursor-pointer"
style={{ background: 'var(--accent)', color: '#fff' }}
onClick={() => fileRef.current?.click()}
>
{avatarSrc ? (
<img src={avatarSrc} alt="头像" className="w-full h-full object-cover" />
) : (
displayName[0]?.toUpperCase()
)}
</div>
<button
type="button"
onClick={() => fileRef.current?.click()}
className="absolute bottom-0 right-0 w-5 h-5 rounded-full flex items-center justify-center"
style={{ background: 'var(--bg)', border: '1px solid var(--border)' }}
>
<Camera size={10} style={{ color: 'var(--text-faint)' }} />
</button>
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleAvatarChange} />
</div>
<div className="flex-1">
<label className="text-xs mb-1 block" style={{ color: 'var(--text-faint)' }}>昵称</label>
<input
style={inputStyle}
placeholder={currentUser?.username}
value={nickname}
onChange={e => setNickname(e.target.value)}
maxLength={20}
/>
</div>
</div>
{profileMsg && (
<p className="text-xs" style={{ color: profileMsg === '保存成功' ? 'var(--accent)' : '#ef4444' }}>
{profileMsg}
</p>
)}
<button
type="submit"
disabled={profileLoading}
className="w-full py-2 rounded-lg text-sm font-medium"
style={{ background: 'var(--accent)', color: '#fff', opacity: profileLoading ? 0.7 : 1 }}
>
{profileLoading ? '保存中…' : '保存'}
</button>
</form>
<div style={{ height: 1, background: 'var(--border)' }} />
{/* 云存储 / 邀请码 */}
<div className="flex flex-col gap-2">
<span className="text-xs font-medium" style={{ color: 'var(--text-faint)' }}>云存储</span>
{currentUser?.cloudEnabled ? (
<button
onClick={syncFromCloud}
disabled={syncStatus === 'syncing'}
className="flex items-center gap-2 text-sm"
style={{ color: syncStatus === 'error' ? '#ef4444' : 'var(--accent)' }}
>
<Cloud size={13} />
{syncStatus === 'syncing' ? '同步中…' : syncStatus === 'error' ? '同步失败,点击重试' : '已开启,点击手动同步'}
</button>
) : (
<form onSubmit={handleActivate} className="flex flex-col gap-2">
<input
style={inputStyle}
placeholder="输入邀请码(如 MIKI-A7X2-KP9Q)"
value={inviteCode}
onChange={e => setInviteCode(e.target.value)}
/>
{inviteError && <p className="text-xs" style={{ color: '#ef4444' }}>{inviteError}</p>}
<button
type="submit"
disabled={inviteLoading}
className="w-full py-1.5 rounded-lg text-sm"
style={{ background: 'var(--bg-muted)', color: 'var(--text)', border: '1px solid var(--border)', opacity: inviteLoading ? 0.7 : 1 }}
>
{inviteLoading ? '激活中…' : '激活云存储'}
</button>
</form>
)}
</div>
<div style={{ height: 1, background: 'var(--border)' }} />
{/* 邮箱 */}
<div className="flex flex-col gap-2">
<span className="text-xs font-medium" style={{ color: 'var(--text-faint)' }}>绑定邮箱</span>
{currentUser?.emailVerified ? (
<div className="flex items-center gap-1.5">
<CheckCircle size={13} style={{ color: 'var(--accent)' }} />
<span className="text-sm" style={{ color: 'var(--text)' }}>{currentUser.email}</span>
<button
onClick={() => { setEmail(currentUser.email ?? ''); setSection('email') }}
className="text-xs ml-auto"
style={{ color: 'var(--text-faint)' }}
>
更换
</button>
</div>
) : currentUser?.email ? (
<div className="flex items-center gap-2">
<Mail size={13} style={{ color: 'var(--text-faint)' }} />
<span className="text-xs" style={{ color: 'var(--text-faint)' }}>{currentUser.email}(未验证)</span>
<button
onClick={() => { setEmail(currentUser.email ?? ''); setSection('email-code') }}
className="text-xs ml-auto"
style={{ color: 'var(--accent)' }}
>
输入验证码
</button>
</div>
) : (
<button
onClick={() => setSection('email')}
className="text-sm text-left"
style={{ color: 'var(--accent)' }}
>
+ 绑定邮箱
</button>
)}
</div>
<div style={{ height: 1, background: 'var(--border)' }} />
{/* 修改密码 */}
<button
onClick={() => setSection('change-password')}
className="flex items-center gap-2 text-sm"
style={{ color: 'var(--text-faint)' }}
onMouseEnter={e => (e.currentTarget.style.color = 'var(--text)')}
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-faint)')}
>
<Key size={13} />
修改密码
</button>
{/* 退出登录 */}
<button
onClick={() => { logout(); onClose() }}
className="flex items-center gap-2 text-sm"
style={{ color: '#ef4444' }}
onMouseEnter={e => (e.currentTarget.style.opacity = '0.7')}
onMouseLeave={e => (e.currentTarget.style.opacity = '1')}
>
<LogOut size={13} />
退出登录
</button>
</>
)}
{/* 绑定邮箱 */}
{section === 'email' && (
<form onSubmit={handleSendEmailCode} className="flex flex-col gap-3">
<input
style={inputStyle}
type="email"
placeholder="输入邮箱地址"
value={email}
onChange={e => setEmail(e.target.value)}
autoFocus
/>
{emailError && <p className="text-xs" style={{ color: '#ef4444' }}>{emailError}</p>}
<button
type="submit"
disabled={emailLoading}
className="w-full py-2 rounded-lg text-sm font-medium"
style={{ background: 'var(--accent)', color: '#fff', opacity: emailLoading ? 0.7 : 1 }}
>
{emailLoading ? '发送中…' : '发送验证码'}
</button>
</form>
)}
{/* 验证邮箱验证码 */}
{section === 'email-code' && (
<form onSubmit={handleVerifyEmail} className="flex flex-col gap-3">
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>
验证码已发送至 {email || currentUser?.email},10 分钟内有效
</p>
<input
style={inputStyle}
placeholder="输入 6 位验证码"
value={emailCode}
onChange={e => setEmailCode(e.target.value)}
maxLength={6}
autoFocus
/>
{emailError && <p className="text-xs" style={{ color: '#ef4444' }}>{emailError}</p>}
<button
type="submit"
disabled={emailLoading}
className="w-full py-2 rounded-lg text-sm font-medium"
style={{ background: 'var(--accent)', color: '#fff', opacity: emailLoading ? 0.7 : 1 }}
>
{emailLoading ? '验证中…' : '验证'}
</button>
</form>
)}
{/* 修改密码 */}
{section === 'change-password' && (
<form onSubmit={handleChangePassword} className="flex flex-col gap-3">
<input
style={inputStyle}
type="password"
placeholder="当前密码"
value={oldPwd}
onChange={e => setOldPwd(e.target.value)}
autoFocus
/>
<input
style={inputStyle}
type="password"
placeholder="新密码(至少 6 位)"
value={newPwd}
onChange={e => setNewPwd(e.target.value)}
/>
{pwdError && <p className="text-xs" style={{ color: '#ef4444' }}>{pwdError}</p>}
{pwdOk && <p className="text-xs" style={{ color: 'var(--accent)' }}>密码修改成功</p>}
<button
type="submit"
disabled={pwdLoading}
className="w-full py-2 rounded-lg text-sm font-medium"
style={{ background: 'var(--accent)', color: '#fff', opacity: pwdLoading ? 0.7 : 1 }}
>
{pwdLoading ? '修改中…' : '确认修改'}
</button>
</form>
)}
</div>
</div>
)
}
+28 -29
View File
@@ -1,12 +1,13 @@
import { useState } from 'react'
import { User, LogOut, Cloud, Key } from 'lucide-react'
import { User, Cloud, Key } from 'lucide-react'
import { useAppStore } from '../../stores/appStore'
import { LoginModal } from './LoginModal'
import { ProfileModal } from './ProfileModal'
export function UserMenu() {
const { currentUser, logout, syncStatus, syncFromCloud } = useAppStore()
const { currentUser, syncStatus, syncFromCloud } = useAppStore()
const [showLogin, setShowLogin] = useState(false)
const [showActivate, setShowActivate] = useState(false)
const [showProfile, setShowProfile] = useState(false)
if (!currentUser) {
return (
@@ -26,32 +27,32 @@ export function UserMenu() {
)
}
const displayName = currentUser.nickname || currentUser.username
return (
<>
<div className="px-3 py-2 flex flex-col gap-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div
className="w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0"
style={{ background: 'var(--accent)', color: '#fff' }}
>
{currentUser.username[0].toUpperCase()}
</div>
<span className="text-xs font-medium truncate" style={{ color: 'var(--text)' }}>
{currentUser.username}
</span>
</div>
<button
onClick={logout}
title="登出"
className="p-1 rounded flex-shrink-0"
style={{ color: 'var(--text-faint)' }}
onMouseEnter={e => (e.currentTarget.style.color = 'var(--text)')}
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-faint)')}
<button
onClick={() => setShowProfile(true)}
className="flex items-center gap-2 w-full text-left rounded-lg transition-all"
style={{ background: 'transparent' }}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-muted)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 overflow-hidden"
style={{ background: 'var(--accent)', color: '#fff' }}
>
<LogOut size={12} />
</button>
</div>
{currentUser.avatar ? (
<img src={currentUser.avatar} alt="头像" className="w-full h-full object-cover" />
) : (
displayName[0]?.toUpperCase()
)}
</div>
<span className="text-xs font-medium truncate" style={{ color: 'var(--text)' }}>
{displayName}
</span>
</button>
{currentUser.cloudEnabled ? (
<button
@@ -65,7 +66,7 @@ export function UserMenu() {
</button>
) : (
<button
onClick={() => setShowActivate(true)}
onClick={() => setShowProfile(true)}
className="flex items-center gap-1.5 text-xs"
style={{ color: 'var(--text-faint)' }}
onMouseEnter={e => (e.currentTarget.style.color = 'var(--accent)')}
@@ -77,9 +78,7 @@ export function UserMenu() {
)}
</div>
{showActivate && (
<LoginModal initialTab="activate" onClose={() => setShowActivate(false)} />
)}
{showProfile && <ProfileModal onClose={() => setShowProfile(false)} />}
</>
)
}
+71 -2
View File
@@ -1,7 +1,7 @@
const TOKEN_KEY = 'mikivl_token'
const API = '/api'
export type CurrentUser = { id: string; username: string; cloudEnabled: boolean }
export type CurrentUser = { id: string; username: string; cloudEnabled: boolean; email: string | null; emailVerified: boolean; nickname: string | null; avatar: string | null }
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
@@ -52,11 +52,80 @@ export async function apiActivate(code: string): Promise<void> {
if (!res.ok) throw new Error(data.error ?? '激活失败')
}
export async function apiUpdateProfile(nickname?: string, avatar?: string): Promise<CurrentUser> {
const res = await fetch(`${API}/auth/me`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ nickname, avatar }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? '更新失败')
return data
}
export async function apiChangePassword(oldPassword: string, newPassword: string): Promise<void> {
const res = await fetch(`${API}/auth/change-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ oldPassword, newPassword }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? '修改失败')
}
export async function apiGetMe(): Promise<CurrentUser> {
const res = await fetch(`${API}/auth/me`, { headers: authHeaders() })
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? '获取用户信息失败')
return data
}
export async function apiSendEmailVerify(email: string): Promise<void> {
const res = await fetch(`${API}/auth/email/send-verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ email }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? '发送失败')
}
export async function apiVerifyEmail(code: string): Promise<void> {
const res = await fetch(`${API}/auth/email/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ code }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? '验证失败')
}
export async function apiForgotPassword(email: string): Promise<void> {
const res = await fetch(`${API}/auth/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? '发送失败')
}
export async function apiResetPassword(email: string, code: string, newPassword: string): Promise<void> {
const res = await fetch(`${API}/auth/reset-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, code, newPassword }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? '重置失败')
}
export function parseToken(token: string): CurrentUser | null {
try {
const payload = JSON.parse(atob(token.split('.')[1]))
return { id: payload.userId, username: payload.username, cloudEnabled: false }
return { id: payload.userId, username: payload.username, cloudEnabled: false, email: null, emailVerified: false, nickname: null, avatar: null }
} catch {
return null
}
}