From b404a81003654d08bb2817156e553b563032d391 Mon Sep 17 00:00:00 2001 From: MikiVL Date: Mon, 27 Jul 2026 21:09:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BE=A7=E8=BE=B9=E6=A0=8F=E4=B8=93?= =?UTF-8?q?=E5=B1=9E=E4=B8=BB=E9=A1=B5=E3=80=81=E9=82=AE=E7=AE=B1=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E3=80=81=E7=AE=A1=E7=90=86=E5=90=8E=E5=8F=B0=E7=AD=89?= =?UTF-8?q?=E5=A4=9A=E9=A1=B9=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 所有笔记/收藏/回收站各自展示专属主页(统计、列表、操作入口) - 新增邮箱绑定、找回密码、个人中心功能 - 新增管理员路由与后台组件 - AI 模型设置、登录弹窗、侧边栏交互优化 - 修复懒加载、cloudEnabled 刷新、验证码冷却等 bug Co-Authored-By: Claude Sonnet 4.6 --- public/favicon.svg | 10 +- server/db.ts | 35 +++ server/index.ts | 32 ++- server/lib/email.ts | 10 + server/middleware/auth.ts | 20 ++ server/routes/admin.ts | 94 ++++++++ server/routes/auth.ts | 86 ++++++- src/components/admin/AdminPanel.tsx | 291 +++++++++++++++++++++++ src/components/ai/ModelSettingsModal.tsx | 8 +- src/components/auth/LoginModal.tsx | 184 +++++++++++++- src/components/editor/AllNotesView.tsx | 160 +++++++++++++ src/components/editor/Editor.tsx | 10 +- src/components/editor/StarredView.tsx | 124 ++++++++++ src/components/editor/TrashHomeView.tsx | 124 ++++++++++ src/components/sidebar/Sidebar.tsx | 12 +- src/lib/ai.ts | 2 +- src/lib/auth.ts | 87 ++++++- src/lib/sync.ts | 2 +- vite.config.ts | 11 +- 19 files changed, 1251 insertions(+), 51 deletions(-) create mode 100644 server/routes/admin.ts create mode 100644 src/components/admin/AdminPanel.tsx create mode 100644 src/components/editor/AllNotesView.tsx create mode 100644 src/components/editor/StarredView.tsx create mode 100644 src/components/editor/TrashHomeView.tsx diff --git a/public/favicon.svg b/public/favicon.svg index 6893eb1..8cab9b1 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1 +1,9 @@ - \ No newline at end of file + + + + + + + + + diff --git a/server/db.ts b/server/db.ts index ffca662..5082fab 100644 --- a/server/db.ts +++ b/server/db.ts @@ -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) } diff --git a/server/index.ts b/server/index.ts index 3a7ba38..a4f631b 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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 = {} + 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() 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}`) }) diff --git a/server/lib/email.ts b/server/lib/email.ts index af9200e..f80bb89 100644 --- a/server/lib/email.ts +++ b/server/lib/email.ts @@ -31,3 +31,13 @@ export async function sendResetCode(to: string, code: string) { html: `

你的重置密码验证码是:${code},10 分钟内有效。如非本人操作请忽略。

`, }) } + +export async function sendRegisterCode(to: string, code: string) { + await createTransporter().sendMail({ + from: '"MikiVL 笔记" ', + to, + subject: '注册验证码', + text: `你的注册验证码是:${code},10 分钟内有效。如非本人操作请忽略。`, + html: `

你的注册验证码是:${code},10 分钟内有效。如非本人操作请忽略。

`, + }) +} diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index eab6905..cb09fe0 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -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) + } +}) diff --git a/server/routes/admin.ts b/server/routes/admin.ts new file mode 100644 index 0000000..ced4f42 --- /dev/null +++ b/server/routes/admin.ts @@ -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 = {} + 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>() + 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 }) +}) diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 0665776..9c989db 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -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() + +// 发送注册验证码 +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 }) }) // 发送邮箱绑定验证码 diff --git a/src/components/admin/AdminPanel.tsx b/src/components/admin/AdminPanel.tsx new file mode 100644 index 0000000..1c4be78 --- /dev/null +++ b/src/components/admin/AdminPanel.tsx @@ -0,0 +1,291 @@ +import { useState, useEffect, useCallback } from 'react' +import { X, Users, MessageSquare, Key, FileText, Plus, Ban, CheckCircle, Trash2, RefreshCw } from 'lucide-react' +import { + apiAdminGetSiteContent, apiAdminUpdateSiteContent, + apiAdminGetUsers, apiAdminBanUser, apiAdminUnbanUser, + apiAdminGetComments, apiAdminDeleteComment, + apiAdminGetInviteCodes, apiAdminCreateInviteCode, + type AdminUser, type AdminComment, type InviteCode, +} from '../../lib/auth' + +type Tab = 'site' | 'users' | 'comments' | 'invites' + +export function AdminPanel({ onClose }: { onClose: () => void }) { + const [tab, setTab] = useState('site') + + return ( +
{ if (e.target === e.currentTarget) onClose() }} + > +
+ {/* 标题栏 */} +
+ 管理员面板 + +
+ + {/* Tab 导航 */} +
+ {([ + ['site', FileText, '站点内容'], + ['users', Users, '用户管理'], + ['comments', MessageSquare, '评论管理'], + ['invites', Key, '邀请码'], + ] as [Tab, typeof FileText, string][]).map(([id, Icon, label]) => ( + + ))} +
+ + {/* 内容区 */} +
+ {tab === 'site' && } + {tab === 'users' && } + {tab === 'comments' && } + {tab === 'invites' && } +
+
+
+ ) +} + +// ── 站点内容 ────────────────────────────────────────────────────────────────── + +function SiteContentTab() { + const [content, setContent] = useState>({}) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [msg, setMsg] = useState('') + + useEffect(() => { + apiAdminGetSiteContent().then(setContent).finally(() => setLoading(false)) + }, []) + + async function save() { + setSaving(true) + setMsg('') + try { + await apiAdminUpdateSiteContent(content) + setMsg('已保存') + } catch (e: any) { + setMsg(e.message) + } finally { + setSaving(false) + } + } + + if (loading) return
加载中…
+ + const fields: [string, string, boolean][] = [ + ['tagline', '标语', false], + ['subtitle', '副标题', false], + ['news', '动态列表(JSON 数组)', true], + ['projects', '项目卡片(JSON 数组)', true], + ] + + return ( +
+ {fields.map(([key, label, multi]) => ( +
+ + {multi ? ( +