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(),
|
username: text('username').notNull().unique(),
|
||||||
passwordHash: text('password_hash').notNull(),
|
passwordHash: text('password_hash').notNull(),
|
||||||
cloudEnabled: integer('cloud_enabled', { mode: 'boolean' }).notNull().default(false),
|
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'),
|
nickname: text('nickname'),
|
||||||
avatar: text('avatar'),
|
avatar: text('avatar'),
|
||||||
email: text('email'),
|
email: text('email'),
|
||||||
@ -66,6 +68,11 @@ export const inviteCodes = sqliteTable('invite_codes', {
|
|||||||
usedAt: integer('used_at'),
|
usedAt: integer('used_at'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const siteContent = sqliteTable('site_content', {
|
||||||
|
key: text('key').primaryKey(),
|
||||||
|
value: text('value').notNull(),
|
||||||
|
})
|
||||||
|
|
||||||
export function initDb() {
|
export function initDb() {
|
||||||
sqlite.exec(`
|
sqlite.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
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('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('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('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 { cors } from 'hono/cors'
|
||||||
import Anthropic from '@anthropic-ai/sdk'
|
import Anthropic from '@anthropic-ai/sdk'
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import { initDb } from './db'
|
import { initDb, db, siteContent } from './db'
|
||||||
import { authRouter } from './routes/auth'
|
import { authRouter } from './routes/auth'
|
||||||
import { notesRouter } from './routes/notes'
|
import { notesRouter } from './routes/notes'
|
||||||
import { foldersRouter } from './routes/folders'
|
import { foldersRouter } from './routes/folders'
|
||||||
import { commentsRouter } from './routes/comments'
|
import { commentsRouter } from './routes/comments'
|
||||||
|
import { adminRouter } from './routes/admin'
|
||||||
|
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
app.use('*', cors())
|
app.use('*', cors())
|
||||||
@ -65,17 +66,26 @@ function getActiveModel(): string {
|
|||||||
|
|
||||||
// ── Model CRUD endpoints ──────────────────────────────────────────────────────
|
// ── Model CRUD endpoints ──────────────────────────────────────────────────────
|
||||||
app.route('/api/auth', authRouter)
|
app.route('/api/auth', authRouter)
|
||||||
app.route('/api/notes', notesRouter)
|
app.route('/api/admin', adminRouter)
|
||||||
app.route('/api/folders', foldersRouter)
|
app.route('/api/note/notes', notesRouter)
|
||||||
app.route('/api/comments', commentsRouter)
|
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()
|
const models = readModels()
|
||||||
// strip apiKey from response for security
|
// strip apiKey from response for security
|
||||||
return c.json(models.map(({ apiKey: _, ...rest }) => rest))
|
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 body = await c.req.json<{ name: string; apiKey: string; baseURL: string; modelId?: string }>()
|
||||||
const models = readModels()
|
const models = readModels()
|
||||||
const newModel: ModelConfig = {
|
const newModel: ModelConfig = {
|
||||||
@ -92,7 +102,7 @@ app.post('/api/models', async (c) => {
|
|||||||
return c.json(safe)
|
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 { id } = c.req.param()
|
||||||
const models = readModels()
|
const models = readModels()
|
||||||
models.forEach(m => { m.isActive = m.id === id })
|
models.forEach(m => { m.isActive = m.id === id })
|
||||||
@ -100,7 +110,7 @@ app.patch('/api/models/:id/activate', (c) => {
|
|||||||
return c.json({ ok: true })
|
return c.json({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
app.delete('/api/models/:id', (c) => {
|
app.delete('/api/note/models/:id', (c) => {
|
||||||
const { id } = c.req.param()
|
const { id } = c.req.param()
|
||||||
let models = readModels()
|
let models = readModels()
|
||||||
const target = models.find(m => m.id === id)
|
const target = models.find(m => m.id === id)
|
||||||
@ -157,7 +167,7 @@ function buildSystemPrompt(type: AIStreamRequest['type'], noteContent: string):
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Stream endpoint ───────────────────────────────────────────────────────────
|
// ── 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 req = await c.req.json<AIStreamRequest>()
|
||||||
const aiClient = getActiveClient()
|
const aiClient = getActiveClient()
|
||||||
const modelId = getActiveModel()
|
const modelId = getActiveModel()
|
||||||
@ -204,6 +214,6 @@ app.post('/api/ai/stream', async (c) => {
|
|||||||
|
|
||||||
initDb()
|
initDb()
|
||||||
|
|
||||||
serve({ fetch: app.fetch, port: 3001 }, () => {
|
serve({ fetch: app.fetch, port: Number(process.env.PORT ?? 3003) }, () => {
|
||||||
console.log('AI proxy server running on http://localhost:3001')
|
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>`,
|
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 { createMiddleware } from 'hono/factory'
|
||||||
import jwt from 'jsonwebtoken'
|
import jwt from 'jsonwebtoken'
|
||||||
|
import { db, users } from '../db'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
|
||||||
export type JwtPayload = { userId: string; username: string }
|
export type JwtPayload = { userId: string; username: string }
|
||||||
|
|
||||||
@ -25,3 +27,21 @@ export const requireAuth = createMiddleware(async (c, next) => {
|
|||||||
return c.json({ error: 'Token 无效或已过期' }, 401)
|
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 { eq } from 'drizzle-orm'
|
||||||
import { db, users, inviteCodes } from '../db'
|
import { db, users, inviteCodes } from '../db'
|
||||||
import { requireAuth } from '../middleware/auth'
|
import { requireAuth } from '../middleware/auth'
|
||||||
import { sendVerifyCode, sendResetCode } from '../lib/email'
|
import { sendVerifyCode, sendResetCode, sendRegisterCode } from '../lib/email'
|
||||||
|
|
||||||
export const authRouter = new Hono()
|
export const authRouter = new Hono()
|
||||||
|
|
||||||
@ -16,21 +16,80 @@ function randomCode() {
|
|||||||
return String(Math.floor(100000 + Math.random() * 900000))
|
return String(Math.floor(100000 + Math.random() * 900000))
|
||||||
}
|
}
|
||||||
|
|
||||||
authRouter.post('/register', async (c) => {
|
// 待注册信息临时存储(key 为邮箱)
|
||||||
const { username, password } = await c.req.json<{ username: string; password: string }>()
|
const registerPending = new Map<string, {
|
||||||
if (!username || !password) return c.json({ error: '用户名和密码不能为空' }, 400)
|
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 (username.length < 2 || username.length > 20) return c.json({ error: '用户名长度 2-20 位' }, 400)
|
||||||
if (password.length < 6) return c.json({ error: '密码至少 6 位' }, 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 passwordHash = await bcrypt.hash(password, 10)
|
||||||
const id = nanoid()
|
const code = randomCode()
|
||||||
db.insert(users).values({ id, username, passwordHash, cloudEnabled: false, createdAt: Date.now() }).run()
|
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' })
|
try {
|
||||||
return c.json({ token, user: { id, username, cloudEnabled: false, email: null, emailVerified: false, nickname: null, avatar: null } })
|
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) => {
|
authRouter.post('/login', async (c) => {
|
||||||
@ -42,9 +101,10 @@ authRouter.post('/login', async (c) => {
|
|||||||
|
|
||||||
const ok = await bcrypt.compare(password, user.passwordHash)
|
const ok = await bcrypt.compare(password, user.passwordHash)
|
||||||
if (!ok) return c.json({ error: '用户名或密码错误' }, 401)
|
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' })
|
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) => {
|
authRouter.post('/activate', requireAuth, async (c) => {
|
||||||
@ -67,7 +127,7 @@ authRouter.get('/me', requireAuth, async (c) => {
|
|||||||
const userId = c.get('userId')
|
const userId = c.get('userId')
|
||||||
const [user] = db.select().from(users).where(eq(users.id, userId)).all()
|
const [user] = db.select().from(users).where(eq(users.id, userId)).all()
|
||||||
if (!user) return c.json({ error: '用户不存在' }, 404)
|
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)
|
if (Object.keys(updates).length === 0) return c.json({ error: '无更新内容' }, 400)
|
||||||
db.update(users).set(updates).where(eq(users.id, userId)).run()
|
db.update(users).set(updates).where(eq(users.id, userId)).run()
|
||||||
const [user] = db.select().from(users).where(eq(users.id, userId)).all()
|
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 () => {
|
const fetchModels = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/models')
|
const res = await fetch('https://www.mikivl.online/api/note/models')
|
||||||
setModels(await res.json())
|
setModels(await res.json())
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@ -32,12 +32,12 @@ export function ModelSettingsModal({ onClose }: { onClose: () => void }) {
|
|||||||
useEffect(() => { fetchModels() }, [])
|
useEffect(() => { fetchModels() }, [])
|
||||||
|
|
||||||
const activate = async (id: string) => {
|
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 })))
|
setModels(prev => prev.map(m => ({ ...m, isActive: m.id === id })))
|
||||||
}
|
}
|
||||||
|
|
||||||
const remove = async (id: string) => {
|
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 => {
|
if (res.ok) setModels(prev => {
|
||||||
const next = prev.filter(m => m.id !== id)
|
const next = prev.filter(m => m.id !== id)
|
||||||
if (prev.find(m => m.id === id)?.isActive && next.length > 0) next[0].isActive = true
|
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('')
|
setFormError('')
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/models', {
|
const res = await fetch('https://www.mikivl.online/api/note/models', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(form),
|
body: JSON.stringify(form),
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { X } from 'lucide-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'
|
import { useAppStore } from '../../stores/appStore'
|
||||||
|
|
||||||
type Tab = 'login' | 'register' | 'activate'
|
type Tab = 'login' | 'register' | 'activate'
|
||||||
// 忘记密码分两步:输入邮箱 → 输入验证码+新密码
|
type RegisterStep = 'form' | 'verify'
|
||||||
type ForgotStep = 'email' | 'reset'
|
type ForgotStep = 'email' | 'reset'
|
||||||
|
|
||||||
export function LoginModal({ onClose, initialTab }: { onClose: () => void; initialTab?: Tab }) {
|
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 [loading, setLoading] = useState(false)
|
||||||
const { setCurrentUser, syncFromCloud, currentUser } = useAppStore()
|
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 [forgotMode, setForgotMode] = useState(false)
|
||||||
const [forgotStep, setForgotStep] = useState<ForgotStep>('email')
|
const [forgotStep, setForgotStep] = useState<ForgotStep>('email')
|
||||||
@ -23,6 +32,29 @@ export function LoginModal({ onClose, initialTab }: { onClose: () => void; initi
|
|||||||
const [forgotCode, setForgotCode] = useState('')
|
const [forgotCode, setForgotCode] = useState('')
|
||||||
const [forgotPassword, setForgotPassword] = 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() {
|
function resetForgot() {
|
||||||
setForgotMode(false)
|
setForgotMode(false)
|
||||||
setForgotStep('email')
|
setForgotStep('email')
|
||||||
@ -32,6 +64,51 @@ export function LoginModal({ onClose, initialTab }: { onClose: () => void; initi
|
|||||||
setError('')
|
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) {
|
async function handleForgotSend(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
@ -71,11 +148,6 @@ export function LoginModal({ onClose, initialTab }: { onClose: () => void; initi
|
|||||||
setCurrentUser(user)
|
setCurrentUser(user)
|
||||||
if (user.cloudEnabled) await syncFromCloud()
|
if (user.cloudEnabled) await syncFromCloud()
|
||||||
onClose()
|
onClose()
|
||||||
} else if (tab === 'register') {
|
|
||||||
const { token, user } = await apiRegister(username, password)
|
|
||||||
setToken(token)
|
|
||||||
setCurrentUser(user)
|
|
||||||
onClose()
|
|
||||||
} else {
|
} else {
|
||||||
await apiActivate(code)
|
await apiActivate(code)
|
||||||
setCurrentUser({ ...currentUser!, cloudEnabled: true })
|
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)' }}>
|
<h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
|
||||||
{forgotMode
|
{forgotMode
|
||||||
? (forgotStep === 'email' ? '找回密码' : '重置密码')
|
? (forgotStep === 'email' ? '找回密码' : '重置密码')
|
||||||
: (tab === 'login' ? '登录' : tab === 'register' ? '注册' : '激活云存储')}
|
: (tab === 'login' ? '登录' : tab === 'register'
|
||||||
|
? (registerStep === 'form' ? '注册' : '验证邮箱')
|
||||||
|
: '激活云存储')}
|
||||||
</h2>
|
</h2>
|
||||||
<button onClick={onClose} className="p-1 rounded" style={{ color: 'var(--text-faint)' }}>
|
<button onClick={onClose} className="p-1 rounded" style={{ color: 'var(--text-faint)' }}>
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
@ -198,6 +272,94 @@ export function LoginModal({ onClose, initialTab }: { onClose: () => void; initi
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</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' && (
|
{tab !== 'activate' && (
|
||||||
@ -205,7 +367,7 @@ export function LoginModal({ onClose, initialTab }: { onClose: () => void; initi
|
|||||||
{(['login', 'register'] as Tab[]).map(t => (
|
{(['login', 'register'] as Tab[]).map(t => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
onClick={() => setTab(t)}
|
onClick={() => { setTab(t); setError('') }}
|
||||||
className="flex-1 py-1 rounded text-xs font-medium transition-all"
|
className="flex-1 py-1 rounded text-xs font-medium transition-all"
|
||||||
style={{
|
style={{
|
||||||
background: tab === t ? 'var(--bg)' : 'transparent',
|
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"
|
className="w-full py-2 rounded-lg text-sm font-medium"
|
||||||
style={{ background: 'var(--accent)', color: '#fff', opacity: loading ? 0.7 : 1 }}
|
style={{ background: 'var(--accent)', color: '#fff', opacity: loading ? 0.7 : 1 }}
|
||||||
>
|
>
|
||||||
{loading ? '请稍候…' : tab === 'login' ? '登录' : tab === 'register' ? '注册' : '激活'}
|
{loading ? '请稍候…' : tab === 'login' ? '登录' : '激活'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{tab === 'login' && (
|
{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 { countWords } from '../../lib/utils'
|
||||||
import { streamAI } from '../../lib/ai'
|
import { streamAI } from '../../lib/ai'
|
||||||
import { WelcomeView } from './WelcomeView'
|
import { WelcomeView } from './WelcomeView'
|
||||||
|
import { AllNotesView } from './AllNotesView'
|
||||||
|
import { StarredView } from './StarredView'
|
||||||
|
import { TrashHomeView } from './TrashHomeView'
|
||||||
import { ExportMenu } from './ExportMenu'
|
import { ExportMenu } from './ExportMenu'
|
||||||
|
|
||||||
const lowlight = createLowlight(common)
|
const lowlight = createLowlight(common)
|
||||||
@ -80,7 +83,7 @@ function SlashMenu({ items, selectedIndex, onSelect }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Editor() {
|
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 activeNote = notes.find(n => n.id === activeNoteId)
|
||||||
|
|
||||||
const [title, setTitle] = useState(activeNote?.title ?? '')
|
const [title, setTitle] = useState(activeNote?.title ?? '')
|
||||||
@ -352,7 +355,10 @@ export function Editor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!activeNote || activeNoteId === '__welcome__') {
|
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
|
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 */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-4 py-3" style={{ borderBottom: '1px solid var(--border)' }}>
|
<div className="flex items-center justify-between px-4 py-3" style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<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>
|
<span className="font-semibold text-sm" style={{ color: 'var(--text)' }}>笔记</span>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button onClick={toggleTheme} className="toolbar-btn" title={theme === 'light' ? '暗色模式' : '亮色模式'}>
|
<button onClick={toggleTheme} className="toolbar-btn" title={theme === 'light' ? '暗色模式' : '亮色模式'}>
|
||||||
{theme === 'light' ? <Moon size={15} /> : <Sun size={15} />}
|
{theme === 'light' ? <Moon size={15} /> : <Sun size={15} />}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ export async function streamAI(
|
|||||||
onChunk: (text: string) => void,
|
onChunk: (text: string) => void,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const res = await fetch('/api/ai/stream', {
|
const res = await fetch('https://www.mikivl.online/api/note/ai/stream', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
const TOKEN_KEY = 'mikivl_token'
|
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 {
|
export function getToken(): string | null {
|
||||||
return localStorage.getItem(TOKEN_KEY)
|
return localStorage.getItem(TOKEN_KEY)
|
||||||
@ -20,11 +20,21 @@ export function authHeaders(): Record<string, string> {
|
|||||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
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`, {
|
const res = await fetch(`${API}/auth/register`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ email, code }),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error ?? '注册失败')
|
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 {
|
export function parseToken(token: string): CurrentUser | null {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(atob(token.split('.')[1]))
|
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 {
|
} catch {
|
||||||
return null
|
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 type { Note, Folder } from '../db'
|
||||||
import { authHeaders } from './auth'
|
import { authHeaders } from './auth'
|
||||||
|
|
||||||
const API = '/api'
|
const API = 'https://www.mikivl.online/api/note'
|
||||||
|
|
||||||
export type SyncPayload = { notes: Note[]; folders: Folder[] }
|
export type SyncPayload = { notes: Note[]; folders: Folder[] }
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import react from '@vitejs/plugin-react'
|
|||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: '/app/',
|
base: '/',
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
@ -23,6 +23,15 @@ export default defineConfig({
|
|||||||
if (id.includes('framer-motion') || id.includes('@dnd-kit') || id.includes('lucide-react')) {
|
if (id.includes('framer-motion') || id.includes('@dnd-kit') || id.includes('lucide-react')) {
|
||||||
return 'vendor-ui'
|
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