MikiVL b404a81003 feat: 侧边栏专属主页、邮箱认证、管理后台等多项功能
- 所有笔记/收藏/回收站各自展示专属主页(统计、列表、操作入口)
- 新增邮箱绑定、找回密码、个人中心功能
- 新增管理员路由与后台组件
- AI 模型设置、登录弹窗、侧边栏交互优化
- 修复懒加载、cloudEnabled 刷新、验证码冷却等 bug

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 21:09:13 +08:00

175 lines
7.0 KiB
TypeScript

import Database from 'better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
import path from 'path'
import fs from 'fs'
const DB_PATH = process.env.DB_PATH || path.join(process.cwd(), 'data', 'app.db')
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true })
const sqlite = new Database(DB_PATH)
sqlite.pragma('journal_mode = WAL')
sqlite.pragma('foreign_keys = ON')
export const db = drizzle(sqlite)
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
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'),
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
pendingEmail: text('pending_email'),
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', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
title: text('title').notNull().default(''),
content: text('content').notNull().default(''),
folderId: text('folder_id'),
tags: text('tags').notNull().default('[]'),
starred: integer('starred', { mode: 'boolean' }).notNull().default(false),
wordCount: integer('word_count').notNull().default(0),
deletedAt: integer('deleted_at'),
createdAt: integer('created_at').notNull(),
updatedAt: integer('updated_at').notNull(),
})
export const folders = sqliteTable('folders', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
name: text('name').notNull(),
parentId: text('parent_id'),
order: integer('order').notNull().default(0),
createdAt: integer('created_at').notNull(),
})
export const comments = sqliteTable('comments', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
content: text('content').notNull(),
createdAt: integer('created_at').notNull(),
})
export const inviteCodes = sqliteTable('invite_codes', {
code: text('code').primaryKey(),
usedByUserId: text('used_by_user_id'),
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 (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
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
);
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
folder_id TEXT,
tags TEXT NOT NULL DEFAULT '[]',
starred INTEGER NOT NULL DEFAULT 0,
word_count INTEGER NOT NULL DEFAULT 0,
deleted_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
name TEXT NOT NULL,
parent_id TEXT,
"order" INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS comments (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
content TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS invite_codes (
code TEXT PRIMARY KEY,
used_by_user_id TEXT,
used_at INTEGER
);
`)
const INVITE_CODES = [
'MIKI-A7X2-KP9Q', 'MIKI-B3N8-WR4L', 'MIKI-C6M1-ZT7Y',
'MIKI-D9F5-HJ2V', 'MIKI-E4K3-QN6U', 'MIKI-F8R7-XG1S',
'MIKI-G2W4-LB5E', 'MIKI-H5T9-MC8D', 'MIKI-J1P6-VF3O',
'MIKI-K7Q2-YH4N',
]
const insert = sqlite.prepare(`INSERT OR IGNORE INTO invite_codes (code) VALUES (?)`)
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`)
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)
}