feat: 侧边栏专属主页、邮箱认证、管理后台等多项功能
- 所有笔记/收藏/回收站各自展示专属主页(统计、列表、操作入口) - 新增邮箱绑定、找回密码、个人中心功能 - 新增管理员路由与后台组件 - AI 模型设置、登录弹窗、侧边栏交互优化 - 修复懒加载、cloudEnabled 刷新、验证码冷却等 bug Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
1440ce066f
commit
b404a81003
@@ -18,6 +18,8 @@ export const users = sqliteTable('users', {
|
||||
username: text('username').notNull().unique(),
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
cloudEnabled: integer('cloud_enabled', { mode: 'boolean' }).notNull().default(false),
|
||||
role: text('role').notNull().default('user'),
|
||||
banned: integer('banned', { mode: 'boolean' }).notNull().default(false),
|
||||
nickname: text('nickname'),
|
||||
avatar: text('avatar'),
|
||||
email: text('email'),
|
||||
@@ -66,6 +68,11 @@ export const inviteCodes = sqliteTable('invite_codes', {
|
||||
usedAt: integer('used_at'),
|
||||
})
|
||||
|
||||
export const siteContent = sqliteTable('site_content', {
|
||||
key: text('key').primaryKey(),
|
||||
value: text('value').notNull(),
|
||||
})
|
||||
|
||||
export function initDb() {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
@@ -136,4 +143,32 @@ export function initDb() {
|
||||
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('pending_email')) sqlite.exec(`ALTER TABLE users ADD COLUMN pending_email TEXT`)
|
||||
if (!colNames.includes('role')) sqlite.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`)
|
||||
if (!colNames.includes('banned')) sqlite.exec(`ALTER TABLE users ADD COLUMN banned INTEGER NOT NULL DEFAULT 0`)
|
||||
|
||||
// 将 MikiVL 用户设为管理员
|
||||
sqlite.exec(`UPDATE users SET role = 'admin' WHERE username = 'MikiVL'`)
|
||||
|
||||
// 创建站点内容表
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS site_content (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`)
|
||||
|
||||
// 预设站点内容默认值
|
||||
const siteDefaults = [
|
||||
['tagline', '探索·创造·分享'],
|
||||
['subtitle', '个人项目与技术探索的集合地'],
|
||||
['news', JSON.stringify([
|
||||
{ date: '2025-06', text: '开放笔记系统公测' },
|
||||
{ date: '2025-03', text: '网站正式上线' },
|
||||
])],
|
||||
['projects', JSON.stringify([
|
||||
{ title: 'MikiNote', desc: '基于 AI 的个人笔记系统,支持云同步', link: 'https://note.mikivl.online/app/' },
|
||||
])],
|
||||
]
|
||||
const insertContent = sqlite.prepare(`INSERT OR IGNORE INTO site_content (key, value) VALUES (?, ?)`)
|
||||
for (const [key, value] of siteDefaults) insertContent.run(key, value)
|
||||
}
|
||||
|
||||
+21
-11
@@ -6,11 +6,12 @@ import { serve } from '@hono/node-server'
|
||||
import { cors } from 'hono/cors'
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import fs from 'node:fs'
|
||||
import { initDb } from './db'
|
||||
import { initDb, db, siteContent } from './db'
|
||||
import { authRouter } from './routes/auth'
|
||||
import { notesRouter } from './routes/notes'
|
||||
import { foldersRouter } from './routes/folders'
|
||||
import { commentsRouter } from './routes/comments'
|
||||
import { adminRouter } from './routes/admin'
|
||||
|
||||
const app = new Hono()
|
||||
app.use('*', cors())
|
||||
@@ -65,17 +66,26 @@ function getActiveModel(): string {
|
||||
|
||||
// ── Model CRUD endpoints ──────────────────────────────────────────────────────
|
||||
app.route('/api/auth', authRouter)
|
||||
app.route('/api/notes', notesRouter)
|
||||
app.route('/api/folders', foldersRouter)
|
||||
app.route('/api/comments', commentsRouter)
|
||||
app.route('/api/admin', adminRouter)
|
||||
app.route('/api/note/notes', notesRouter)
|
||||
app.route('/api/note/folders', foldersRouter)
|
||||
app.route('/api/note/comments', commentsRouter)
|
||||
|
||||
app.get('/api/models', (c) => {
|
||||
// 公共站点内容接口(无需认证)
|
||||
app.get('/api/site-content', (c) => {
|
||||
const rows = db.select().from(siteContent).all()
|
||||
const result: Record<string, string> = {}
|
||||
for (const row of rows) result[row.key] = row.value
|
||||
return c.json(result)
|
||||
})
|
||||
|
||||
app.get('/api/note/models', (c) => {
|
||||
const models = readModels()
|
||||
// strip apiKey from response for security
|
||||
return c.json(models.map(({ apiKey: _, ...rest }) => rest))
|
||||
})
|
||||
|
||||
app.post('/api/models', async (c) => {
|
||||
app.post('/api/note/models', async (c) => {
|
||||
const body = await c.req.json<{ name: string; apiKey: string; baseURL: string; modelId?: string }>()
|
||||
const models = readModels()
|
||||
const newModel: ModelConfig = {
|
||||
@@ -92,7 +102,7 @@ app.post('/api/models', async (c) => {
|
||||
return c.json(safe)
|
||||
})
|
||||
|
||||
app.patch('/api/models/:id/activate', (c) => {
|
||||
app.patch('/api/note/models/:id/activate', (c) => {
|
||||
const { id } = c.req.param()
|
||||
const models = readModels()
|
||||
models.forEach(m => { m.isActive = m.id === id })
|
||||
@@ -100,7 +110,7 @@ app.patch('/api/models/:id/activate', (c) => {
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
|
||||
app.delete('/api/models/:id', (c) => {
|
||||
app.delete('/api/note/models/:id', (c) => {
|
||||
const { id } = c.req.param()
|
||||
let models = readModels()
|
||||
const target = models.find(m => m.id === id)
|
||||
@@ -157,7 +167,7 @@ function buildSystemPrompt(type: AIStreamRequest['type'], noteContent: string):
|
||||
}
|
||||
|
||||
// ── Stream endpoint ───────────────────────────────────────────────────────────
|
||||
app.post('/api/ai/stream', async (c) => {
|
||||
app.post('/api/note/ai/stream', async (c) => {
|
||||
const req = await c.req.json<AIStreamRequest>()
|
||||
const aiClient = getActiveClient()
|
||||
const modelId = getActiveModel()
|
||||
@@ -204,6 +214,6 @@ app.post('/api/ai/stream', async (c) => {
|
||||
|
||||
initDb()
|
||||
|
||||
serve({ fetch: app.fetch, port: 3001 }, () => {
|
||||
console.log('AI proxy server running on http://localhost:3001')
|
||||
serve({ fetch: app.fetch, port: Number(process.env.PORT ?? 3003) }, () => {
|
||||
console.log(`AI proxy server running on http://localhost:${process.env.PORT ?? 3003}`)
|
||||
})
|
||||
|
||||
@@ -31,3 +31,13 @@ export async function sendResetCode(to: string, code: string) {
|
||||
html: `<p style="font-family:sans-serif">你的重置密码验证码是:<strong style="font-size:1.2em;letter-spacing:0.1em">${code}</strong>,10 分钟内有效。如非本人操作请忽略。</p>`,
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendRegisterCode(to: string, code: string) {
|
||||
await createTransporter().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>`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createMiddleware } from 'hono/factory'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { db, users } from '../db'
|
||||
import { eq } from 'drizzle-orm'
|
||||
|
||||
export type JwtPayload = { userId: string; username: string }
|
||||
|
||||
@@ -25,3 +27,21 @@ export const requireAuth = createMiddleware(async (c, next) => {
|
||||
return c.json({ error: 'Token 无效或已过期' }, 401)
|
||||
}
|
||||
})
|
||||
|
||||
export const requireAdmin = createMiddleware(async (c, next) => {
|
||||
const header = c.req.header('Authorization')
|
||||
if (!header?.startsWith('Bearer ')) {
|
||||
return c.json({ error: '未登录' }, 401)
|
||||
}
|
||||
const token = header.slice(7)
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload
|
||||
const [user] = db.select({ role: users.role }).from(users).where(eq(users.id, payload.userId)).all()
|
||||
if (!user || user.role !== 'admin') return c.json({ error: '权限不足' }, 403)
|
||||
c.set('userId', payload.userId)
|
||||
c.set('username', payload.username)
|
||||
await next()
|
||||
} catch {
|
||||
return c.json({ error: 'Token 无效或已过期' }, 401)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Hono } from 'hono'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { db, users, comments, inviteCodes, siteContent } from '../db'
|
||||
import { requireAdmin } from '../middleware/auth'
|
||||
|
||||
export const adminRouter = new Hono()
|
||||
adminRouter.use('*', requireAdmin)
|
||||
|
||||
function nanoid() {
|
||||
return Math.random().toString(36).slice(2, 11) + Date.now().toString(36)
|
||||
}
|
||||
|
||||
// 站点内容读取
|
||||
adminRouter.get('/site-content', (c) => {
|
||||
const rows = db.select().from(siteContent).all()
|
||||
const result: Record<string, string> = {}
|
||||
for (const row of rows) result[row.key] = row.value
|
||||
return c.json(result)
|
||||
})
|
||||
|
||||
// 站点内容批量更新
|
||||
adminRouter.put('/site-content', async (c) => {
|
||||
const body = await c.req.json<Record<string, string>>()
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
if (typeof value !== 'string') continue
|
||||
db.insert(siteContent).values({ key, value }).onConflictDoUpdate({ target: siteContent.key, set: { value } }).run()
|
||||
}
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
// 用户列表
|
||||
adminRouter.get('/users', (c) => {
|
||||
const rows = db.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
role: users.role,
|
||||
banned: users.banned,
|
||||
cloudEnabled: users.cloudEnabled,
|
||||
email: users.email,
|
||||
createdAt: users.createdAt,
|
||||
}).from(users).all()
|
||||
return c.json(rows)
|
||||
})
|
||||
|
||||
// 封禁用户
|
||||
adminRouter.post('/users/:id/ban', (c) => {
|
||||
const { id } = c.req.param()
|
||||
const [user] = db.select({ role: users.role }).from(users).where(eq(users.id, id)).all()
|
||||
if (!user) return c.json({ error: '用户不存在' }, 404)
|
||||
if (user.role === 'admin') return c.json({ error: '不能封禁管理员' }, 403)
|
||||
db.update(users).set({ banned: true }).where(eq(users.id, id)).run()
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
// 解封用户
|
||||
adminRouter.post('/users/:id/unban', (c) => {
|
||||
const { id } = c.req.param()
|
||||
const [user] = db.select({ id: users.id }).from(users).where(eq(users.id, id)).all()
|
||||
if (!user) return c.json({ error: '用户不存在' }, 404)
|
||||
db.update(users).set({ banned: false }).where(eq(users.id, id)).run()
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
// 评论列表
|
||||
adminRouter.get('/comments', (c) => {
|
||||
const rows = db.select({
|
||||
id: comments.id,
|
||||
content: comments.content,
|
||||
createdAt: comments.createdAt,
|
||||
userId: comments.userId,
|
||||
username: users.username,
|
||||
}).from(comments).leftJoin(users, eq(comments.userId, users.id)).all()
|
||||
return c.json(rows)
|
||||
})
|
||||
|
||||
// 删除评论
|
||||
adminRouter.delete('/comments/:id', (c) => {
|
||||
const { id } = c.req.param()
|
||||
db.delete(comments).where(eq(comments.id, id)).run()
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
// 邀请码列表
|
||||
adminRouter.get('/invite-codes', (c) => {
|
||||
const rows = db.select().from(inviteCodes).all()
|
||||
return c.json(rows)
|
||||
})
|
||||
|
||||
// 生成新邀请码
|
||||
adminRouter.post('/invite-codes', (c) => {
|
||||
const code = 'MIKI-' + nanoid().toUpperCase().slice(0, 4) + '-' + nanoid().toUpperCase().slice(0, 4)
|
||||
db.insert(inviteCodes).values({ code }).run()
|
||||
return c.json({ code })
|
||||
})
|
||||
+73
-13
@@ -4,7 +4,7 @@ import jwt from 'jsonwebtoken'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { db, users, inviteCodes } from '../db'
|
||||
import { requireAuth } from '../middleware/auth'
|
||||
import { sendVerifyCode, sendResetCode } from '../lib/email'
|
||||
import { sendVerifyCode, sendResetCode, sendRegisterCode } from '../lib/email'
|
||||
|
||||
export const authRouter = new Hono()
|
||||
|
||||
@@ -16,21 +16,80 @@ function randomCode() {
|
||||
return String(Math.floor(100000 + Math.random() * 900000))
|
||||
}
|
||||
|
||||
authRouter.post('/register', async (c) => {
|
||||
const { username, password } = await c.req.json<{ username: string; password: string }>()
|
||||
if (!username || !password) return c.json({ error: '用户名和密码不能为空' }, 400)
|
||||
// 待注册信息临时存储(key 为邮箱)
|
||||
const registerPending = new Map<string, {
|
||||
username: string
|
||||
passwordHash: string
|
||||
code: string
|
||||
expiry: number
|
||||
sentAt: number
|
||||
}>()
|
||||
|
||||
// 发送注册验证码
|
||||
authRouter.post('/register/send-code', async (c) => {
|
||||
const { username, password, email } = await c.req.json<{ username: string; password: string; email: string }>()
|
||||
if (!username || !password || !email) return c.json({ error: '请填写所有字段' }, 400)
|
||||
if (username.length < 2 || username.length > 20) return c.json({ error: '用户名长度 2-20 位' }, 400)
|
||||
if (password.length < 6) return c.json({ error: '密码至少 6 位' }, 400)
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return c.json({ error: '邮箱格式不正确' }, 400)
|
||||
|
||||
const existing = db.select().from(users).where(eq(users.username, username)).all()
|
||||
if (existing.length > 0) return c.json({ error: '用户名已存在' }, 409)
|
||||
// 检查用户名是否已存在
|
||||
const existingUser = db.select().from(users).where(eq(users.username, username)).all()
|
||||
if (existingUser.length > 0) return c.json({ error: '用户名已存在' }, 409)
|
||||
|
||||
// 检查邮箱是否已被验证用户使用
|
||||
const existingEmail = db.select().from(users).where(eq(users.email, email)).all()
|
||||
if (existingEmail.some(u => u.emailVerified)) return c.json({ error: '该邮箱已被其他账号绑定' }, 409)
|
||||
|
||||
// 60秒冷却
|
||||
const pending = registerPending.get(email)
|
||||
if (pending && Date.now() - pending.sentAt < 60 * 1000) {
|
||||
return c.json({ error: '请等待 60 秒后再重新发送' }, 429)
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10)
|
||||
const id = nanoid()
|
||||
db.insert(users).values({ id, username, passwordHash, cloudEnabled: false, createdAt: Date.now() }).run()
|
||||
const code = randomCode()
|
||||
registerPending.set(email, { username, passwordHash, code, expiry: Date.now() + 10 * 60 * 1000, sentAt: Date.now() })
|
||||
|
||||
const token = jwt.sign({ userId: id, username }, process.env.JWT_SECRET!, { expiresIn: '30d' })
|
||||
return c.json({ token, user: { id, username, cloudEnabled: false, email: null, emailVerified: false, nickname: null, avatar: null } })
|
||||
try {
|
||||
await sendRegisterCode(email, code)
|
||||
} catch (err) {
|
||||
console.error('[email]', err)
|
||||
registerPending.delete(email)
|
||||
return c.json({ error: '邮件发送失败,请稍后再试' }, 500)
|
||||
}
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
// 完成注册(验证验证码)
|
||||
authRouter.post('/register', async (c) => {
|
||||
const { email, code } = await c.req.json<{ email: string; code: string }>()
|
||||
if (!email || !code) return c.json({ error: '参数不完整' }, 400)
|
||||
|
||||
const pending = registerPending.get(email)
|
||||
if (!pending) return c.json({ error: '请先发送验证码' }, 400)
|
||||
if (Date.now() > pending.expiry) {
|
||||
registerPending.delete(email)
|
||||
return c.json({ error: '验证码已过期,请重新发送' }, 400)
|
||||
}
|
||||
if (pending.code !== code.trim()) return c.json({ error: '验证码错误' }, 400)
|
||||
|
||||
// 再次检查用户名/邮箱唯一性(防止并发)
|
||||
const existingUser = db.select().from(users).where(eq(users.username, pending.username)).all()
|
||||
if (existingUser.length > 0) {
|
||||
registerPending.delete(email)
|
||||
return c.json({ error: '用户名已被注册,请更换用户名重新发送验证码' }, 409)
|
||||
}
|
||||
|
||||
const id = nanoid()
|
||||
db.insert(users).values({
|
||||
id, username: pending.username, passwordHash: pending.passwordHash,
|
||||
email, emailVerified: true, cloudEnabled: false, createdAt: Date.now(),
|
||||
}).run()
|
||||
registerPending.delete(email)
|
||||
|
||||
const token = jwt.sign({ userId: id, username: pending.username }, process.env.JWT_SECRET!, { expiresIn: '30d' })
|
||||
return c.json({ token, user: { id, username: pending.username, cloudEnabled: false, role: 'user', email, emailVerified: true, nickname: null, avatar: null } })
|
||||
})
|
||||
|
||||
authRouter.post('/login', async (c) => {
|
||||
@@ -42,9 +101,10 @@ authRouter.post('/login', async (c) => {
|
||||
|
||||
const ok = await bcrypt.compare(password, user.passwordHash)
|
||||
if (!ok) return c.json({ error: '用户名或密码错误' }, 401)
|
||||
if (user.banned) return c.json({ error: '账号已被封禁,请联系管理员' }, 403)
|
||||
|
||||
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, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname ?? null, avatar: user.avatar ?? null } })
|
||||
return c.json({ token, user: { id: user.id, username: user.username, cloudEnabled: user.cloudEnabled, role: user.role, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname ?? null, avatar: user.avatar ?? null } })
|
||||
})
|
||||
|
||||
authRouter.post('/activate', requireAuth, async (c) => {
|
||||
@@ -67,7 +127,7 @@ 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 })
|
||||
return c.json({ id: user.id, username: user.username, cloudEnabled: user.cloudEnabled, role: user.role, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname ?? null, avatar: user.avatar ?? null })
|
||||
})
|
||||
|
||||
// 更新个人资料(昵称、头像)
|
||||
@@ -87,7 +147,7 @@ authRouter.put('/me', requireAuth, async (c) => {
|
||||
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 })
|
||||
return c.json({ id: user.id, username: user.username, cloudEnabled: user.cloudEnabled, role: user.role, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname ?? null, avatar: user.avatar ?? null })
|
||||
})
|
||||
|
||||
// 发送邮箱绑定验证码
|
||||
|
||||
Reference in New Issue
Block a user