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:
parent
f2681cb3de
commit
b864b2903a
21
package-lock.json
generated
21
package-lock.json
generated
@ -55,6 +55,7 @@
|
|||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.3.0",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"mammoth": "^1.12.0",
|
"mammoth": "^1.12.0",
|
||||||
|
"nodemailer": "^8.0.7",
|
||||||
"pdfjs-dist": "^5.7.284",
|
"pdfjs-dist": "^5.7.284",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
@ -70,6 +71,7 @@
|
|||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^24.12.2",
|
"@types/node": "^24.12.2",
|
||||||
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
@ -4115,6 +4117,16 @@
|
|||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/nodemailer": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
@ -8132,6 +8144,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "8.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",
|
||||||
|
"integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/obug": {
|
"node_modules/obug": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||||
|
|||||||
@ -61,6 +61,7 @@
|
|||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.3.0",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"mammoth": "^1.12.0",
|
"mammoth": "^1.12.0",
|
||||||
|
"nodemailer": "^8.0.7",
|
||||||
"pdfjs-dist": "^5.7.284",
|
"pdfjs-dist": "^5.7.284",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
@ -76,6 +77,7 @@
|
|||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^24.12.2",
|
"@types/node": "^24.12.2",
|
||||||
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
|||||||
36
server/db.ts
36
server/db.ts
@ -14,11 +14,19 @@ sqlite.pragma('foreign_keys = ON')
|
|||||||
export const db = drizzle(sqlite)
|
export const db = drizzle(sqlite)
|
||||||
|
|
||||||
export const users = sqliteTable('users', {
|
export const users = sqliteTable('users', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
username: text('username').notNull().unique(),
|
username: text('username').notNull().unique(),
|
||||||
passwordHash: text('password_hash').notNull(),
|
passwordHash: text('password_hash').notNull(),
|
||||||
cloudEnabled: integer('cloud_enabled', { mode: 'boolean' }).notNull().default(false),
|
cloudEnabled: integer('cloud_enabled', { mode: 'boolean' }).notNull().default(false),
|
||||||
createdAt: integer('created_at').notNull(),
|
nickname: text('nickname'),
|
||||||
|
avatar: text('avatar'),
|
||||||
|
email: text('email'),
|
||||||
|
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
emailVerifyCode: text('email_verify_code'),
|
||||||
|
emailVerifyExpiry: integer('email_verify_expiry'),
|
||||||
|
resetCode: text('reset_code'),
|
||||||
|
resetCodeExpiry: integer('reset_code_expiry'),
|
||||||
|
createdAt: integer('created_at').notNull(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const notes = sqliteTable('notes', {
|
export const notes = sqliteTable('notes', {
|
||||||
@ -64,6 +72,12 @@ export function initDb() {
|
|||||||
username TEXT UNIQUE NOT NULL,
|
username TEXT UNIQUE NOT NULL,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
cloud_enabled INTEGER NOT NULL DEFAULT 0,
|
cloud_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
email TEXT,
|
||||||
|
email_verified INTEGER NOT NULL DEFAULT 0,
|
||||||
|
email_verify_code TEXT,
|
||||||
|
email_verify_expiry INTEGER,
|
||||||
|
reset_code TEXT,
|
||||||
|
reset_code_expiry INTEGER,
|
||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS notes (
|
CREATE TABLE IF NOT EXISTS notes (
|
||||||
@ -108,4 +122,16 @@ export function initDb() {
|
|||||||
]
|
]
|
||||||
const insert = sqlite.prepare(`INSERT OR IGNORE INTO invite_codes (code) VALUES (?)`)
|
const insert = sqlite.prepare(`INSERT OR IGNORE INTO invite_codes (code) VALUES (?)`)
|
||||||
for (const code of INVITE_CODES) insert.run(code)
|
for (const code of INVITE_CODES) insert.run(code)
|
||||||
|
|
||||||
|
// 迁移:为已有数据库添加邮箱相关字段
|
||||||
|
const cols = sqlite.prepare(`PRAGMA table_info(users)`).all() as { name: string }[]
|
||||||
|
const colNames = cols.map(c => c.name)
|
||||||
|
if (!colNames.includes('email')) sqlite.exec(`ALTER TABLE users ADD COLUMN email TEXT`)
|
||||||
|
if (!colNames.includes('email_verified')) sqlite.exec(`ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0`)
|
||||||
|
if (!colNames.includes('email_verify_code')) sqlite.exec(`ALTER TABLE users ADD COLUMN email_verify_code TEXT`)
|
||||||
|
if (!colNames.includes('email_verify_expiry')) sqlite.exec(`ALTER TABLE users ADD COLUMN email_verify_expiry INTEGER`)
|
||||||
|
if (!colNames.includes('reset_code')) sqlite.exec(`ALTER TABLE users ADD COLUMN reset_code TEXT`)
|
||||||
|
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('avatar')) sqlite.exec(`ALTER TABLE users ADD COLUMN avatar TEXT`)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import 'dotenv/config'
|
import dotenv from 'dotenv'
|
||||||
|
import path from 'node:path'
|
||||||
|
dotenv.config({ path: path.resolve(process.cwd(), '.env') })
|
||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { serve } from '@hono/node-server'
|
import { serve } from '@hono/node-server'
|
||||||
import { cors } from 'hono/cors'
|
import { cors } from 'hono/cors'
|
||||||
import Anthropic from '@anthropic-ai/sdk'
|
import Anthropic from '@anthropic-ai/sdk'
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import path from 'node:path'
|
|
||||||
import { initDb } from './db'
|
import { initDb } from './db'
|
||||||
import { authRouter } from './routes/auth'
|
import { authRouter } from './routes/auth'
|
||||||
import { notesRouter } from './routes/notes'
|
import { notesRouter } from './routes/notes'
|
||||||
|
|||||||
31
server/lib/email.ts
Normal file
31
server/lib/email.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import nodemailer from 'nodemailer'
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: 'smtp.126.com',
|
||||||
|
port: 465,
|
||||||
|
secure: true,
|
||||||
|
auth: {
|
||||||
|
user: 'mikivl@126.com',
|
||||||
|
pass: process.env.EMAIL_PASS!,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function sendVerifyCode(to: string, code: string) {
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: '"MikiVL 笔记" <mikivl@126.com>',
|
||||||
|
to,
|
||||||
|
subject: '邮箱验证码',
|
||||||
|
text: `你的验证码是:${code},10 分钟内有效。`,
|
||||||
|
html: `<p style="font-family:sans-serif">你的验证码是:<strong style="font-size:1.2em;letter-spacing:0.1em">${code}</strong>,10 分钟内有效。</p>`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendResetCode(to: string, code: string) {
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: '"MikiVL 笔记" <mikivl@126.com>',
|
||||||
|
to,
|
||||||
|
subject: '重置密码验证码',
|
||||||
|
text: `你的重置密码验证码是:${code},10 分钟内有效。如非本人操作请忽略。`,
|
||||||
|
html: `<p style="font-family:sans-serif">你的重置密码验证码是:<strong style="font-size:1.2em;letter-spacing:0.1em">${code}</strong>,10 分钟内有效。如非本人操作请忽略。</p>`,
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import jwt from 'jsonwebtoken'
|
|||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import { db, users, inviteCodes } from '../db'
|
import { db, users, inviteCodes } from '../db'
|
||||||
import { requireAuth } from '../middleware/auth'
|
import { requireAuth } from '../middleware/auth'
|
||||||
|
import { sendVerifyCode, sendResetCode } from '../lib/email'
|
||||||
|
|
||||||
export const authRouter = new Hono()
|
export const authRouter = new Hono()
|
||||||
|
|
||||||
@ -11,6 +12,10 @@ function nanoid() {
|
|||||||
return Math.random().toString(36).slice(2, 11) + Date.now().toString(36)
|
return Math.random().toString(36).slice(2, 11) + Date.now().toString(36)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function randomCode() {
|
||||||
|
return String(Math.floor(100000 + Math.random() * 900000))
|
||||||
|
}
|
||||||
|
|
||||||
authRouter.post('/register', async (c) => {
|
authRouter.post('/register', async (c) => {
|
||||||
const { username, password } = await c.req.json<{ username: string; password: string }>()
|
const { username, password } = await c.req.json<{ username: string; password: string }>()
|
||||||
if (!username || !password) return c.json({ error: '用户名和密码不能为空' }, 400)
|
if (!username || !password) return c.json({ error: '用户名和密码不能为空' }, 400)
|
||||||
@ -25,7 +30,7 @@ authRouter.post('/register', async (c) => {
|
|||||||
db.insert(users).values({ id, username, passwordHash, cloudEnabled: false, createdAt: Date.now() }).run()
|
db.insert(users).values({ id, username, passwordHash, cloudEnabled: false, createdAt: Date.now() }).run()
|
||||||
|
|
||||||
const token = jwt.sign({ userId: id, username }, process.env.JWT_SECRET!, { expiresIn: '30d' })
|
const token = jwt.sign({ userId: id, username }, process.env.JWT_SECRET!, { expiresIn: '30d' })
|
||||||
return c.json({ token, user: { id, username, cloudEnabled: false } })
|
return c.json({ token, user: { id, username, cloudEnabled: false, email: null, emailVerified: false, nickname: null, avatar: null } })
|
||||||
})
|
})
|
||||||
|
|
||||||
authRouter.post('/login', async (c) => {
|
authRouter.post('/login', async (c) => {
|
||||||
@ -39,7 +44,7 @@ authRouter.post('/login', async (c) => {
|
|||||||
if (!ok) return c.json({ error: '用户名或密码错误' }, 401)
|
if (!ok) return c.json({ error: '用户名或密码错误' }, 401)
|
||||||
|
|
||||||
const token = jwt.sign({ userId: user.id, username: user.username }, process.env.JWT_SECRET!, { expiresIn: '30d' })
|
const token = jwt.sign({ userId: user.id, username: user.username }, process.env.JWT_SECRET!, { expiresIn: '30d' })
|
||||||
return c.json({ token, user: { id: user.id, username: user.username, cloudEnabled: user.cloudEnabled } })
|
return c.json({ token, user: { id: user.id, username: user.username, cloudEnabled: user.cloudEnabled, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname ?? null, avatar: user.avatar ?? null } })
|
||||||
})
|
})
|
||||||
|
|
||||||
authRouter.post('/activate', requireAuth, async (c) => {
|
authRouter.post('/activate', requireAuth, async (c) => {
|
||||||
@ -56,3 +61,123 @@ authRouter.post('/activate', requireAuth, async (c) => {
|
|||||||
|
|
||||||
return c.json({ success: true })
|
return c.json({ success: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 获取当前用户信息
|
||||||
|
authRouter.get('/me', requireAuth, async (c) => {
|
||||||
|
const userId = c.get('userId')
|
||||||
|
const [user] = db.select().from(users).where(eq(users.id, userId)).all()
|
||||||
|
if (!user) return c.json({ error: '用户不存在' }, 404)
|
||||||
|
return c.json({ id: user.id, username: user.username, cloudEnabled: user.cloudEnabled, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname ?? null, avatar: user.avatar ?? null })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新个人资料(昵称、头像)
|
||||||
|
authRouter.put('/me', requireAuth, async (c) => {
|
||||||
|
const userId = c.get('userId')
|
||||||
|
const { nickname, avatar } = await c.req.json<{ nickname?: string; avatar?: string }>()
|
||||||
|
const updates: Record<string, any> = {}
|
||||||
|
if (nickname !== undefined) {
|
||||||
|
if (nickname.length > 20) return c.json({ error: '昵称不超过 20 字' }, 400)
|
||||||
|
updates.nickname = nickname.trim() || null
|
||||||
|
}
|
||||||
|
if (avatar !== undefined) {
|
||||||
|
// 限制头像大小(base64 约 100KB)
|
||||||
|
if (avatar && avatar.length > 150000) return c.json({ error: '头像不能超过 100KB' }, 400)
|
||||||
|
updates.avatar = avatar || null
|
||||||
|
}
|
||||||
|
if (Object.keys(updates).length === 0) return c.json({ error: '无更新内容' }, 400)
|
||||||
|
db.update(users).set(updates).where(eq(users.id, userId)).run()
|
||||||
|
const [user] = db.select().from(users).where(eq(users.id, userId)).all()
|
||||||
|
return c.json({ id: user.id, username: user.username, cloudEnabled: user.cloudEnabled, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname ?? null, avatar: user.avatar ?? null })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 发送邮箱绑定验证码
|
||||||
|
authRouter.post('/email/send-verify', requireAuth, async (c) => {
|
||||||
|
const userId = c.get('userId')
|
||||||
|
const { email } = await c.req.json<{ email: string }>()
|
||||||
|
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return c.json({ error: '邮箱格式不正确' }, 400)
|
||||||
|
|
||||||
|
// 检查邮箱是否已被其他用户绑定
|
||||||
|
const existing = db.select().from(users).where(eq(users.email, email)).all()
|
||||||
|
if (existing.some(u => u.id !== userId)) return c.json({ error: '该邮箱已被其他账号绑定' }, 409)
|
||||||
|
|
||||||
|
const code = randomCode()
|
||||||
|
const expiry = Date.now() + 10 * 60 * 1000
|
||||||
|
db.update(users).set({ email, emailVerifyCode: code, emailVerifyExpiry: expiry, emailVerified: false }).where(eq(users.id, userId)).run()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendVerifyCode(email, code)
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: '邮件发送失败,请稍后再试' }, 500)
|
||||||
|
}
|
||||||
|
return c.json({ success: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 验证邮箱绑定验证码
|
||||||
|
authRouter.post('/email/verify', requireAuth, async (c) => {
|
||||||
|
const userId = c.get('userId')
|
||||||
|
const { code } = await c.req.json<{ code: string }>()
|
||||||
|
if (!code) return c.json({ error: '请输入验证码' }, 400)
|
||||||
|
|
||||||
|
const [user] = db.select().from(users).where(eq(users.id, userId)).all()
|
||||||
|
if (!user) return c.json({ error: '用户不存在' }, 404)
|
||||||
|
if (!user.emailVerifyCode || user.emailVerifyCode !== code.trim()) 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()
|
||||||
|
return c.json({ success: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 忘记密码:发送重置验证码
|
||||||
|
authRouter.post('/forgot-password', async (c) => {
|
||||||
|
const { email } = await c.req.json<{ email: string }>()
|
||||||
|
if (!email) return c.json({ error: '请输入邮箱' }, 400)
|
||||||
|
|
||||||
|
const [user] = db.select().from(users).where(eq(users.email, email)).all()
|
||||||
|
// 无论是否找到用户都返回成功,避免枚举攻击
|
||||||
|
if (!user || !user.emailVerified) return c.json({ success: true })
|
||||||
|
|
||||||
|
const code = randomCode()
|
||||||
|
const expiry = Date.now() + 10 * 60 * 1000
|
||||||
|
db.update(users).set({ resetCode: code, resetCodeExpiry: expiry }).where(eq(users.id, user.id)).run()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendResetCode(email, code)
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: '邮件发送失败,请稍后再试' }, 500)
|
||||||
|
}
|
||||||
|
return c.json({ success: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 重置密码:验证码 + 新密码
|
||||||
|
authRouter.post('/reset-password', async (c) => {
|
||||||
|
const { email, code, newPassword } = await c.req.json<{ email: string; code: string; newPassword: string }>()
|
||||||
|
if (!email || !code || !newPassword) return c.json({ error: '参数不完整' }, 400)
|
||||||
|
if (newPassword.length < 6) return c.json({ error: '新密码至少 6 位' }, 400)
|
||||||
|
|
||||||
|
const [user] = db.select().from(users).where(eq(users.email, email)).all()
|
||||||
|
if (!user) return c.json({ error: '邮箱未绑定任何账号' }, 404)
|
||||||
|
if (!user.resetCode || user.resetCode !== code.trim()) return c.json({ error: '验证码错误' }, 400)
|
||||||
|
if (!user.resetCodeExpiry || Date.now() > user.resetCodeExpiry) return c.json({ error: '验证码已过期,请重新获取' }, 400)
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(newPassword, 10)
|
||||||
|
db.update(users).set({ passwordHash, resetCode: null, resetCodeExpiry: null }).where(eq(users.id, user.id)).run()
|
||||||
|
return c.json({ success: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 修改密码(需要旧密码)
|
||||||
|
authRouter.post('/change-password', requireAuth, async (c) => {
|
||||||
|
const userId = c.get('userId')
|
||||||
|
const { oldPassword, newPassword } = await c.req.json<{ oldPassword: string; newPassword: string }>()
|
||||||
|
if (!oldPassword || !newPassword) return c.json({ error: '参数不完整' }, 400)
|
||||||
|
if (newPassword.length < 6) return c.json({ error: '新密码至少 6 位' }, 400)
|
||||||
|
|
||||||
|
const [user] = db.select().from(users).where(eq(users.id, userId)).all()
|
||||||
|
if (!user) return c.json({ error: '用户不存在' }, 404)
|
||||||
|
const ok = await bcrypt.compare(oldPassword, user.passwordHash)
|
||||||
|
if (!ok) return c.json({ error: '当前密码错误' }, 400)
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(newPassword, 10)
|
||||||
|
db.update(users).set({ passwordHash }).where(eq(users.id, userId)).run()
|
||||||
|
return c.json({ success: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
|||||||
209
src/components/auth/AccountModal.tsx
Normal file
209
src/components/auth/AccountModal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,9 +1,11 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { X } from 'lucide-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'
|
import { useAppStore } from '../../stores/appStore'
|
||||||
|
|
||||||
type Tab = 'login' | 'register' | 'activate'
|
type Tab = 'login' | 'register' | 'activate'
|
||||||
|
// 忘记密码分两步:输入邮箱 → 输入验证码+新密码
|
||||||
|
type ForgotStep = 'email' | 'reset'
|
||||||
|
|
||||||
export function LoginModal({ onClose, initialTab }: { onClose: () => void; initialTab?: Tab }) {
|
export function LoginModal({ onClose, initialTab }: { onClose: () => void; initialTab?: Tab }) {
|
||||||
const [tab, setTab] = useState<Tab>(initialTab ?? 'login')
|
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 [loading, setLoading] = useState(false)
|
||||||
const { setCurrentUser, syncFromCloud, currentUser } = useAppStore()
|
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) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
@ -67,70 +113,163 @@ export function LoginModal({ onClose, initialTab }: { onClose: () => void; initi
|
|||||||
>
|
>
|
||||||
<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 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
|
||||||
{tab === 'login' ? '登录' : tab === 'register' ? '注册' : '激活云存储'}
|
{forgotMode
|
||||||
|
? (forgotStep === 'email' ? '找回密码' : '重置密码')
|
||||||
|
: (tab === 'login' ? '登录' : tab === 'register' ? '注册' : '激活云存储')}
|
||||||
</h2>
|
</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>
|
||||||
|
|
||||||
{tab !== 'activate' && (
|
{/* 忘记密码流程 */}
|
||||||
<div className="flex gap-1 p-1 rounded-lg" style={{ background: 'var(--bg-muted)' }}>
|
{forgotMode ? (
|
||||||
{(['login', 'register'] as Tab[]).map(t => (
|
forgotStep === 'email' ? (
|
||||||
<button
|
<form onSubmit={handleForgotSend} className="flex flex-col gap-3">
|
||||||
key={t}
|
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>
|
||||||
onClick={() => setTab(t)}
|
输入绑定的邮箱,我们将发送验证码
|
||||||
className="flex-1 py-1 rounded text-xs font-medium transition-all"
|
</p>
|
||||||
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' ? (
|
|
||||||
<>
|
|
||||||
<input
|
<input
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
placeholder="用户名"
|
type="email"
|
||||||
value={username}
|
placeholder="邮箱地址"
|
||||||
onChange={e => setUsername(e.target.value)}
|
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
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="密码(至少 6 位)"
|
placeholder="新密码(至少 6 位)"
|
||||||
value={password}
|
value={forgotPassword}
|
||||||
onChange={e => setPassword(e.target.value)}
|
onChange={e => setForgotPassword(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</>
|
{error && <p className="text-xs" style={{ color: '#ef4444' }}>{error}</p>}
|
||||||
) : (
|
<div className="flex gap-2">
|
||||||
<input
|
<button
|
||||||
style={inputStyle}
|
type="button"
|
||||||
placeholder="邀请码(如 MIKI-A7X2-KP9Q)"
|
onClick={() => { setForgotStep('email'); setError('') }}
|
||||||
value={code}
|
className="flex-1 py-2 rounded-lg text-sm"
|
||||||
onChange={e => setCode(e.target.value)}
|
style={{ background: 'var(--bg-muted)', color: 'var(--text-faint)' }}
|
||||||
autoFocus
|
>
|
||||||
/>
|
返回
|
||||||
)}
|
</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
|
{error && <p className="text-xs" style={{ color: '#ef4444' }}>{error}</p>}
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
<button
|
||||||
className="w-full py-2 rounded-lg text-sm font-medium"
|
type="submit"
|
||||||
style={{ background: 'var(--accent)', color: '#fff', opacity: loading ? 0.7 : 1 }}
|
disabled={loading}
|
||||||
>
|
className="w-full py-2 rounded-lg text-sm font-medium"
|
||||||
{loading ? '请稍候…' : tab === 'login' ? '登录' : tab === 'register' ? '注册' : '激活'}
|
style={{ background: 'var(--accent)', color: '#fff', opacity: loading ? 0.7 : 1 }}
|
||||||
</button>
|
>
|
||||||
</form>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
427
src/components/auth/ProfileModal.tsx
Normal file
427
src/components/auth/ProfileModal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,12 +1,13 @@
|
|||||||
import { useState } from 'react'
|
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 { useAppStore } from '../../stores/appStore'
|
||||||
import { LoginModal } from './LoginModal'
|
import { LoginModal } from './LoginModal'
|
||||||
|
import { ProfileModal } from './ProfileModal'
|
||||||
|
|
||||||
export function UserMenu() {
|
export function UserMenu() {
|
||||||
const { currentUser, logout, syncStatus, syncFromCloud } = useAppStore()
|
const { currentUser, syncStatus, syncFromCloud } = useAppStore()
|
||||||
const [showLogin, setShowLogin] = useState(false)
|
const [showLogin, setShowLogin] = useState(false)
|
||||||
const [showActivate, setShowActivate] = useState(false)
|
const [showProfile, setShowProfile] = useState(false)
|
||||||
|
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
return (
|
return (
|
||||||
@ -26,32 +27,32 @@ export function UserMenu() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const displayName = currentUser.nickname || currentUser.username
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="px-3 py-2 flex flex-col gap-1">
|
<div className="px-3 py-2 flex flex-col gap-1">
|
||||||
<div className="flex items-center justify-between">
|
<button
|
||||||
<div className="flex items-center gap-2">
|
onClick={() => setShowProfile(true)}
|
||||||
<div
|
className="flex items-center gap-2 w-full text-left rounded-lg transition-all"
|
||||||
className="w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0"
|
style={{ background: 'transparent' }}
|
||||||
style={{ background: 'var(--accent)', color: '#fff' }}
|
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-muted)')}
|
||||||
>
|
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||||
{currentUser.username[0].toUpperCase()}
|
>
|
||||||
</div>
|
<div
|
||||||
<span className="text-xs font-medium truncate" style={{ color: 'var(--text)' }}>
|
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 overflow-hidden"
|
||||||
{currentUser.username}
|
style={{ background: 'var(--accent)', color: '#fff' }}
|
||||||
</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)')}
|
|
||||||
>
|
>
|
||||||
<LogOut size={12} />
|
{currentUser.avatar ? (
|
||||||
</button>
|
<img src={currentUser.avatar} alt="头像" className="w-full h-full object-cover" />
|
||||||
</div>
|
) : (
|
||||||
|
displayName[0]?.toUpperCase()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium truncate" style={{ color: 'var(--text)' }}>
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
{currentUser.cloudEnabled ? (
|
{currentUser.cloudEnabled ? (
|
||||||
<button
|
<button
|
||||||
@ -65,7 +66,7 @@ export function UserMenu() {
|
|||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowActivate(true)}
|
onClick={() => setShowProfile(true)}
|
||||||
className="flex items-center gap-1.5 text-xs"
|
className="flex items-center gap-1.5 text-xs"
|
||||||
style={{ color: 'var(--text-faint)' }}
|
style={{ color: 'var(--text-faint)' }}
|
||||||
onMouseEnter={e => (e.currentTarget.style.color = 'var(--accent)')}
|
onMouseEnter={e => (e.currentTarget.style.color = 'var(--accent)')}
|
||||||
@ -77,9 +78,7 @@ export function UserMenu() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showActivate && (
|
{showProfile && <ProfileModal onClose={() => setShowProfile(false)} />}
|
||||||
<LoginModal initialTab="activate" onClose={() => setShowActivate(false)} />
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
const TOKEN_KEY = 'mikivl_token'
|
const TOKEN_KEY = 'mikivl_token'
|
||||||
const API = '/api'
|
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 {
|
export function getToken(): string | null {
|
||||||
return localStorage.getItem(TOKEN_KEY)
|
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 ?? '激活失败')
|
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 {
|
export function parseToken(token: string): CurrentUser | null {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(atob(token.split('.')[1]))
|
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 {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user