feat: 侧边栏专属主页、邮箱认证、管理后台等多项功能
- 所有笔记/收藏/回收站各自展示专属主页(统计、列表、操作入口) - 新增邮箱绑定、找回密码、个人中心功能 - 新增管理员路由与后台组件 - AI 模型设置、登录弹窗、侧边栏交互优化 - 修复懒加载、cloudEnabled 刷新、验证码冷却等 bug Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1440ce066f
commit
b404a81003
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 724 B |
35
server/db.ts
35
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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
94
server/routes/admin.ts
Normal file
94
server/routes/admin.ts
Normal 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 })
|
||||
})
|
||||
@ -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 })
|
||||
})
|
||||
|
||||
// 发送邮箱绑定验证码
|
||||
|
||||
291
src/components/admin/AdminPanel.tsx
Normal file
291
src/components/admin/AdminPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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),
|
||||
|
||||
@ -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' && (
|
||||
|
||||
160
src/components/editor/AllNotesView.tsx
Normal file
160
src/components/editor/AllNotesView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
|
||||
124
src/components/editor/StarredView.tsx
Normal file
124
src/components/editor/StarredView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
124
src/components/editor/TrashHomeView.tsx
Normal file
124
src/components/editor/TrashHomeView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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} />}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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[] }
|
||||
|
||||
|
||||
@ -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'
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user