fix: 邮件懒加载、cloudEnabled 刷新、邮箱写入时机、验证码重发冷却
- server/lib/email.ts: transporter 改为懒加载,避免 ESM import 提升导致 EMAIL_PASS 未加载 - server/routes/auth.ts: send-verify 改为写 pendingEmail,验证成功后才写 email;新增 60s 冷却;catch 块打印错误 - server/db.ts: 新增 pendingEmail 字段及迁移 - src/stores/appStore.ts: 新增 refreshCurrentUser,启动时调用 apiGetMe 拉取真实 cloudEnabled - src/App.tsx: 启动时调用 refreshCurrentUser - ProfileModal/AccountModal: 加入 60s 倒计时和重新发送按钮 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b864b2903a
commit
689c8b2d86
@ -22,6 +22,7 @@ export const users = sqliteTable('users', {
|
|||||||
avatar: text('avatar'),
|
avatar: text('avatar'),
|
||||||
email: text('email'),
|
email: text('email'),
|
||||||
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
|
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
pendingEmail: text('pending_email'),
|
||||||
emailVerifyCode: text('email_verify_code'),
|
emailVerifyCode: text('email_verify_code'),
|
||||||
emailVerifyExpiry: integer('email_verify_expiry'),
|
emailVerifyExpiry: integer('email_verify_expiry'),
|
||||||
resetCode: text('reset_code'),
|
resetCode: text('reset_code'),
|
||||||
@ -134,4 +135,5 @@ export function initDb() {
|
|||||||
if (!colNames.includes('reset_code_expiry')) sqlite.exec(`ALTER TABLE users ADD COLUMN reset_code_expiry INTEGER`)
|
if (!colNames.includes('reset_code_expiry')) sqlite.exec(`ALTER TABLE users ADD COLUMN reset_code_expiry INTEGER`)
|
||||||
if (!colNames.includes('nickname')) sqlite.exec(`ALTER TABLE users ADD COLUMN nickname TEXT`)
|
if (!colNames.includes('nickname')) sqlite.exec(`ALTER TABLE users ADD COLUMN nickname TEXT`)
|
||||||
if (!colNames.includes('avatar')) sqlite.exec(`ALTER TABLE users ADD COLUMN avatar TEXT`)
|
if (!colNames.includes('avatar')) sqlite.exec(`ALTER TABLE users ADD COLUMN avatar TEXT`)
|
||||||
|
if (!colNames.includes('pending_email')) sqlite.exec(`ALTER TABLE users ADD COLUMN pending_email TEXT`)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import nodemailer from 'nodemailer'
|
import nodemailer from 'nodemailer'
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport({
|
function createTransporter() {
|
||||||
|
return nodemailer.createTransport({
|
||||||
host: 'smtp.126.com',
|
host: 'smtp.126.com',
|
||||||
port: 465,
|
port: 465,
|
||||||
secure: true,
|
secure: true,
|
||||||
@ -8,10 +9,11 @@ const transporter = nodemailer.createTransport({
|
|||||||
user: 'mikivl@126.com',
|
user: 'mikivl@126.com',
|
||||||
pass: process.env.EMAIL_PASS!,
|
pass: process.env.EMAIL_PASS!,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendVerifyCode(to: string, code: string) {
|
export async function sendVerifyCode(to: string, code: string) {
|
||||||
await transporter.sendMail({
|
await createTransporter().sendMail({
|
||||||
from: '"MikiVL 笔记" <mikivl@126.com>',
|
from: '"MikiVL 笔记" <mikivl@126.com>',
|
||||||
to,
|
to,
|
||||||
subject: '邮箱验证码',
|
subject: '邮箱验证码',
|
||||||
@ -21,7 +23,7 @@ export async function sendVerifyCode(to: string, code: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function sendResetCode(to: string, code: string) {
|
export async function sendResetCode(to: string, code: string) {
|
||||||
await transporter.sendMail({
|
await createTransporter().sendMail({
|
||||||
from: '"MikiVL 笔记" <mikivl@126.com>',
|
from: '"MikiVL 笔记" <mikivl@126.com>',
|
||||||
to,
|
to,
|
||||||
subject: '重置密码验证码',
|
subject: '重置密码验证码',
|
||||||
|
|||||||
@ -96,17 +96,25 @@ authRouter.post('/email/send-verify', requireAuth, async (c) => {
|
|||||||
const { email } = await c.req.json<{ email: string }>()
|
const { email } = await c.req.json<{ email: string }>()
|
||||||
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return c.json({ error: '邮箱格式不正确' }, 400)
|
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return c.json({ error: '邮箱格式不正确' }, 400)
|
||||||
|
|
||||||
// 检查邮箱是否已被其他用户绑定
|
// 检查邮箱是否已被其他已验证用户绑定
|
||||||
const existing = db.select().from(users).where(eq(users.email, email)).all()
|
const existing = db.select().from(users).where(eq(users.email, email)).all()
|
||||||
if (existing.some(u => u.id !== userId)) return c.json({ error: '该邮箱已被其他账号绑定' }, 409)
|
if (existing.some(u => u.id !== userId)) return c.json({ error: '该邮箱已被其他账号绑定' }, 409)
|
||||||
|
|
||||||
|
// 60 秒冷却:上次发送时间在 60 秒内则拒绝
|
||||||
|
const [user] = db.select().from(users).where(eq(users.id, userId)).all()
|
||||||
|
if (user?.emailVerifyExpiry && user.emailVerifyExpiry - Date.now() > 9 * 60 * 1000) {
|
||||||
|
return c.json({ error: '请等待 60 秒后再重新发送' }, 429)
|
||||||
|
}
|
||||||
|
|
||||||
const code = randomCode()
|
const code = randomCode()
|
||||||
const expiry = Date.now() + 10 * 60 * 1000
|
const expiry = Date.now() + 10 * 60 * 1000
|
||||||
db.update(users).set({ email, emailVerifyCode: code, emailVerifyExpiry: expiry, emailVerified: false }).where(eq(users.id, userId)).run()
|
// 只写入 pendingEmail,不修改 email 字段
|
||||||
|
db.update(users).set({ pendingEmail: email, emailVerifyCode: code, emailVerifyExpiry: expiry }).where(eq(users.id, userId)).run()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendVerifyCode(email, code)
|
await sendVerifyCode(email, code)
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('[email]', err)
|
||||||
return c.json({ error: '邮件发送失败,请稍后再试' }, 500)
|
return c.json({ error: '邮件发送失败,请稍后再试' }, 500)
|
||||||
}
|
}
|
||||||
return c.json({ success: true })
|
return c.json({ success: true })
|
||||||
@ -123,7 +131,8 @@ authRouter.post('/email/verify', requireAuth, async (c) => {
|
|||||||
if (!user.emailVerifyCode || user.emailVerifyCode !== code.trim()) return c.json({ error: '验证码错误' }, 400)
|
if (!user.emailVerifyCode || user.emailVerifyCode !== code.trim()) return c.json({ error: '验证码错误' }, 400)
|
||||||
if (!user.emailVerifyExpiry || Date.now() > user.emailVerifyExpiry) return c.json({ error: '验证码已过期,请重新发送' }, 400)
|
if (!user.emailVerifyExpiry || Date.now() > user.emailVerifyExpiry) return c.json({ error: '验证码已过期,请重新发送' }, 400)
|
||||||
|
|
||||||
db.update(users).set({ emailVerified: true, emailVerifyCode: null, emailVerifyExpiry: null }).where(eq(users.id, userId)).run()
|
// 验证成功:将 pendingEmail 写入正式 email 字段
|
||||||
|
db.update(users).set({ email: user.pendingEmail, emailVerified: true, pendingEmail: null, emailVerifyCode: null, emailVerifyExpiry: null }).where(eq(users.id, userId)).run()
|
||||||
return c.json({ success: true })
|
return c.json({ success: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { useAppStore } from './stores/appStore'
|
|||||||
import { seedIfEmpty, deduplicateDB } from './db'
|
import { seedIfEmpty, deduplicateDB } from './db'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { loadAll, theme, focusMode, aiPanelOpen, toggleFocusMode, toggleAiPanel, createNote, activeFolderId } = useAppStore()
|
const { loadAll, theme, focusMode, aiPanelOpen, toggleFocusMode, toggleAiPanel, createNote, activeFolderId, refreshCurrentUser } = useAppStore()
|
||||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -17,6 +17,7 @@ export default function App() {
|
|||||||
seedIfEmpty()
|
seedIfEmpty()
|
||||||
.then(() => deduplicateDB())
|
.then(() => deduplicateDB())
|
||||||
.then(() => loadAll())
|
.then(() => loadAll())
|
||||||
|
refreshCurrentUser()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -1,17 +1,31 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { X, CheckCircle, Mail } from 'lucide-react'
|
import { X, CheckCircle } from 'lucide-react'
|
||||||
import { apiSendEmailVerify, apiVerifyEmail, apiGetMe } from '../../lib/auth'
|
import { apiSendEmailVerify, apiVerifyEmail, apiGetMe } from '../../lib/auth'
|
||||||
import { useAppStore } from '../../stores/appStore'
|
import { useAppStore } from '../../stores/appStore'
|
||||||
|
|
||||||
type Step = 'status' | 'enter-email' | 'enter-code'
|
type Step = 'enter-email' | 'enter-code'
|
||||||
|
|
||||||
export function AccountModal({ onClose }: { onClose: () => void }) {
|
export function AccountModal({ onClose }: { onClose: () => void }) {
|
||||||
const { currentUser, setCurrentUser } = useAppStore()
|
const { currentUser, setCurrentUser } = useAppStore()
|
||||||
const [step, setStep] = useState<Step>('status')
|
const [step, setStep] = useState<Step>('enter-email')
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [code, setCode] = useState('')
|
const [code, setCode] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [countdown, setCountdown] = useState(0)
|
||||||
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => () => { if (timerRef.current) clearInterval(timerRef.current) }, [])
|
||||||
|
|
||||||
|
function startCountdown() {
|
||||||
|
setCountdown(60)
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
setCountdown(n => {
|
||||||
|
if (n <= 1) { clearInterval(timerRef.current!); return 0 }
|
||||||
|
return n - 1
|
||||||
|
})
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSendCode(e: React.FormEvent) {
|
async function handleSendCode(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@ -20,6 +34,7 @@ export function AccountModal({ onClose }: { onClose: () => void }) {
|
|||||||
try {
|
try {
|
||||||
await apiSendEmailVerify(email)
|
await apiSendEmailVerify(email)
|
||||||
setStep('enter-code')
|
setStep('enter-code')
|
||||||
|
startCountdown()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
@ -31,9 +46,8 @@ export function AccountModal({ onClose }: { onClose: () => void }) {
|
|||||||
setError('')
|
setError('')
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
await apiSendEmailVerify(currentUser?.email ?? '')
|
await apiSendEmailVerify(email)
|
||||||
setStep('enter-code')
|
startCountdown()
|
||||||
setEmail(currentUser?.email ?? '')
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
@ -49,8 +63,7 @@ export function AccountModal({ onClose }: { onClose: () => void }) {
|
|||||||
await apiVerifyEmail(code)
|
await apiVerifyEmail(code)
|
||||||
const updated = await apiGetMe()
|
const updated = await apiGetMe()
|
||||||
setCurrentUser(updated)
|
setCurrentUser(updated)
|
||||||
setStep('status')
|
onClose()
|
||||||
setCode('')
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
@ -81,63 +94,20 @@ export function AccountModal({ onClose }: { onClose: () => void }) {
|
|||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>账号管理</h2>
|
<h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
|
||||||
|
{currentUser?.emailVerified ? '邮箱已绑定' : '绑定邮箱'}
|
||||||
|
</h2>
|
||||||
<button onClick={onClose} className="p-1 rounded" style={{ color: 'var(--text-faint)' }}>
|
<button onClick={onClose} className="p-1 rounded" style={{ color: 'var(--text-faint)' }}>
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 ? (
|
{currentUser?.emailVerified ? (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<CheckCircle size={13} style={{ color: 'var(--accent)' }} />
|
<CheckCircle size={13} style={{ color: 'var(--accent)' }} />
|
||||||
<span className="text-sm" style={{ color: 'var(--text)' }}>{currentUser.email}</span>
|
<span className="text-sm" style={{ color: 'var(--text)' }}>{currentUser.email}</span>
|
||||||
</div>
|
</div>
|
||||||
) : currentUser?.email ? (
|
) : step === 'enter-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">
|
<form onSubmit={handleSendCode} className="flex flex-col gap-3">
|
||||||
<input
|
<input
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
@ -148,31 +118,19 @@ export function AccountModal({ onClose }: { onClose: () => void }) {
|
|||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
{error && <p className="text-xs" style={{ color: '#ef4444' }}>{error}</p>}
|
{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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex-1 py-2 rounded-lg text-sm font-medium"
|
className="w-full py-2 rounded-lg text-sm font-medium"
|
||||||
style={{ background: 'var(--accent)', color: '#fff', opacity: loading ? 0.7 : 1 }}
|
style={{ background: 'var(--accent)', color: '#fff', opacity: loading ? 0.7 : 1 }}
|
||||||
>
|
>
|
||||||
{loading ? '发送中…' : '发送验证码'}
|
{loading ? '发送中…' : '发送验证码'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
)}
|
) : (
|
||||||
|
|
||||||
{step === 'enter-code' && (
|
|
||||||
<form onSubmit={handleVerify} className="flex flex-col gap-3">
|
<form onSubmit={handleVerify} className="flex flex-col gap-3">
|
||||||
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>
|
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>
|
||||||
验证码已发送至 {email || currentUser?.email},10 分钟内有效
|
验证码已发送至 {email},10 分钟内有效
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
@ -183,23 +141,32 @@ export function AccountModal({ onClose }: { onClose: () => void }) {
|
|||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
{error && <p className="text-xs" style={{ color: '#ef4444' }}>{error}</p>}
|
{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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex-1 py-2 rounded-lg text-sm font-medium"
|
className="w-full py-2 rounded-lg text-sm font-medium"
|
||||||
style={{ background: 'var(--accent)', color: '#fff', opacity: loading ? 0.7 : 1 }}
|
style={{ background: 'var(--accent)', color: '#fff', opacity: loading ? 0.7 : 1 }}
|
||||||
>
|
>
|
||||||
{loading ? '验证中…' : '验证'}
|
{loading ? '验证中…' : '验证'}
|
||||||
</button>
|
</button>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setStep('enter-email'); setError(''); setCode('') }}
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color: 'var(--text-faint)' }}
|
||||||
|
>
|
||||||
|
重新输入邮箱
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResend}
|
||||||
|
disabled={countdown > 0 || loading}
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color: countdown > 0 ? 'var(--text-faint)' : 'var(--accent)', cursor: countdown > 0 ? 'default' : 'pointer' }}
|
||||||
|
>
|
||||||
|
{countdown > 0 ? `${countdown}s 后重发` : '重新发送'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useRef } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { X, CheckCircle, Mail, Camera, Key, LogOut, Cloud } from 'lucide-react'
|
import { X, CheckCircle, Mail, Camera, Key, LogOut, Cloud } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
apiUpdateProfile, apiChangePassword,
|
apiUpdateProfile, apiChangePassword,
|
||||||
@ -30,6 +30,8 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
|
|||||||
const [emailCode, setEmailCode] = useState('')
|
const [emailCode, setEmailCode] = useState('')
|
||||||
const [emailLoading, setEmailLoading] = useState(false)
|
const [emailLoading, setEmailLoading] = useState(false)
|
||||||
const [emailError, setEmailError] = useState('')
|
const [emailError, setEmailError] = useState('')
|
||||||
|
const [resendCountdown, setResendCountdown] = useState(0)
|
||||||
|
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
// 修改密码
|
// 修改密码
|
||||||
const [oldPwd, setOldPwd] = useState('')
|
const [oldPwd, setOldPwd] = useState('')
|
||||||
@ -38,6 +40,18 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
|
|||||||
const [pwdError, setPwdError] = useState('')
|
const [pwdError, setPwdError] = useState('')
|
||||||
const [pwdOk, setPwdOk] = useState(false)
|
const [pwdOk, setPwdOk] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => () => { if (countdownRef.current) clearInterval(countdownRef.current) }, [])
|
||||||
|
|
||||||
|
function startCountdown() {
|
||||||
|
setResendCountdown(60)
|
||||||
|
countdownRef.current = setInterval(() => {
|
||||||
|
setResendCountdown(n => {
|
||||||
|
if (n <= 1) { clearInterval(countdownRef.current!); return 0 }
|
||||||
|
return n - 1
|
||||||
|
})
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
const inputStyle: React.CSSProperties = {
|
||||||
background: 'var(--bg-muted)',
|
background: 'var(--bg-muted)',
|
||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
@ -102,6 +116,20 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
|
|||||||
try {
|
try {
|
||||||
await apiSendEmailVerify(email)
|
await apiSendEmailVerify(email)
|
||||||
setSection('email-code')
|
setSection('email-code')
|
||||||
|
startCountdown()
|
||||||
|
} catch (err: any) {
|
||||||
|
setEmailError(err.message)
|
||||||
|
} finally {
|
||||||
|
setEmailLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResendEmailCode() {
|
||||||
|
setEmailError('')
|
||||||
|
setEmailLoading(true)
|
||||||
|
try {
|
||||||
|
await apiSendEmailVerify(email)
|
||||||
|
startCountdown()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setEmailError(err.message)
|
setEmailError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
@ -369,7 +397,7 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
|
|||||||
{section === 'email-code' && (
|
{section === 'email-code' && (
|
||||||
<form onSubmit={handleVerifyEmail} className="flex flex-col gap-3">
|
<form onSubmit={handleVerifyEmail} className="flex flex-col gap-3">
|
||||||
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>
|
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>
|
||||||
验证码已发送至 {email || currentUser?.email},10 分钟内有效
|
验证码已发送至 {email},10 分钟内有效
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
@ -388,6 +416,15 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
|
|||||||
>
|
>
|
||||||
{emailLoading ? '验证中…' : '验证'}
|
{emailLoading ? '验证中…' : '验证'}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResendEmailCode}
|
||||||
|
disabled={resendCountdown > 0 || emailLoading}
|
||||||
|
className="text-xs text-center"
|
||||||
|
style={{ color: resendCountdown > 0 ? 'var(--text-faint)' : 'var(--accent)', cursor: resendCountdown > 0 ? 'default' : 'pointer' }}
|
||||||
|
>
|
||||||
|
{resendCountdown > 0 ? `${resendCountdown} 秒后可重新发送` : '重新发送验证码'}
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { db, type Note, type Folder } from '../db'
|
import { db, type Note, type Folder } from '../db'
|
||||||
import { generateId, extractTextFromJSON } from '../lib/utils'
|
import { generateId, extractTextFromJSON } from '../lib/utils'
|
||||||
import { getToken, clearToken, parseToken, type CurrentUser } from '../lib/auth'
|
import { getToken, clearToken, parseToken, apiGetMe, type CurrentUser } from '../lib/auth'
|
||||||
import { pullAll, pushAll, pushNote, pushFolder, pushDeleteNote, pushDeleteFolder, mergeNotes, mergeFolders } from '../lib/sync'
|
import { pullAll, pushAll, pushNote, pushFolder, pushDeleteNote, pushDeleteFolder, mergeNotes, mergeFolders } from '../lib/sync'
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
@ -49,6 +49,7 @@ interface AppState {
|
|||||||
syncStatus: 'idle' | 'syncing' | 'error'
|
syncStatus: 'idle' | 'syncing' | 'error'
|
||||||
setCurrentUser: (user: CurrentUser | null) => void
|
setCurrentUser: (user: CurrentUser | null) => void
|
||||||
logout: () => void
|
logout: () => void
|
||||||
|
refreshCurrentUser: () => Promise<void>
|
||||||
syncFromCloud: () => Promise<void>
|
syncFromCloud: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -264,6 +265,20 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|||||||
set({ currentUser: null, syncStatus: 'idle' })
|
set({ currentUser: null, syncStatus: 'idle' })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
refreshCurrentUser: async () => {
|
||||||
|
const token = getToken()
|
||||||
|
if (!token) return
|
||||||
|
try {
|
||||||
|
const user = await apiGetMe()
|
||||||
|
set({ currentUser: user })
|
||||||
|
if (user.cloudEnabled) get().syncFromCloud()
|
||||||
|
} catch {
|
||||||
|
// token 失效则清除
|
||||||
|
clearToken()
|
||||||
|
set({ currentUser: null })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
syncFromCloud: async () => {
|
syncFromCloud: async () => {
|
||||||
const { notes, folders, currentUser } = get()
|
const { notes, folders, currentUser } = get()
|
||||||
if (!currentUser?.cloudEnabled) return
|
if (!currentUser?.cloudEnabled) return
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user