feat: 侧边栏专属主页、邮箱认证、管理后台等多项功能

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MikiVL 2026-07-27 21:09:13 +08:00
parent 1440ce066f
commit b404a81003
19 changed files with 1251 additions and 51 deletions

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 724 B

View File

@ -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)
}

View File

@ -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}`)
})

View File

@ -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>`,
})
}

View File

@ -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)
}
})

94
server/routes/admin.ts Normal file
View File

@ -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 })
})

View File

@ -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 })
})
// 发送邮箱绑定验证码

View File

@ -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<Tab>('site')
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.6)' }}
onClick={e => { if (e.target === e.currentTarget) onClose() }}
>
<div
className="relative flex flex-col rounded-xl shadow-2xl overflow-hidden"
style={{ width: 720, maxWidth: '96vw', maxHeight: '88vh', background: 'var(--bg)', border: '1px solid var(--border)' }}
>
{/* 标题栏 */}
<div className="flex items-center justify-between px-5 py-4 border-b" style={{ borderColor: 'var(--border)' }}>
<span className="font-semibold text-sm" style={{ color: 'var(--text)' }}></span>
<button onClick={onClose} style={{ color: 'var(--text-faint)' }} className="hover:opacity-70 transition-opacity">
<X size={16} />
</button>
</div>
{/* Tab 导航 */}
<div className="flex gap-1 px-4 pt-3" style={{ borderBottom: '1px solid var(--border)' }}>
{([
['site', FileText, '站点内容'],
['users', Users, '用户管理'],
['comments', MessageSquare, '评论管理'],
['invites', Key, '邀请码'],
] as [Tab, typeof FileText, string][]).map(([id, Icon, label]) => (
<button
key={id}
onClick={() => setTab(id)}
className="flex items-center gap-1.5 px-3 py-2 text-xs rounded-t transition-colors"
style={{
color: tab === id ? 'var(--accent)' : 'var(--text-faint)',
borderBottom: tab === id ? '2px solid var(--accent)' : '2px solid transparent',
marginBottom: -1,
}}
>
<Icon size={12} />
{label}
</button>
))}
</div>
{/* 内容区 */}
<div className="flex-1 overflow-y-auto p-5">
{tab === 'site' && <SiteContentTab />}
{tab === 'users' && <UsersTab />}
{tab === 'comments' && <CommentsTab />}
{tab === 'invites' && <InvitesTab />}
</div>
</div>
</div>
)
}
// ── 站点内容 ──────────────────────────────────────────────────────────────────
function SiteContentTab() {
const [content, setContent] = useState<Record<string, string>>({})
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 <div className="text-xs" style={{ color: 'var(--text-faint)' }}></div>
const fields: [string, string, boolean][] = [
['tagline', '标语', false],
['subtitle', '副标题', false],
['news', '动态列表JSON 数组)', true],
['projects', '项目卡片JSON 数组)', true],
]
return (
<div className="flex flex-col gap-4">
{fields.map(([key, label, multi]) => (
<div key={key} className="flex flex-col gap-1.5">
<label className="text-xs font-medium" style={{ color: 'var(--text-faint)' }}>{label}</label>
{multi ? (
<textarea
rows={6}
value={content[key] ?? ''}
onChange={e => setContent(prev => ({ ...prev, [key]: e.target.value }))}
className="text-xs rounded-lg p-2.5 font-mono resize-y"
style={{ background: 'var(--bg-muted)', border: '1px solid var(--border)', color: 'var(--text)', outline: 'none' }}
/>
) : (
<input
type="text"
value={content[key] ?? ''}
onChange={e => setContent(prev => ({ ...prev, [key]: e.target.value }))}
className="text-xs rounded-lg px-3 py-2"
style={{ background: 'var(--bg-muted)', border: '1px solid var(--border)', color: 'var(--text)', outline: 'none' }}
/>
)}
</div>
))}
<div className="flex items-center gap-3 mt-1">
<button
onClick={save}
disabled={saving}
className="px-4 py-1.5 rounded-lg text-xs font-medium transition-opacity"
style={{ background: 'var(--accent)', color: '#fff', opacity: saving ? 0.6 : 1 }}
>
{saving ? '保存中…' : '保存'}
</button>
{msg && <span className="text-xs" style={{ color: msg === '已保存' ? '#22c55e' : '#ef4444' }}>{msg}</span>}
</div>
</div>
)
}
// ── 用户管理 ──────────────────────────────────────────────────────────────────
function UsersTab() {
const [users, setUsers] = useState<AdminUser[]>([])
const [loading, setLoading] = useState(true)
const load = useCallback(() => {
setLoading(true)
apiAdminGetUsers().then(setUsers).finally(() => setLoading(false))
}, [])
useEffect(() => { load() }, [load])
async function toggleBan(user: AdminUser) {
if (user.banned) await apiAdminUnbanUser(user.id)
else await apiAdminBanUser(user.id)
load()
}
if (loading) return <div className="text-xs" style={{ color: 'var(--text-faint)' }}></div>
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between mb-1">
<span className="text-xs" style={{ color: 'var(--text-faint)' }}> {users.length} </span>
<button onClick={load} className="flex items-center gap-1 text-xs" style={{ color: 'var(--text-faint)' }}><RefreshCw size={11} /></button>
</div>
{users.map(u => (
<div key={u.id} className="flex items-center gap-3 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-muted)' }}>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium truncate" style={{ color: 'var(--text)' }}>{u.username}</span>
{u.role === 'admin' && <span className="text-[10px] px-1.5 py-0.5 rounded" style={{ background: '#7c3aed22', color: '#a855f7' }}></span>}
{u.banned && <span className="text-[10px] px-1.5 py-0.5 rounded" style={{ background: '#ef444422', color: '#ef4444' }}></span>}
</div>
<div className="text-[10px] mt-0.5" style={{ color: 'var(--text-faint)' }}>
{u.email ?? '未绑定邮箱'} · {new Date(u.createdAt).toLocaleDateString('zh-CN')}
</div>
</div>
{u.role !== 'admin' && (
<button
onClick={() => toggleBan(u)}
className="flex items-center gap-1 text-[10px] px-2 py-1 rounded transition-opacity hover:opacity-70"
style={{ background: u.banned ? '#22c55e22' : '#ef444422', color: u.banned ? '#22c55e' : '#ef4444' }}
>
{u.banned ? <><CheckCircle size={10} /></> : <><Ban size={10} /></>}
</button>
)}
</div>
))}
</div>
)
}
// ── 评论管理 ──────────────────────────────────────────────────────────────────
function CommentsTab() {
const [items, setItems] = useState<AdminComment[]>([])
const [loading, setLoading] = useState(true)
const load = useCallback(() => {
setLoading(true)
apiAdminGetComments().then(setItems).finally(() => setLoading(false))
}, [])
useEffect(() => { load() }, [load])
async function del(id: string) {
await apiAdminDeleteComment(id)
setItems(prev => prev.filter(i => i.id !== id))
}
if (loading) return <div className="text-xs" style={{ color: 'var(--text-faint)' }}></div>
if (!items.length) return <div className="text-xs" style={{ color: 'var(--text-faint)' }}></div>
return (
<div className="flex flex-col gap-2">
{items.map(item => (
<div key={item.id} className="flex items-start gap-3 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-muted)' }}>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-xs font-medium" style={{ color: 'var(--text)' }}>{item.username ?? '匿名'}</span>
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{new Date(item.createdAt).toLocaleString('zh-CN')}</span>
</div>
<p className="text-xs leading-relaxed" style={{ color: 'var(--text-faint)', whiteSpace: 'pre-wrap' }}>{item.content}</p>
</div>
<button onClick={() => del(item.id)} className="flex-shrink-0 hover:opacity-70 transition-opacity" style={{ color: '#ef4444' }}>
<Trash2 size={13} />
</button>
</div>
))}
</div>
)
}
// ── 邀请码管理 ────────────────────────────────────────────────────────────────
function InvitesTab() {
const [codes, setCodes] = useState<InviteCode[]>([])
const [loading, setLoading] = useState(true)
const [creating, setCreating] = useState(false)
const load = useCallback(() => {
setLoading(true)
apiAdminGetInviteCodes().then(setCodes).finally(() => setLoading(false))
}, [])
useEffect(() => { load() }, [load])
async function create() {
setCreating(true)
try {
await apiAdminCreateInviteCode()
load()
} finally {
setCreating(false)
}
}
if (loading) return <div className="text-xs" style={{ color: 'var(--text-faint)' }}></div>
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-xs" style={{ color: 'var(--text-faint)' }}> {codes.length} </span>
<button
onClick={create}
disabled={creating}
className="flex items-center gap-1 text-xs px-3 py-1.5 rounded-lg transition-opacity"
style={{ background: 'var(--accent)', color: '#fff', opacity: creating ? 0.6 : 1 }}
>
<Plus size={11} />
</button>
</div>
<div className="flex flex-col gap-1.5">
{codes.map(c => (
<div key={c.code} className="flex items-center gap-3 px-3 py-2 rounded-lg font-mono text-xs" style={{ background: 'var(--bg-muted)' }}>
<span className="flex-1" style={{ color: c.usedByUserId ? 'var(--text-faint)' : 'var(--text)' }}>{c.code}</span>
{c.usedByUserId ? (
<span className="text-[10px] px-1.5 py-0.5 rounded" style={{ background: '#6b728022', color: 'var(--text-faint)' }}>使</span>
) : (
<span className="text-[10px] px-1.5 py-0.5 rounded" style={{ background: '#22c55e22', color: '#22c55e' }}></span>
)}
</div>
))}
</div>
</div>
)
}

View File

@ -22,7 +22,7 @@ export function ModelSettingsModal({ onClose }: { onClose: () => void }) {
const fetchModels = async () => {
try {
const res = await fetch('/api/models')
const res = await fetch('https://www.mikivl.online/api/note/models')
setModels(await res.json())
} finally {
setLoading(false)
@ -32,12 +32,12 @@ export function ModelSettingsModal({ onClose }: { onClose: () => void }) {
useEffect(() => { fetchModels() }, [])
const activate = async (id: string) => {
await fetch(`/api/models/${id}/activate`, { method: 'PATCH' })
await fetch(`https://www.mikivl.online/api/note/models/${id}/activate`, { method: 'PATCH' })
setModels(prev => prev.map(m => ({ ...m, isActive: m.id === id })))
}
const remove = async (id: string) => {
const res = await fetch(`/api/models/${id}`, { method: 'DELETE' })
const res = await fetch(`https://www.mikivl.online/api/note/models/${id}`, { method: 'DELETE' })
if (res.ok) setModels(prev => {
const next = prev.filter(m => m.id !== id)
if (prev.find(m => m.id === id)?.isActive && next.length > 0) next[0].isActive = true
@ -52,7 +52,7 @@ export function ModelSettingsModal({ onClose }: { onClose: () => void }) {
setFormError('')
setSaving(true)
try {
const res = await fetch('/api/models', {
const res = await fetch('https://www.mikivl.online/api/note/models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),

View File

@ -1,10 +1,10 @@
import { useState } from 'react'
import { useState, useEffect, useRef } from 'react'
import { X } from 'lucide-react'
import { apiLogin, apiRegister, apiActivate, setToken, apiForgotPassword, apiResetPassword } from '../../lib/auth'
import { apiLogin, apiRegister, apiRegisterSendCode, apiActivate, setToken, apiForgotPassword, apiResetPassword } from '../../lib/auth'
import { useAppStore } from '../../stores/appStore'
type Tab = 'login' | 'register' | 'activate'
// 忘记密码分两步:输入邮箱 → 输入验证码+新密码
type RegisterStep = 'form' | 'verify'
type ForgotStep = 'email' | 'reset'
export function LoginModal({ onClose, initialTab }: { onClose: () => void; initialTab?: Tab }) {
@ -16,6 +16,15 @@ export function LoginModal({ onClose, initialTab }: { onClose: () => void; initi
const [loading, setLoading] = useState(false)
const { setCurrentUser, syncFromCloud, currentUser } = useAppStore()
// 注册两步流程
const [registerStep, setRegisterStep] = useState<RegisterStep>('form')
const [regUsername, setRegUsername] = useState('')
const [regEmail, setRegEmail] = useState('')
const [regPassword, setRegPassword] = useState('')
const [regCode, setRegCode] = useState('')
const [countdown, setCountdown] = useState(0)
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null)
// 忘记密码状态
const [forgotMode, setForgotMode] = useState(false)
const [forgotStep, setForgotStep] = useState<ForgotStep>('email')
@ -23,6 +32,29 @@ export function LoginModal({ onClose, initialTab }: { onClose: () => void; initi
const [forgotCode, setForgotCode] = useState('')
const [forgotPassword, setForgotPassword] = useState('')
useEffect(() => () => { if (countdownRef.current) clearInterval(countdownRef.current) }, [])
function startCountdown() {
setCountdown(60)
countdownRef.current = setInterval(() => {
setCountdown(v => {
if (v <= 1) { clearInterval(countdownRef.current!); return 0 }
return v - 1
})
}, 1000)
}
function resetRegister() {
setRegisterStep('form')
setRegUsername('')
setRegEmail('')
setRegPassword('')
setRegCode('')
setCountdown(0)
if (countdownRef.current) clearInterval(countdownRef.current)
setError('')
}
function resetForgot() {
setForgotMode(false)
setForgotStep('email')
@ -32,6 +64,51 @@ export function LoginModal({ onClose, initialTab }: { onClose: () => void; initi
setError('')
}
async function handleRegisterSend(e: React.FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
try {
await apiRegisterSendCode(regUsername, regPassword, regEmail)
setRegisterStep('verify')
startCountdown()
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
async function handleRegisterVerify(e: React.FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
try {
const { token, user } = await apiRegister(regEmail, regCode)
setToken(token)
setCurrentUser(user)
onClose()
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
async function handleResend() {
if (countdown > 0) return
setError('')
setLoading(true)
try {
await apiRegisterSendCode(regUsername, regPassword, regEmail)
startCountdown()
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
async function handleForgotSend(e: React.FormEvent) {
e.preventDefault()
setError('')
@ -71,11 +148,6 @@ export function LoginModal({ onClose, initialTab }: { onClose: () => void; initi
setCurrentUser(user)
if (user.cloudEnabled) await syncFromCloud()
onClose()
} else if (tab === 'register') {
const { token, user } = await apiRegister(username, password)
setToken(token)
setCurrentUser(user)
onClose()
} else {
await apiActivate(code)
setCurrentUser({ ...currentUser!, cloudEnabled: true })
@ -115,7 +187,9 @@ export function LoginModal({ onClose, initialTab }: { onClose: () => void; initi
<h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
{forgotMode
? (forgotStep === 'email' ? '找回密码' : '重置密码')
: (tab === 'login' ? '登录' : tab === 'register' ? '注册' : '激活云存储')}
: (tab === 'login' ? '登录' : tab === 'register'
? (registerStep === 'form' ? '注册' : '验证邮箱')
: '激活云存储')}
</h2>
<button onClick={onClose} className="p-1 rounded" style={{ color: 'var(--text-faint)' }}>
<X size={14} />
@ -198,6 +272,94 @@ export function LoginModal({ onClose, initialTab }: { onClose: () => void; initi
</div>
</form>
)
) : tab === 'register' ? (
// 注册两步流程
registerStep === 'form' ? (
<form onSubmit={handleRegisterSend} className="flex flex-col gap-3">
<input
style={inputStyle}
placeholder="用户名2-20 位)"
value={regUsername}
onChange={e => setRegUsername(e.target.value)}
autoFocus
/>
<input
style={inputStyle}
type="email"
placeholder="邮箱地址"
value={regEmail}
onChange={e => setRegEmail(e.target.value)}
/>
<input
style={inputStyle}
type="password"
placeholder="密码(至少 6 位)"
value={regPassword}
onChange={e => setRegPassword(e.target.value)}
/>
{error && <p className="text-xs" style={{ color: '#ef4444' }}>{error}</p>}
<div className="flex gap-2">
<button
type="button"
onClick={() => { setTab('login'); 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>
) : (
<form onSubmit={handleRegisterVerify} className="flex flex-col gap-3">
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>
<strong style={{ color: 'var(--text)' }}>{regEmail}</strong>10
</p>
<input
style={inputStyle}
placeholder="6 位验证码"
value={regCode}
onChange={e => setRegCode(e.target.value)}
maxLength={6}
autoFocus
/>
{error && <p className="text-xs" style={{ color: '#ef4444' }}>{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full py-2 rounded-lg text-sm font-medium"
style={{ background: 'var(--accent)', color: '#fff', opacity: loading ? 0.7 : 1 }}
>
{loading ? '注册中…' : '完成注册'}
</button>
<div className="flex justify-between items-center">
<button
type="button"
onClick={() => { resetRegister() }}
className="text-xs"
style={{ color: 'var(--text-faint)' }}
>
</button>
<button
type="button"
onClick={handleResend}
disabled={countdown > 0 || loading}
className="text-xs"
style={{ color: countdown > 0 ? 'var(--text-faint)' : 'var(--accent)' }}
>
{countdown > 0 ? `重新发送 (${countdown}s)` : '重新发送'}
</button>
</div>
</form>
)
) : (
<>
{tab !== 'activate' && (
@ -205,7 +367,7 @@ export function LoginModal({ onClose, initialTab }: { onClose: () => void; initi
{(['login', 'register'] as Tab[]).map(t => (
<button
key={t}
onClick={() => setTab(t)}
onClick={() => { setTab(t); setError('') }}
className="flex-1 py-1 rounded text-xs font-medium transition-all"
style={{
background: tab === t ? 'var(--bg)' : 'transparent',
@ -254,7 +416,7 @@ export function LoginModal({ onClose, initialTab }: { onClose: () => void; initi
className="w-full py-2 rounded-lg text-sm font-medium"
style={{ background: 'var(--accent)', color: '#fff', opacity: loading ? 0.7 : 1 }}
>
{loading ? '请稍候…' : tab === 'login' ? '登录' : tab === 'register' ? '注册' : '激活'}
{loading ? '请稍候…' : tab === 'login' ? '登录' : '激活'}
</button>
{tab === 'login' && (

View File

@ -0,0 +1,160 @@
import { FileText, Plus, Clock, Hash, Folder } from 'lucide-react'
import { useAppStore } from '../../stores/appStore'
import { formatDate } from '../../lib/utils'
export function AllNotesView() {
const { notes, folders, createNote, setActiveNote } = useAppStore()
const recentNotes = [...notes]
.sort((a, b) => b.updatedAt - a.updatedAt)
.slice(0, 5)
const totalWords = notes.reduce((sum, n) => sum + (n.wordCount ?? 0), 0)
const tagCounts: Record<string, number> = {}
for (const note of notes) {
for (const tag of note.tags) {
tagCounts[tag] = (tagCounts[tag] ?? 0) + 1
}
}
const topTags = Object.entries(tagCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
return (
<div className="flex-1 overflow-y-auto h-full" style={{ background: 'var(--bg)' }}>
<div className="max-w-2xl mx-auto px-12 pt-16 pb-20">
{/* Hero */}
<div className="mb-10">
<div
className="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-6 text-2xl"
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)' }}
>
<FileText size={26} />
</div>
<h1
className="text-4xl font-bold mb-3"
style={{ color: 'var(--text)', letterSpacing: '-0.03em', lineHeight: 1.15 }}
>
</h1>
<p className="text-base" style={{ color: 'var(--text-muted)', lineHeight: 1.7 }}>
{notes.length} {folders.length} {totalWords.toLocaleString()}
</p>
<button
onClick={() => createNote(null)}
className="inline-flex items-center gap-2 mt-6 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
style={{ background: 'var(--accent)', color: '#fff' }}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--accent-hover)')}
onMouseLeave={e => (e.currentTarget.style.background = 'var(--accent)')}
>
<Plus size={15} />
</button>
</div>
<div style={{ borderTop: '1px solid var(--border)', marginBottom: '2.5rem' }} />
{/* 统计卡片 */}
<div className="grid grid-cols-3 gap-3 mb-10">
{[
{ label: '全部笔记', value: notes.length, icon: <FileText size={16} /> },
{ label: '文件夹', value: folders.length, icon: <Folder size={16} /> },
{ label: '累计字数', value: totalWords.toLocaleString(), icon: <Hash size={16} /> },
].map(stat => (
<div
key={stat.label}
className="rounded-xl p-4"
style={{ background: 'var(--bg-subtle)', border: '1px solid var(--border)' }}
>
<div
className="inline-flex items-center justify-center w-8 h-8 rounded-lg mb-3"
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)' }}
>
{stat.icon}
</div>
<div className="text-2xl font-bold mb-0.5" style={{ color: 'var(--text)' }}>{stat.value}</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{stat.label}</div>
</div>
))}
</div>
{/* 最近修改 */}
{recentNotes.length > 0 && (
<>
<h2
className="text-xs font-semibold uppercase tracking-widest mb-4"
style={{ color: 'var(--text-faint)' }}
>
</h2>
<div
className="rounded-xl overflow-hidden mb-10"
style={{ border: '1px solid var(--border)' }}
>
{recentNotes.map((note, i) => (
<button
key={note.id}
onClick={() => setActiveNote(note.id)}
className="w-full flex items-center justify-between px-5 py-3 text-left transition-colors"
style={{
borderTop: i > 0 ? '1px solid var(--border)' : 'none',
background: 'var(--bg-subtle)',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-muted)')}
onMouseLeave={e => (e.currentTarget.style.background = 'var(--bg-subtle)')}
>
<div className="flex items-center gap-3 min-w-0">
<Clock size={13} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span className="text-sm truncate" style={{ color: 'var(--text)' }}>
{note.title || '无标题笔记'}
</span>
</div>
<span className="text-xs shrink-0 ml-3" style={{ color: 'var(--text-faint)' }}>
{formatDate(note.updatedAt)}
</span>
</button>
))}
</div>
</>
)}
{/* 标签云 */}
{topTags.length > 0 && (
<>
<h2
className="text-xs font-semibold uppercase tracking-widest mb-4"
style={{ color: 'var(--text-faint)' }}
>
</h2>
<div className="flex flex-wrap gap-2">
{topTags.map(([tag, count]) => (
<span
key={tag}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm"
style={{ background: 'var(--bg-subtle)', border: '1px solid var(--border)', color: 'var(--text-muted)' }}
>
<Hash size={11} />
{tag}
<span className="text-xs" style={{ color: 'var(--text-faint)' }}>({count})</span>
</span>
))}
</div>
</>
)}
{/* 空状态 */}
{notes.length === 0 && (
<div className="text-center py-12" style={{ color: 'var(--text-faint)' }}>
<FileText size={40} className="mx-auto mb-4 opacity-30" />
<p className="text-sm mb-2"></p>
<p className="text-xs"></p>
</div>
)}
</div>
</div>
)
}

View File

@ -30,6 +30,9 @@ import { useAppStore } from '../../stores/appStore'
import { countWords } from '../../lib/utils'
import { streamAI } from '../../lib/ai'
import { WelcomeView } from './WelcomeView'
import { AllNotesView } from './AllNotesView'
import { StarredView } from './StarredView'
import { TrashHomeView } from './TrashHomeView'
import { ExportMenu } from './ExportMenu'
const lowlight = createLowlight(common)
@ -80,7 +83,7 @@ function SlashMenu({ items, selectedIndex, onSelect }: {
}
export function Editor() {
const { activeNoteId, notes, updateNote, toggleStar, focusMode, toggleFocusMode, toggleAiPanel, aiPanelOpen } = useAppStore()
const { activeNoteId, activeFolderId, notes, updateNote, toggleStar, focusMode, toggleFocusMode, toggleAiPanel, aiPanelOpen } = useAppStore()
const activeNote = notes.find(n => n.id === activeNoteId)
const [title, setTitle] = useState(activeNote?.title ?? '')
@ -352,7 +355,10 @@ export function Editor() {
}
if (!activeNote || activeNoteId === '__welcome__') {
return <WelcomeView />
if (activeNoteId === '__welcome__') return <WelcomeView />
if (activeFolderId === 'starred') return <StarredView />
if (activeFolderId === 'trash') return <TrashHomeView />
return <AllNotesView />
}
const readingTime = wordCount > 0 ? Math.max(1, Math.ceil(wordCount / 250)) : 0

View File

@ -0,0 +1,124 @@
import { Star, Plus, Hash, Clock } from 'lucide-react'
import { useAppStore } from '../../stores/appStore'
import { formatDate } from '../../lib/utils'
export function StarredView() {
const { notes, createNote, setActiveNote, toggleStar } = useAppStore()
const starredNotes = notes.filter(n => n.starred).sort((a, b) => b.updatedAt - a.updatedAt)
return (
<div className="flex-1 overflow-y-auto h-full" style={{ background: 'var(--bg)' }}>
<div className="max-w-2xl mx-auto px-12 pt-16 pb-20">
{/* Hero */}
<div className="mb-10">
<div
className="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-6"
style={{ background: 'var(--accent-subtle)', color: '#f59e0b' }}
>
<Star size={26} fill="currentColor" />
</div>
<h1
className="text-4xl font-bold mb-3"
style={{ color: 'var(--text)', letterSpacing: '-0.03em', lineHeight: 1.15 }}
>
</h1>
<p className="text-base" style={{ color: 'var(--text-muted)', lineHeight: 1.7 }}>
{starredNotes.length > 0
? `已收藏 ${starredNotes.length} 篇笔记,点击即可快速访问。`
: '还没有收藏任何笔记。在笔记列表或编辑器标题旁点击星标即可收藏。'}
</p>
{starredNotes.length === 0 && (
<button
onClick={() => createNote(null)}
className="inline-flex items-center gap-2 mt-6 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
style={{ background: 'var(--accent)', color: '#fff' }}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--accent-hover)')}
onMouseLeave={e => (e.currentTarget.style.background = 'var(--accent)')}
>
<Plus size={15} />
</button>
)}
</div>
<div style={{ borderTop: '1px solid var(--border)', marginBottom: '2.5rem' }} />
{starredNotes.length > 0 ? (
<>
<h2
className="text-xs font-semibold uppercase tracking-widest mb-4"
style={{ color: 'var(--text-faint)' }}
>
</h2>
<div
className="rounded-xl overflow-hidden"
style={{ border: '1px solid var(--border)' }}
>
{starredNotes.map((note, i) => (
<button
key={note.id}
onClick={() => setActiveNote(note.id)}
className="w-full flex items-center justify-between px-5 py-3.5 text-left transition-colors"
style={{
borderTop: i > 0 ? '1px solid var(--border)' : 'none',
background: 'var(--bg-subtle)',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-muted)')}
onMouseLeave={e => (e.currentTarget.style.background = 'var(--bg-subtle)')}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<Star size={13} style={{ color: '#f59e0b', flexShrink: 0 }} fill="currentColor" />
<div className="min-w-0">
<div className="text-sm font-medium truncate" style={{ color: 'var(--text)' }}>
{note.title || '无标题笔记'}
</div>
{note.tags.length > 0 && (
<div className="flex items-center gap-1 mt-1 flex-wrap">
{note.tags.slice(0, 3).map(tag => (
<span
key={tag}
className="inline-flex items-center gap-0.5 text-xs px-1.5 py-0.5 rounded-full"
style={{ background: 'var(--bg-muted)', color: 'var(--text-muted)' }}
>
<Hash size={9} />{tag}
</span>
))}
</div>
)}
</div>
</div>
<div className="flex items-center gap-3 shrink-0 ml-3">
<span className="text-xs" style={{ color: 'var(--text-faint)' }}>
<Clock size={11} className="inline mr-1" />
{formatDate(note.updatedAt)}
</span>
<button
onClick={e => { e.stopPropagation(); toggleStar(note.id) }}
className="p-1 rounded transition-colors"
title="取消收藏"
style={{ color: 'var(--text-faint)' }}
onMouseEnter={e => (e.currentTarget.style.color = '#ef4444')}
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-faint)')}
>
<Star size={13} />
</button>
</div>
</button>
))}
</div>
</>
) : (
<div className="text-center py-12" style={{ color: 'var(--text-faint)' }}>
<Star size={40} className="mx-auto mb-4 opacity-30" />
<p className="text-sm mb-1"></p>
<p className="text-xs"> hover </p>
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,124 @@
import { Trash2, RotateCcw, AlertTriangle } from 'lucide-react'
import { useAppStore } from '../../stores/appStore'
function daysLeft(deletedAt: number): number {
const elapsed = Date.now() - deletedAt
return Math.max(0, 30 - Math.floor(elapsed / (24 * 60 * 60 * 1000)))
}
export function TrashHomeView() {
const { trashNotes, restoreNote, emptyTrash } = useAppStore()
const notes = trashNotes()
return (
<div className="flex-1 overflow-y-auto h-full" style={{ background: 'var(--bg)' }}>
<div className="max-w-2xl mx-auto px-12 pt-16 pb-20">
{/* Hero */}
<div className="mb-10">
<div
className="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-6"
style={{ background: 'rgba(239,68,68,0.1)', color: '#ef4444' }}
>
<Trash2 size={26} />
</div>
<div className="flex items-start justify-between gap-4">
<div>
<h1
className="text-4xl font-bold mb-3"
style={{ color: 'var(--text)', letterSpacing: '-0.03em', lineHeight: 1.15 }}
>
</h1>
<p className="text-base" style={{ color: 'var(--text-muted)', lineHeight: 1.7 }}>
{notes.length > 0
? `${notes.length} 篇笔记将在 30 天后永久删除,可随时恢复。`
: '回收站为空,已删除的笔记会在这里保留 30 天。'}
</p>
</div>
{notes.length > 0 && (
<button
onClick={() => {
if (confirm('确认清空回收站?此操作无法撤销。')) emptyTrash()
}}
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium mt-1 transition-colors"
style={{ background: 'rgba(239,68,68,0.1)', color: '#ef4444' }}
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(239,68,68,0.18)')}
onMouseLeave={e => (e.currentTarget.style.background = 'rgba(239,68,68,0.1)')}
>
<Trash2 size={13} />
</button>
)}
</div>
</div>
<div style={{ borderTop: '1px solid var(--border)', marginBottom: '2.5rem' }} />
{notes.length > 0 ? (
<>
{/* 提示 */}
<div
className="flex items-start gap-3 rounded-xl px-4 py-3 mb-6 text-sm"
style={{ background: 'rgba(239,68,68,0.06)', border: '1px solid rgba(239,68,68,0.2)', color: 'var(--text-muted)' }}
>
<AlertTriangle size={15} style={{ color: '#ef4444', flexShrink: 0, marginTop: 1 }} />
30
</div>
<h2
className="text-xs font-semibold uppercase tracking-widest mb-4"
style={{ color: 'var(--text-faint)' }}
>
</h2>
<div
className="rounded-xl overflow-hidden"
style={{ border: '1px solid var(--border)' }}
>
{notes.map((note, i) => {
const days = daysLeft(note.deletedAt!)
const urgent = days <= 3
return (
<div
key={note.id}
className="flex items-center justify-between px-5 py-3.5 group"
style={{
borderTop: i > 0 ? '1px solid var(--border)' : 'none',
background: 'var(--bg-subtle)',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-muted)')}
onMouseLeave={e => (e.currentTarget.style.background = 'var(--bg-subtle)')}
>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate" style={{ color: 'var(--text)' }}>
{note.title || '无标题笔记'}
</div>
<div className="text-xs mt-0.5" style={{ color: urgent ? '#ef4444' : 'var(--text-faint)' }}>
{days === 0 ? '今天永久删除' : `${days} 天后永久删除`}
</div>
</div>
<button
onClick={() => restoreNote(note.id)}
className="shrink-0 flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity"
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)' }}
>
<RotateCcw size={12} />
</button>
</div>
)
})}
</div>
</>
) : (
<div className="text-center py-12" style={{ color: 'var(--text-faint)' }}>
<Trash2 size={40} className="mx-auto mb-4 opacity-30" />
<p className="text-sm"></p>
</div>
)}
</div>
</div>
)
}

View File

@ -201,7 +201,17 @@ export function Sidebar() {
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3" style={{ borderBottom: '1px solid var(--border)' }}>
<span className="font-semibold text-sm" style={{ color: 'var(--text)' }}></span>
<div className="flex items-center gap-2">
<svg width="20" height="20" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="6" y="4" width="20" height="26" rx="2" stroke="#4fc3f7" strokeWidth="1.5"/>
<line x1="10" y1="11" x2="22" y2="11" stroke="#4fc3f7" strokeWidth="1.2" strokeLinecap="round"/>
<line x1="10" y1="16" x2="22" y2="16" stroke="#4fc3f7" strokeWidth="1.2" strokeLinecap="round"/>
<line x1="10" y1="21" x2="17" y2="21" stroke="#4fc3f7" strokeWidth="1.2" strokeLinecap="round"/>
<circle cx="27" cy="27" r="5" fill="var(--bg-subtle)" stroke="#4fc3f7" strokeWidth="1.2"/>
<path d="M25 27l1.5 1.5L29 25" stroke="#4fc3f7" strokeWidth="1.1" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span className="font-semibold text-sm" style={{ color: 'var(--text)' }}></span>
</div>
<div className="flex items-center gap-1">
<button onClick={toggleTheme} className="toolbar-btn" title={theme === 'light' ? '暗色模式' : '亮色模式'}>
{theme === 'light' ? <Moon size={15} /> : <Sun size={15} />}

View File

@ -11,7 +11,7 @@ export async function streamAI(
onChunk: (text: string) => void,
signal?: AbortSignal,
): Promise<void> {
const res = await fetch('/api/ai/stream', {
const res = await fetch('https://www.mikivl.online/api/note/ai/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),

View File

@ -1,7 +1,7 @@
const TOKEN_KEY = 'mikivl_token'
const API = '/api'
const API = 'https://www.mikivl.online/api'
export type CurrentUser = { id: string; username: string; cloudEnabled: boolean; email: string | null; emailVerified: boolean; nickname: string | null; avatar: string | null }
export type CurrentUser = { id: string; username: string; cloudEnabled: boolean; role: 'user' | 'admin'; email: string | null; emailVerified: boolean; nickname: string | null; avatar: string | null }
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
@ -20,11 +20,21 @@ export function authHeaders(): Record<string, string> {
return token ? { Authorization: `Bearer ${token}` } : {}
}
export async function apiRegister(username: string, password: string): Promise<{ token: string; user: CurrentUser }> {
export async function apiRegisterSendCode(username: string, password: string, email: string): Promise<void> {
const res = await fetch(`${API}/auth/register/send-code`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, email }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? '发送失败')
}
export async function apiRegister(email: string, code: string): Promise<{ token: string; user: CurrentUser }> {
const res = await fetch(`${API}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
body: JSON.stringify({ email, code }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? '注册失败')
@ -123,9 +133,76 @@ export async function apiResetPassword(email: string, code: string, newPassword:
export function parseToken(token: string): CurrentUser | null {
try {
const payload = JSON.parse(atob(token.split('.')[1]))
return { id: payload.userId, username: payload.username, cloudEnabled: false, email: null, emailVerified: false, nickname: null, avatar: null }
return { id: payload.userId, username: payload.username, cloudEnabled: false, role: 'user', email: null, emailVerified: false, nickname: null, avatar: null }
} catch {
return null
}
}
export type AdminUser = { id: string; username: string; role: string; banned: boolean; cloudEnabled: boolean; email: string | null; createdAt: number }
export type AdminComment = { id: string; content: string; createdAt: number; userId: string; username: string | null }
export type InviteCode = { code: string; usedByUserId: string | null; usedAt: number | null }
export async function apiAdminGetSiteContent(): Promise<Record<string, string>> {
const res = await fetch(`${API}/admin/site-content`, { headers: authHeaders() })
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? '获取失败')
return data
}
export async function apiAdminUpdateSiteContent(content: Record<string, string>): Promise<void> {
const res = await fetch(`${API}/admin/site-content`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(content),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? '更新失败')
}
export async function apiAdminGetUsers(): Promise<AdminUser[]> {
const res = await fetch(`${API}/admin/users`, { headers: authHeaders() })
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? '获取失败')
return data
}
export async function apiAdminBanUser(id: string): Promise<void> {
const res = await fetch(`${API}/admin/users/${id}/ban`, { method: 'POST', headers: authHeaders() })
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? '操作失败')
}
export async function apiAdminUnbanUser(id: string): Promise<void> {
const res = await fetch(`${API}/admin/users/${id}/unban`, { method: 'POST', headers: authHeaders() })
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? '操作失败')
}
export async function apiAdminGetComments(): Promise<AdminComment[]> {
const res = await fetch(`${API}/admin/comments`, { headers: authHeaders() })
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? '获取失败')
return data
}
export async function apiAdminDeleteComment(id: string): Promise<void> {
const res = await fetch(`${API}/admin/comments/${id}`, { method: 'DELETE', headers: authHeaders() })
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? '删除失败')
}
export async function apiAdminGetInviteCodes(): Promise<InviteCode[]> {
const res = await fetch(`${API}/admin/invite-codes`, { headers: authHeaders() })
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? '获取失败')
return data
}
export async function apiAdminCreateInviteCode(): Promise<{ code: string }> {
const res = await fetch(`${API}/admin/invite-codes`, { method: 'POST', headers: authHeaders() })
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? '生成失败')
return data
}

View File

@ -1,7 +1,7 @@
import type { Note, Folder } from '../db'
import { authHeaders } from './auth'
const API = '/api'
const API = 'https://www.mikivl.online/api/note'
export type SyncPayload = { notes: Note[]; folders: Folder[] }

View File

@ -3,7 +3,7 @@ import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
base: '/app/',
base: '/',
plugins: [react(), tailwindcss()],
server: {
proxy: {
@ -23,6 +23,15 @@ export default defineConfig({
if (id.includes('framer-motion') || id.includes('@dnd-kit') || id.includes('lucide-react')) {
return 'vendor-ui'
}
if (id.includes('node_modules/pdfjs-dist')) {
return 'vendor-pdf'
}
if (id.includes('node_modules/mammoth') || id.includes('node_modules/docx')) {
return 'vendor-docx'
}
if (id.includes('node_modules/zustand') || id.includes('node_modules/dexie')) {
return 'vendor-state'
}
},
},
},