Compare commits

...
23 Commits
Author SHA1 Message Date
MikiVLandClaude Sonnet 4.6 3299672ad8 fix: 点击所有笔记/收藏/回收站时清空当前笔记,回到对应主页
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 21:35:51 +08:00
MikiVLandClaude Sonnet 4.6 e7e3e2bb47 fix: 登录同步时以远端数据为准,避免种子笔记污染云端
远端有数据时直接覆盖本地,不再合并本地 IndexedDB 种子数据;
仅远端为空(首次激活云同步)时才上传本地数据。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 21:23:10 +08:00
MikiVLandClaude Sonnet 4.6 b404a81003 feat: 侧边栏专属主页、邮箱认证、管理后台等多项功能
- 所有笔记/收藏/回收站各自展示专属主页(统计、列表、操作入口)
- 新增邮箱绑定、找回密码、个人中心功能
- 新增管理员路由与后台组件
- AI 模型设置、登录弹窗、侧边栏交互优化
- 修复懒加载、cloudEnabled 刷新、验证码冷却等 bug

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-27 21:09:13 +08:00
MikiVLandClaude Sonnet 4.6 1440ce066f 删除旧版主页文件
主页现在统一维护在 mikivl.online/index.html,不再在此仓库存放副本。
deploy.sh 已更新为从正确路径读取。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-26 09:55:10 +08:00
MikiVLandClaude Sonnet 4.6 c4ac36fc03 更新部署脚本:切换到 Caddy + systemd 架构
- deploy.sh 改为使用 Caddy host 架构(不再依赖 docker nginx)
- 前端 dist 部署到 /opt/mikivl/note(Caddy note.mikivl.online root)
- 主页从 ../mikivl.online/index.html 上传(防止覆盖最新主页)
- 添加 better-sqlite3 自动重新编译逻辑
- docker-compose.yml 移除 program1 只读挂载限制

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-26 09:53:45 +08:00
MikiVLandClaude Sonnet 4.6 b02f23729b 修复笔记侧边栏多个 bug
- 移除管理员面板入口(UserMenu 中的按钮和相关 import)
- 修复重复文件夹问题:seed 判断同时检查 notes 和 folders 数量
- 文件夹新增 hover 删除按钮,附带确认弹窗
- 文件夹区域添加 overflow-y-auto 和 maxHeight 限制,防止过多文件夹遮挡笔记列表

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-26 09:02:27 +08:00
MikiVLandClaude Sonnet 4.6 689c8b2d86 fix: 邮件懒加载、cloudEnabled 刷新、邮箱写入时机、验证码重发冷却
- server/lib/email.ts: transporter 改为懒加载,避免 ESM import 提升导致 EMAIL_PASS 未加载
- server/routes/auth.ts: send-verify 改为写 pendingEmail,验证成功后才写 email;新增 60s 冷却;catch 块打印错误
- server/db.ts: 新增 pendingEmail 字段及迁移
- src/stores/appStore.ts: 新增 refreshCurrentUser,启动时调用 apiGetMe 拉取真实 cloudEnabled
- src/App.tsx: 启动时调用 refreshCurrentUser
- ProfileModal/AccountModal: 加入 60s 倒计时和重新发送按钮

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 14:37:05 +08:00
MikiVLandClaude Sonnet 4.6 b864b2903a feat: 邮箱绑定、找回密码、个人中心
- 后端新增邮箱绑定/验证、忘记密码/重置密码、修改密码接口
- users 表新增 nickname、avatar 字段,含迁移脚本
- 新增 PUT /api/auth/me 更新头像和昵称
- 新增 POST /api/auth/change-password 修改密码(需旧密码)
- 前端新增 ProfileModal 个人中心(头像上传、昵称、邀请码、邮箱绑定、修改密码、退出)
- LoginModal 新增忘记密码流程
- UserMenu 点击头像直接打开个人中心
- server/lib/email.ts:nodemailer 邮件发送封装

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 14:01:23 +08:00
MikiVL f2681cb3de feat: 首页评论区(登录发评论,公开可读) 2026-05-05 13:00:22 +08:00
MikiVL 8074a94fed feat: 侧边栏底部集成用户菜单 2026-05-05 12:58:45 +08:00
MikiVL 71aa09ebd7 feat: 侧边栏用户菜单(登录状态/云存储/登出) 2026-05-05 12:54:56 +08:00
MikiVL 8103bfd266 feat: 登录/注册/邀请码激活弹窗 2026-05-05 12:53:24 +08:00
MikiVL e26ae634b7 feat: appStore 添加用户状态和云同步触发 2026-05-05 12:52:22 +08:00
MikiVL 6d534cc63b feat: 前端云同步库(pull/push/merge) 2026-05-05 06:00:22 +08:00
MikiVL 2c01b0f910 feat: 前端认证库(login/register/activate/token 管理) 2026-05-05 05:59:40 +08:00
MikiVL c3958d7091 feat: 注册认证/笔记/评论路由到 Hono app 2026-05-05 05:58:50 +08:00
MikiVL 2e3517ead5 feat: 评论路由 2026-05-05 05:57:39 +08:00
MikiVL 2178247093 feat: 笔记和文件夹云端同步路由 2026-05-05 05:57:02 +08:00
MikiVL c8ba2a8a51 feat: 注册/登录/邀请码激活路由 2026-05-05 05:55:56 +08:00
MikiVL 52a5a005a7 feat: JWT 认证中间件 2026-05-05 05:54:59 +08:00
MikiVL 43b3ef82f2 feat: Drizzle schema + SQLite 初始化 2026-05-05 05:53:19 +08:00
MikiVL 533134a21b chore: 安装 drizzle-orm/better-sqlite3/bcryptjs/jsonwebtoken 2026-05-05 05:52:20 +08:00
MikiVL 0b21ff0644 docs: 用户认证+云同步+评论系统设计文档 2026-05-05 05:09:50 +08:00
35 changed files with 5114 additions and 428 deletions
+2
View File
@@ -1 +1,3 @@
ANTHROPIC_API_KEY=sk-ant-your-key-here
JWT_SECRET=change-this-to-a-random-secret
JWT_SECRET=change-this-to-a-random-secret
+46 -23
View File
@@ -1,51 +1,74 @@
#!/usr/bin/env bash
# deploy.sh — 本地运行,构建并上传到 VPS
# 架构:Caddy (host) + Node.js systemd service (host)
set -euo pipefail
VPS_USER="root"
VPS_HOST="your.vps.ip" # ← 替换为你的 VPS IP 或域名
VPS_HOST="mikivl"
VPS_DIR="/opt/mikivl"
# 主页文件位置(使用 mikivl.online 主仓库的最新版本)
HOMEPAGE_SRC="../mikivl.online/index.html"
info() { printf "\033[36m[INFO]\033[0m %s\n" "$*"; }
ok() { printf "\033[32m[OK]\033[0m %s\n" "$*"; }
die() { printf "\033[31m[ERROR]\033[0m %s\n" "$*" >&2; exit 1; }
# ── Step 1: 本地构建 ──────────────────────────────────────────────────────────
info "Building Vite app (base=/app/) ..."
info "Building Vite app ..."
npm run build
ok "Build complete → dist/"
# ── Step 2: 上传文件 ──────────────────────────────────────────────────────────
info "Uploading to ${VPS_USER}@${VPS_HOST}:${VPS_DIR} ..."
info "Uploading to ${VPS_HOST}:${VPS_DIR} ..."
ssh "${VPS_USER}@${VPS_HOST}" "mkdir -p ${VPS_DIR}/{homepage,app,nginx,program1/server,program1/node_modules}"
# 个人主页(从主仓库上传最新版本)
if [ -f "${HOMEPAGE_SRC}" ]; then
scp "${HOMEPAGE_SRC}" "${VPS_HOST}:${VPS_DIR}/homepage/index.html"
ok "Homepage uploaded from ${HOMEPAGE_SRC}"
else
die "Homepage source not found: ${HOMEPAGE_SRC}"
fi
# 个人主页
scp homepage/index.html "${VPS_USER}@${VPS_HOST}:${VPS_DIR}/homepage/"
# App 构建产物
rsync -az --delete dist/ "${VPS_USER}@${VPS_HOST}:${VPS_DIR}/app/"
# App 构建产物 → note 目录(Caddy 的 note.mikivl.online root)
rsync -az --delete dist/ "${VPS_HOST}:${VPS_DIR}/note/"
# 后端源码
rsync -az server/ "${VPS_USER}@${VPS_HOST}:${VPS_DIR}/program1/server/"
scp package.json package-lock.json "${VPS_USER}@${VPS_HOST}:${VPS_DIR}/program1/"
# Nginx 配置 + Docker Compose
scp nginx/default.conf "${VPS_USER}@${VPS_HOST}:${VPS_DIR}/nginx/"
scp docker-compose.yml "${VPS_USER}@${VPS_HOST}:${VPS_DIR}/"
rsync -az server/ "${VPS_HOST}:${VPS_DIR}/program1/server/"
scp package.json package-lock.json "${VPS_HOST}:${VPS_DIR}/program1/"
ok "Upload complete."
# ── Step 3: VPS 上安装依赖并重启服务 ─────────────────────────────────────────
info "Restarting services on VPS ..."
info "Installing dependencies and restarting program1 service on VPS ..."
ssh "${VPS_USER}@${VPS_HOST}" bash <<'REMOTE'
ssh "${VPS_HOST}" bash <<'REMOTE'
set -euo pipefail
NODE=/root/.nvm/versions/node/v24.15.0/bin/node
NPM=/root/.nvm/versions/node/v24.15.0/bin/npm
cd /opt/mikivl/program1
npm ci --omit=dev
cd /opt/mikivl
docker compose up -d --remove-orphans
docker compose ps
${NPM} ci --ignore-scripts
# 检查 better-sqlite3 是否需要重新编译
REQUIRED_VERSION=$(${NODE} -e "process.stdout.write(String(process.versions.modules))")
BUILT_VERSION=$(${NODE} -e "
try {
const b = require('./node_modules/better-sqlite3');
process.stdout.write('ok');
} catch(e) {
process.stdout.write('rebuild');
}
" 2>/dev/null || echo "rebuild")
if [ "${BUILT_VERSION}" = "rebuild" ]; then
echo "[INFO] Rebuilding better-sqlite3 for Node $(${NODE} -v) ..."
cd node_modules/better-sqlite3
${NODE} /root/.nvm/versions/node/v24.15.0/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js rebuild
cd ../..
fi
systemctl restart program1.service
sleep 3
systemctl is-active program1.service && echo "[OK] program1.service is running"
REMOTE
ok "Deployment complete! → https://www.mikivl.online"
ok "Deployment complete! → https://note.mikivl.online"
+1 -1
View File
@@ -8,7 +8,7 @@ services:
- MODELS_FILE=/app/models_data/models.json
- NODE_ENV=production
volumes:
- ./program1:/app:ro
- ./program1:/app
- models_data:/app/models_data
expose:
- "3001"
@@ -0,0 +1,238 @@
# 用户认证 + 云端同步 + 评论系统设计文档
**日期:** 2026-05-05
**作者:** MikiVL
**状态:** 已批准
---
## 背景与目标
当前笔记应用(www.mikivl.online/app)的所有数据存储在浏览器 IndexedDB 中,清除浏览器数据或换设备即丢失。本次扩展目标:
1. 添加用户账号系统(用户名+密码注册/登录)
2. 登录用户的笔记双存——本地 IndexedDB 继续作为缓存,服务端 SQLite 持久化
3. 云存储功能需邀请码激活(预生成 10 个)
4. 主页(www.mikivl.online)新增评论区,登录用户可发评论
---
## 架构总览
```
前端(React) 服务端(Hono + Node.js) 数据库(SQLite + Drizzle)
──────────────── ────────────────────── ───────────────────────────
IndexedDB(本地) ←→ /api/auth/* users
/api/notes/* ←──── notes
/api/folders/* ←──── folders
/api/comments/* ←──── comments
/api/ai/*(已有) invite_codes
```
---
## 数据库 Schema
### users
```sql
id TEXT PRIMARY KEY -- nanoid
username TEXT UNIQUE NOT NULL
passwordHash TEXT NOT NULL -- bcrypt hash
cloudEnabled INTEGER DEFAULT 0 -- 0=未激活,1=已激活(填写邀请码后)
createdAt INTEGER NOT NULL
```
### notes
```sql
id TEXT PRIMARY KEY
userId TEXT NOT NULL REFERENCES users(id)
title TEXT NOT NULL DEFAULT ''
content TEXT NOT NULL DEFAULT ''
folderId TEXT -- null = 根目录
tags TEXT NOT NULL DEFAULT '[]' -- JSON 数组
starred INTEGER DEFAULT 0
wordCount INTEGER DEFAULT 0
deletedAt INTEGER -- null = 未删除,软删除时间戳
createdAt INTEGER NOT NULL
updatedAt INTEGER NOT NULL
```
### folders
```sql
id TEXT PRIMARY KEY
userId TEXT NOT NULL REFERENCES users(id)
name TEXT NOT NULL
parentId TEXT -- null = 根目录
order INTEGER DEFAULT 0
createdAt INTEGER NOT NULL
```
### comments
```sql
id TEXT PRIMARY KEY
userId TEXT NOT NULL REFERENCES users(id)
content TEXT NOT NULL
createdAt INTEGER NOT NULL
```
### invite_codes
```sql
code TEXT PRIMARY KEY
usedByUserId TEXT -- null = 未使用
usedAt INTEGER -- null = 未使用
```
---
## 认证方案
- **JWT**,签发时有效期 30 天,存储在 `localStorage`(key: `mikivl_token`)
- 无 refresh token(30 天足够,过期重新登录)
- 密码使用 bcrypt hash(cost factor 10)
- 所有 `/api/notes/*`、`/api/folders/*` 接口需携带 `Authorization: Bearer <token>`
---
## 同步策略
**本地优先,登录后同步:**
1. **未登录**:完全使用 IndexedDB,功能不受影响
2. **登录时**:执行全量同步——拉取云端数据与本地合并,以 `updatedAt` 较新的为准
3. **本地写操作**(create/update/delete):操作完成后立即异步推送到云端(fire-and-forget,失败静默)
4. **冲突处理**:同一条笔记本地和云端都有修改,取 `updatedAt` 较大的版本
**同步接口:**
- `GET /api/notes/sync` — 返回该用户全量笔记和文件夹
- `POST /api/notes/sync` — 批量 upsert 笔记和文件夹(幂等)
---
## API 接口定义
### 认证
```
POST /api/auth/register
Body: { username: string, password: string }
Response: { token: string, user: { id, username, cloudEnabled } }
POST /api/auth/login
Body: { username: string, password: string }
Response: { token: string, user: { id, username, cloudEnabled } }
POST /api/auth/activate
Header: Authorization: Bearer <token>
Body: { code: string }
Response: { success: true }
```
### 同步
```
GET /api/notes/sync
Header: Authorization: Bearer <token>
Response: { notes: Note[], folders: Folder[] }
POST /api/notes/sync
Header: Authorization: Bearer <token>
Body: { notes: Note[], folders: Folder[] }
Response: { synced: number }
```
### 笔记 CRUD(单条操作,供实时同步用)
```
PUT /api/notes/:id -- upsert 单条笔记
DELETE /api/notes/:id -- 软删除(设置 deletedAt)
PUT /api/folders/:id -- upsert 单条文件夹
DELETE /api/folders/:id -- 删除文件夹
```
### 评论
```
GET /api/comments -- 获取所有评论(公开)
POST /api/comments -- 发布评论(需登录)
Header: Authorization: Bearer <token>
Body: { content: string }
Response: { id, username, content, createdAt }
```
---
## 前端改动
### 新增文件
- `src/lib/auth.ts` — JWT 存取、login/register/activate API 封装
- `src/lib/sync.ts` — 全量同步、单条推送、冲突合并逻辑
- `src/components/auth/LoginModal.tsx` — 登录/注册弹窗(Tab 切换),含邀请码激活入口
- `src/components/auth/UserMenu.tsx` — 侧边栏底部用户区域(头像、用户名、云同步状态、登出)
### 修改文件
- `src/stores/appStore.ts` — 添加 `currentUser`、`syncStatus` state;写操作(createNote/updateNote/deleteNote/restoreNote/createFolder/updateFolder/deleteFolder)完成后触发 `sync.pushOne()`
- `src/components/sidebar/Sidebar.tsx` — 底部添加 `<UserMenu />`
- `homepage/index.html` — 底部新增评论区(读取 `/api/comments`,登录用户可发评论)
### appStore 新增 state
```typescript
currentUser: { id: string; username: string; cloudEnabled: boolean } | null
syncStatus: 'idle' | 'syncing' | 'error'
```
---
## 服务端文件结构
```
server/
├── index.ts (已有,注册新路由)
├── db.ts (Drizzle 初始化,schema 定义,DB 文件路径:/opt/mikivl/data/app.db)
├── middleware/
│ └── auth.ts (JWT 验证,注入 c.set('userId', ...))
└── routes/
├── auth.ts (register/login/activate)
├── notes.ts (sync GET/POST + 单条 CRUD)
├── folders.ts (单条 CRUD)
└── comments.ts (list/create)
```
---
## 邀请码
预生成 10 个,数据库初始化时写入:
```
MIKI-A7X2-KP9Q
MIKI-B3N8-WR4L
MIKI-C6M1-ZT7Y
MIKI-D9F5-HJ2V
MIKI-E4K3-QN6U
MIKI-F8R7-XG1S
MIKI-G2W4-LB5E
MIKI-H5T9-MC8D
MIKI-J1P6-VF3O
MIKI-K7Q2-YH4N
```
---
## 部署变更
- 服务端新增依赖:`drizzle-orm`、`better-sqlite3`、`bcryptjs`、`jsonwebtoken`
- SQLite 数据库文件:`/opt/mikivl/data/app.db`(首次启动自动创建)
- 服务器需创建目录:`mkdir -p /opt/mikivl/data`
- PM2 重启 hono-server 后生效
---
## 分拆子项目顺序
由于子系统间有依赖关系,按以下顺序实现:
1. **服务端基础层**(db.ts + schema + auth 路由)
2. **前端认证**(LoginModal + UserMenu + auth.ts + appStore 扩展)
3. **云同步**(sync.ts + notes/folders 路由 + appStore 写操作触发同步)
4. **首页评论**(comments 路由 + homepage/index.html 评论区)
-371
View File
@@ -1,371 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="MikiVL — 独立开发者,专注构建 AI 驱动的生产力工具" />
<title>MikiVL — 开发项目</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d0d11;
--bg-card: #13131a;
--bg-hover: #1a1a24;
--border: #22222e;
--text: #e2e2ec;
--muted: #7070a0;
--faint: #3a3a55;
--accent: #818cf8;
--accent2: #34d399;
--tag-bg: #1e1b4b;
--tag-text: #a5b4fc;
}
html { scroll-behavior: smooth; }
body {
background: var(--bg);
color: var(--text);
font-family: 'SF Mono', 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
font-size: 14px;
line-height: 1.7;
-webkit-font-smoothing: antialiased;
}
/* ── Nav ── */
nav {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
display: flex; align-items: center; justify-content: space-between;
padding: 0 clamp(1.5rem, 5vw, 4rem);
height: 56px;
background: rgba(13,13,17,0.88);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-bottom: 1px solid var(--border);
}
.nav-logo {
font-size: 1rem; font-weight: 700; color: var(--accent);
text-decoration: none; letter-spacing: -0.02em;
}
.nav-logo span { color: var(--muted); font-weight: 400; }
.nav-links { display: flex; gap: 2rem; list-style: none; }
.nav-links a {
color: var(--muted); text-decoration: none; font-size: 0.8rem;
letter-spacing: 0.06em; text-transform: uppercase;
transition: color 0.15s;
}
.nav-links a:hover { color: var(--text); }
/* ── Layout ── */
main { max-width: 860px; margin: 0 auto; padding: 0 clamp(1.5rem, 5vw, 2rem); }
section { padding: 6rem 0 2rem; }
section:first-of-type { padding-top: 9rem; }
/* ── Section label ── */
.section-label {
font-size: 0.72rem; letter-spacing: 0.15em; text-transform: uppercase;
color: var(--accent); margin-bottom: 0.5rem;
}
h2 {
font-size: clamp(1.4rem, 3vw, 1.9rem); font-weight: 700;
color: var(--text); margin-bottom: 1.75rem; letter-spacing: -0.03em;
}
/* ── Hero ── */
.hero-greeting {
font-size: 0.85rem; color: var(--accent2); margin-bottom: 0.75rem;
letter-spacing: 0.08em;
}
.hero-name {
font-size: clamp(2.2rem, 7vw, 3.6rem); font-weight: 800;
color: var(--text); letter-spacing: -0.04em; line-height: 1.05;
margin-bottom: 1.25rem;
}
.hero-name em { color: var(--accent); font-style: normal; }
.hero-desc {
font-size: 0.95rem; color: var(--muted); max-width: 500px;
line-height: 1.85; margin-bottom: 2.25rem;
font-family: system-ui, -apple-system, sans-serif;
}
.hero-actions { display: flex; gap: 0.75rem; flex-wrap: wrap; }
.btn {
display: inline-flex; align-items: center; gap: 0.45rem;
padding: 0.55rem 1.1rem;
border-radius: 6px; font-size: 0.82rem;
text-decoration: none; transition: all 0.15s;
font-family: inherit;
}
.btn-primary {
background: var(--accent); color: #0d0d11; border: 1px solid var(--accent);
font-weight: 600;
}
.btn-primary:hover { background: #a5b4fc; border-color: #a5b4fc; }
.btn-ghost {
background: transparent; color: var(--muted); border: 1px solid var(--border);
}
.btn-ghost:hover { border-color: var(--faint); color: var(--text); background: var(--bg-hover); }
/* ── Divider ── */
.divider { border: none; border-top: 1px solid var(--border); margin: 0; }
/* ── Projects ── */
.projects-grid { display: grid; gap: 1rem; }
.project-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.5rem 1.6rem;
text-decoration: none;
display: block;
transition: border-color 0.15s, background 0.15s, transform 0.18s;
position: relative; overflow: hidden;
}
.project-card::before {
content: '';
position: absolute; top: 0; left: 0; right: 0; height: 2px;
background: linear-gradient(90deg, var(--accent), var(--accent2));
opacity: 0; transition: opacity 0.2s;
}
.project-card:hover {
border-color: var(--faint);
background: var(--bg-hover);
transform: translateY(-2px);
}
.project-card:hover::before { opacity: 1; }
.project-header {
display: flex; align-items: flex-start;
justify-content: space-between; gap: 1rem; margin-bottom: 0.5rem;
}
.project-name {
font-size: 1rem; font-weight: 700; color: var(--text);
letter-spacing: -0.02em;
}
.project-status {
font-size: 0.7rem; padding: 0.15rem 0.5rem;
border-radius: 20px; letter-spacing: 0.05em;
background: #052e16; color: #6ee7b7;
white-space: nowrap; margin-top: 2px;
}
.project-desc {
font-size: 0.85rem; color: var(--muted); line-height: 1.75;
font-family: system-ui, -apple-system, sans-serif;
margin-bottom: 1.1rem;
}
.project-tags { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.tag {
font-size: 0.7rem; padding: 0.18rem 0.5rem;
background: var(--tag-bg); color: var(--tag-text);
border-radius: 4px; letter-spacing: 0.03em;
}
.tag.green { background: #052e16; color: #6ee7b7; }
.tag.purple { background: #2e1065; color: #c4b5fd; }
.tag.orange { background: #431407; color: #fdba74; }
.tag.sky { background: #082f49; color: #7dd3fc; }
/* ── Skills ── */
.skills-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 1rem;
}
.skill-group {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.1rem 1.2rem;
}
.skill-group-title {
font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase;
color: var(--accent); margin-bottom: 0.8rem;
}
.skill-list { list-style: none; }
.skill-list li {
font-size: 0.83rem; color: var(--muted); padding: 0.22rem 0;
display: flex; align-items: center; gap: 0.5rem;
}
.skill-list li::before { content: '▸'; color: var(--faint); font-size: 0.65rem; }
/* ── Contact ── */
.contact-links { display: flex; flex-direction: column; gap: 0.9rem; }
.contact-link {
display: inline-flex; align-items: center; gap: 0.75rem;
color: var(--muted); text-decoration: none; font-size: 0.88rem;
transition: color 0.15s; width: fit-content;
}
.contact-link:hover { color: var(--accent); }
.contact-icon { color: var(--faint); font-size: 1rem; width: 1.2rem; text-align: center; }
/* ── Footer ── */
footer {
border-top: 1px solid var(--border);
padding: 2rem clamp(1.5rem, 5vw, 4rem);
text-align: center;
color: var(--faint); font-size: 0.75rem;
margin-top: 4rem;
}
/* ── Cursor blink ── */
.cursor {
display: inline-block; width: 3px; height: 0.85em;
background: var(--accent); margin-left: 1px;
vertical-align: text-bottom; border-radius: 1px;
animation: blink 1.1s step-end infinite;
}
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0} }
/* ── Responsive ── */
@media (max-width: 600px) {
.nav-links { gap: 1.2rem; }
.skills-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 400px) {
.nav-links li:nth-child(3), .nav-links li:nth-child(4) { display: none; }
}
</style>
</head>
<body>
<nav>
<a class="nav-logo" href="#about">MikiVL<span>.online</span></a>
<ul class="nav-links">
<li><a href="#about">about</a></li>
<li><a href="#projects">projects</a></li>
<li><a href="#skills">skills</a></li>
<li><a href="#contact">contact</a></li>
</ul>
</nav>
<main>
<!-- About -->
<section id="about">
<p class="hero-greeting">// hello, world</p>
<h1 class="hero-name">MikiVL<em>.</em><span class="cursor"></span></h1>
<p class="hero-desc">
独立开发者,专注于构建 AI 驱动的生产力工具。<br>
喜欢探索 AI 与实用工具的交叉地带,用代码解决真实问题。
</p>
<div class="hero-actions">
<a class="btn btn-primary" href="#projects">查看项目 →</a>
<a class="btn btn-ghost" href="#contact">联系我</a>
</div>
</section>
<hr class="divider" />
<!-- Projects -->
<section id="projects">
<p class="section-label">// projects</p>
<h2>开发项目</h2>
<div class="projects-grid">
<a class="project-card" href="/app/" target="_blank" rel="noopener">
<div class="project-header">
<span class="project-name">笔记</span>
<span class="project-status">● 上线中</span>
</div>
<p class="project-desc">
AI 驱动的个人笔记工具。富文本编辑器,支持 AI 续写、润色、摘要、翻译,
多文件夹管理,标签系统,导入导出(MD / DOCX / PDF),数据本地存储,隐私优先。
</p>
<div class="project-tags">
<span class="tag">React 19</span>
<span class="tag">TypeScript</span>
<span class="tag sky">Vite</span>
<span class="tag green">Hono</span>
<span class="tag purple">Claude API</span>
<span class="tag orange">TipTap</span>
<span class="tag">IndexedDB</span>
</div>
</a>
<a class="project-card" href="/sushu/" target="_blank" rel="noopener">
<div class="project-header">
<span class="project-name">素数计算器</span>
<span class="project-status">● 上线中</span>
</div>
<p class="project-desc">
在线素数计算工具。支持判断任意正整数是否为素数,以及生成指定范围内的素数列表。
</p>
<div class="project-tags">
<span class="tag sky">Vite</span>
<span class="tag">Vanilla JS</span>
</div>
</a>
</div>
</section>
<hr class="divider" />
<!-- Skills -->
<section id="skills">
<p class="section-label">// skills</p>
<h2>技术栈</h2>
<div class="skills-grid">
<div class="skill-group">
<p class="skill-group-title">Frontend</p>
<ul class="skill-list">
<li>React / TypeScript</li>
<li>Vite / TailwindCSS</li>
<li>TipTap / ProseMirror</li>
<li>Framer Motion</li>
</ul>
</div>
<div class="skill-group">
<p class="skill-group-title">Backend</p>
<ul class="skill-list">
<li>Node.js / Hono</li>
<li>REST / SSE</li>
<li>Docker / Nginx</li>
<li>Linux / VPS</li>
</ul>
</div>
<div class="skill-group">
<p class="skill-group-title">AI</p>
<ul class="skill-list">
<li>Anthropic Claude API</li>
<li>Prompt Engineering</li>
<li>Streaming (SSE)</li>
<li>RAG / Embeddings</li>
</ul>
</div>
<div class="skill-group">
<p class="skill-group-title">Tooling</p>
<ul class="skill-list">
<li>Git / GitHub</li>
<li>Vitest / ESLint</li>
<li>Claude Code</li>
<li>SSH / rsync</li>
</ul>
</div>
</div>
</section>
<hr class="divider" />
<!-- Contact -->
<section id="contact">
<p class="section-label">// contact</p>
<h2>联系方式</h2>
<div class="contact-links">
<a class="contact-link" href="https://github.com/MikiVL" target="_blank" rel="noopener">
<span class="contact-icon">⌥</span>
github.com/MikiVL
</a>
<a class="contact-link" href="mailto:hi@mikivl.online">
<span class="contact-icon">@</span>
hi@mikivl.online
</a>
</div>
</section>
</main>
<footer>
<p>© 2026 MikiVL · built with ♥ and <a href="https://claude.ai/code" style="color:var(--faint);text-decoration:none;" target="_blank">Claude Code</a></p>
</footer>
</body>
</html>
+1672 -3
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -47,16 +47,21 @@
"@tiptap/extension-underline": "^3.22.5",
"@tiptap/react": "^3.22.5",
"@tiptap/starter-kit": "^3.22.5",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dexie": "^4.4.2",
"docx": "^9.6.1",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"framer-motion": "^12.38.0",
"hono": "^4.12.16",
"jsonwebtoken": "^9.0.3",
"lowlight": "^3.3.0",
"lucide-react": "^1.14.0",
"mammoth": "^1.12.0",
"nodemailer": "^8.0.7",
"pdfjs-dist": "^5.7.284",
"react": "^19.2.5",
"react-dom": "^19.2.5",
@@ -68,12 +73,17 @@
"@eslint/js": "^10.0.1",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.4",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.12.2",
"@types/nodemailer": "^8.0.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.5.0",
"concurrently": "^9.2.1",
"drizzle-kit": "^0.31.10",
"eslint": "^10.2.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
+9 -1
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 724 B

+174
View File
@@ -0,0 +1,174 @@
import Database from 'better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
import path from 'path'
import fs from 'fs'
const DB_PATH = process.env.DB_PATH || path.join(process.cwd(), 'data', 'app.db')
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true })
const sqlite = new Database(DB_PATH)
sqlite.pragma('journal_mode = WAL')
sqlite.pragma('foreign_keys = ON')
export const db = drizzle(sqlite)
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
username: text('username').notNull().unique(),
passwordHash: text('password_hash').notNull(),
cloudEnabled: integer('cloud_enabled', { mode: 'boolean' }).notNull().default(false),
role: text('role').notNull().default('user'),
banned: integer('banned', { mode: 'boolean' }).notNull().default(false),
nickname: text('nickname'),
avatar: text('avatar'),
email: text('email'),
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false),
pendingEmail: text('pending_email'),
emailVerifyCode: text('email_verify_code'),
emailVerifyExpiry: integer('email_verify_expiry'),
resetCode: text('reset_code'),
resetCodeExpiry: integer('reset_code_expiry'),
createdAt: integer('created_at').notNull(),
})
export const notes = sqliteTable('notes', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
title: text('title').notNull().default(''),
content: text('content').notNull().default(''),
folderId: text('folder_id'),
tags: text('tags').notNull().default('[]'),
starred: integer('starred', { mode: 'boolean' }).notNull().default(false),
wordCount: integer('word_count').notNull().default(0),
deletedAt: integer('deleted_at'),
createdAt: integer('created_at').notNull(),
updatedAt: integer('updated_at').notNull(),
})
export const folders = sqliteTable('folders', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
name: text('name').notNull(),
parentId: text('parent_id'),
order: integer('order').notNull().default(0),
createdAt: integer('created_at').notNull(),
})
export const comments = sqliteTable('comments', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
content: text('content').notNull(),
createdAt: integer('created_at').notNull(),
})
export const inviteCodes = sqliteTable('invite_codes', {
code: text('code').primaryKey(),
usedByUserId: text('used_by_user_id'),
usedAt: integer('used_at'),
})
export const siteContent = sqliteTable('site_content', {
key: text('key').primaryKey(),
value: text('value').notNull(),
})
export function initDb() {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
cloud_enabled INTEGER NOT NULL DEFAULT 0,
email TEXT,
email_verified INTEGER NOT NULL DEFAULT 0,
email_verify_code TEXT,
email_verify_expiry INTEGER,
reset_code TEXT,
reset_code_expiry INTEGER,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
folder_id TEXT,
tags TEXT NOT NULL DEFAULT '[]',
starred INTEGER NOT NULL DEFAULT 0,
word_count INTEGER NOT NULL DEFAULT 0,
deleted_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
name TEXT NOT NULL,
parent_id TEXT,
"order" INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS comments (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
content TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS invite_codes (
code TEXT PRIMARY KEY,
used_by_user_id TEXT,
used_at INTEGER
);
`)
const INVITE_CODES = [
'MIKI-A7X2-KP9Q', 'MIKI-B3N8-WR4L', 'MIKI-C6M1-ZT7Y',
'MIKI-D9F5-HJ2V', 'MIKI-E4K3-QN6U', 'MIKI-F8R7-XG1S',
'MIKI-G2W4-LB5E', 'MIKI-H5T9-MC8D', 'MIKI-J1P6-VF3O',
'MIKI-K7Q2-YH4N',
]
const insert = sqlite.prepare(`INSERT OR IGNORE INTO invite_codes (code) VALUES (?)`)
for (const code of INVITE_CODES) insert.run(code)
// 迁移:为已有数据库添加邮箱相关字段
const cols = sqlite.prepare(`PRAGMA table_info(users)`).all() as { name: string }[]
const colNames = cols.map(c => c.name)
if (!colNames.includes('email')) sqlite.exec(`ALTER TABLE users ADD COLUMN email TEXT`)
if (!colNames.includes('email_verified')) sqlite.exec(`ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0`)
if (!colNames.includes('email_verify_code')) sqlite.exec(`ALTER TABLE users ADD COLUMN email_verify_code TEXT`)
if (!colNames.includes('email_verify_expiry')) sqlite.exec(`ALTER TABLE users ADD COLUMN email_verify_expiry INTEGER`)
if (!colNames.includes('reset_code')) sqlite.exec(`ALTER TABLE users ADD COLUMN reset_code TEXT`)
if (!colNames.includes('reset_code_expiry')) sqlite.exec(`ALTER TABLE users ADD COLUMN reset_code_expiry INTEGER`)
if (!colNames.includes('nickname')) sqlite.exec(`ALTER TABLE users ADD COLUMN nickname TEXT`)
if (!colNames.includes('avatar')) sqlite.exec(`ALTER TABLE users ADD COLUMN avatar TEXT`)
if (!colNames.includes('pending_email')) sqlite.exec(`ALTER TABLE users ADD COLUMN pending_email TEXT`)
if (!colNames.includes('role')) sqlite.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`)
if (!colNames.includes('banned')) sqlite.exec(`ALTER TABLE users ADD COLUMN banned INTEGER NOT NULL DEFAULT 0`)
// 将 MikiVL 用户设为管理员
sqlite.exec(`UPDATE users SET role = 'admin' WHERE username = 'MikiVL'`)
// 创建站点内容表
sqlite.exec(`
CREATE TABLE IF NOT EXISTS site_content (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`)
// 预设站点内容默认值
const siteDefaults = [
['tagline', '探索·创造·分享'],
['subtitle', '个人项目与技术探索的集合地'],
['news', JSON.stringify([
{ date: '2025-06', text: '开放笔记系统公测' },
{ date: '2025-03', text: '网站正式上线' },
])],
['projects', JSON.stringify([
{ title: 'MikiNote', desc: '基于 AI 的个人笔记系统,支持云同步', link: 'https://note.mikivl.online/app/' },
])],
]
const insertContent = sqlite.prepare(`INSERT OR IGNORE INTO site_content (key, value) VALUES (?, ?)`)
for (const [key, value] of siteDefaults) insertContent.run(key, value)
}
+32 -9
View File
@@ -1,10 +1,17 @@
import 'dotenv/config'
import dotenv from 'dotenv'
import path from 'node:path'
dotenv.config({ path: path.resolve(process.cwd(), '.env') })
import { Hono } from 'hono'
import { serve } from '@hono/node-server'
import { cors } from 'hono/cors'
import Anthropic from '@anthropic-ai/sdk'
import fs from 'node:fs'
import path from 'node:path'
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())
@@ -58,13 +65,27 @@ function getActiveModel(): string {
}
// ── Model CRUD endpoints ──────────────────────────────────────────────────────
app.get('/api/models', (c) => {
app.route('/api/auth', authRouter)
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/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 = {
@@ -81,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 })
@@ -89,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)
@@ -146,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()
@@ -191,6 +212,8 @@ app.post('/api/ai/stream', async (c) => {
)
})
serve({ fetch: app.fetch, port: 3001 }, () => {
console.log('AI proxy server running on http://localhost:3001')
initDb()
serve({ fetch: app.fetch, port: Number(process.env.PORT ?? 3003) }, () => {
console.log(`AI proxy server running on http://localhost:${process.env.PORT ?? 3003}`)
})
+43
View File
@@ -0,0 +1,43 @@
import nodemailer from 'nodemailer'
function createTransporter() {
return nodemailer.createTransport({
host: 'smtp.126.com',
port: 465,
secure: true,
auth: {
user: 'mikivl@126.com',
pass: process.env.EMAIL_PASS!,
},
})
}
export async function sendVerifyCode(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>`,
})
}
export async function sendResetCode(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>`,
})
}
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>`,
})
}
+47
View File
@@ -0,0 +1,47 @@
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 }
declare module 'hono' {
interface ContextVariableMap {
userId: string
username: string
}
}
export const requireAuth = 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
c.set('userId', payload.userId)
c.set('username', payload.username)
await next()
} catch {
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
View File
@@ -0,0 +1,94 @@
import { Hono } from 'hono'
import { eq } from 'drizzle-orm'
import { db, users, comments, inviteCodes, siteContent } from '../db'
import { requireAdmin } from '../middleware/auth'
export const adminRouter = new Hono()
adminRouter.use('*', requireAdmin)
function nanoid() {
return Math.random().toString(36).slice(2, 11) + Date.now().toString(36)
}
// 站点内容读取
adminRouter.get('/site-content', (c) => {
const rows = db.select().from(siteContent).all()
const result: Record<string, string> = {}
for (const row of rows) result[row.key] = row.value
return c.json(result)
})
// 站点内容批量更新
adminRouter.put('/site-content', async (c) => {
const body = await c.req.json<Record<string, string>>()
for (const [key, value] of Object.entries(body)) {
if (typeof value !== 'string') continue
db.insert(siteContent).values({ key, value }).onConflictDoUpdate({ target: siteContent.key, set: { value } }).run()
}
return c.json({ success: true })
})
// 用户列表
adminRouter.get('/users', (c) => {
const rows = db.select({
id: users.id,
username: users.username,
role: users.role,
banned: users.banned,
cloudEnabled: users.cloudEnabled,
email: users.email,
createdAt: users.createdAt,
}).from(users).all()
return c.json(rows)
})
// 封禁用户
adminRouter.post('/users/:id/ban', (c) => {
const { id } = c.req.param()
const [user] = db.select({ role: users.role }).from(users).where(eq(users.id, id)).all()
if (!user) return c.json({ error: '用户不存在' }, 404)
if (user.role === 'admin') return c.json({ error: '不能封禁管理员' }, 403)
db.update(users).set({ banned: true }).where(eq(users.id, id)).run()
return c.json({ success: true })
})
// 解封用户
adminRouter.post('/users/:id/unban', (c) => {
const { id } = c.req.param()
const [user] = db.select({ id: users.id }).from(users).where(eq(users.id, id)).all()
if (!user) return c.json({ error: '用户不存在' }, 404)
db.update(users).set({ banned: false }).where(eq(users.id, id)).run()
return c.json({ success: true })
})
// 评论列表
adminRouter.get('/comments', (c) => {
const rows = db.select({
id: comments.id,
content: comments.content,
createdAt: comments.createdAt,
userId: comments.userId,
username: users.username,
}).from(comments).leftJoin(users, eq(comments.userId, users.id)).all()
return c.json(rows)
})
// 删除评论
adminRouter.delete('/comments/:id', (c) => {
const { id } = c.req.param()
db.delete(comments).where(eq(comments.id, id)).run()
return c.json({ success: true })
})
// 邀请码列表
adminRouter.get('/invite-codes', (c) => {
const rows = db.select().from(inviteCodes).all()
return c.json(rows)
})
// 生成新邀请码
adminRouter.post('/invite-codes', (c) => {
const code = 'MIKI-' + nanoid().toUpperCase().slice(0, 4) + '-' + nanoid().toUpperCase().slice(0, 4)
db.insert(inviteCodes).values({ code }).run()
return c.json({ code })
})
+252
View File
@@ -0,0 +1,252 @@
import { Hono } from 'hono'
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import { eq } from 'drizzle-orm'
import { db, users, inviteCodes } from '../db'
import { requireAuth } from '../middleware/auth'
import { sendVerifyCode, sendResetCode, sendRegisterCode } from '../lib/email'
export const authRouter = new Hono()
function nanoid() {
return Math.random().toString(36).slice(2, 11) + Date.now().toString(36)
}
function randomCode() {
return String(Math.floor(100000 + Math.random() * 900000))
}
// 待注册信息临时存储(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 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 code = randomCode()
registerPending.set(email, { username, passwordHash, code, expiry: Date.now() + 10 * 60 * 1000, sentAt: Date.now() })
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) => {
const { username, password } = await c.req.json<{ username: string; password: string }>()
if (!username || !password) return c.json({ error: '用户名和密码不能为空' }, 400)
const [user] = db.select().from(users).where(eq(users.username, username)).all()
if (!user) return c.json({ error: '用户名或密码错误' }, 401)
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, role: user.role, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname ?? null, avatar: user.avatar ?? null } })
})
authRouter.post('/activate', requireAuth, async (c) => {
const userId = c.get('userId')
const { code } = await c.req.json<{ code: string }>()
if (!code) return c.json({ error: '邀请码不能为空' }, 400)
const [invite] = db.select().from(inviteCodes).where(eq(inviteCodes.code, code.trim().toUpperCase())).all()
if (!invite) return c.json({ error: '邀请码不存在' }, 404)
if (invite.usedByUserId) return c.json({ error: '邀请码已被使用' }, 409)
db.update(inviteCodes).set({ usedByUserId: userId, usedAt: Date.now() }).where(eq(inviteCodes.code, code.trim().toUpperCase())).run()
db.update(users).set({ cloudEnabled: true }).where(eq(users.id, userId)).run()
return c.json({ success: true })
})
// 获取当前用户信息
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, role: user.role, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname ?? null, avatar: user.avatar ?? null })
})
// 更新个人资料(昵称、头像)
authRouter.put('/me', requireAuth, async (c) => {
const userId = c.get('userId')
const { nickname, avatar } = await c.req.json<{ nickname?: string; avatar?: string }>()
const updates: Record<string, any> = {}
if (nickname !== undefined) {
if (nickname.length > 20) return c.json({ error: '昵称不超过 20 字' }, 400)
updates.nickname = nickname.trim() || null
}
if (avatar !== undefined) {
// 限制头像大小(base64 约 100KB)
if (avatar && avatar.length > 150000) return c.json({ error: '头像不能超过 100KB' }, 400)
updates.avatar = avatar || null
}
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, role: user.role, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname ?? null, avatar: user.avatar ?? null })
})
// 发送邮箱绑定验证码
authRouter.post('/email/send-verify', requireAuth, async (c) => {
const userId = c.get('userId')
const { email } = await c.req.json<{ email: string }>()
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return c.json({ error: '邮箱格式不正确' }, 400)
// 检查邮箱是否已被其他已验证用户绑定
const existing = db.select().from(users).where(eq(users.email, email)).all()
if (existing.some(u => u.id !== userId)) return c.json({ error: '该邮箱已被其他账号绑定' }, 409)
// 60 秒冷却:上次发送时间在 60 秒内则拒绝
const [user] = db.select().from(users).where(eq(users.id, userId)).all()
if (user?.emailVerifyExpiry && user.emailVerifyExpiry - Date.now() > 9 * 60 * 1000) {
return c.json({ error: '请等待 60 秒后再重新发送' }, 429)
}
const code = randomCode()
const expiry = Date.now() + 10 * 60 * 1000
// 只写入 pendingEmail,不修改 email 字段
db.update(users).set({ pendingEmail: email, emailVerifyCode: code, emailVerifyExpiry: expiry }).where(eq(users.id, userId)).run()
try {
await sendVerifyCode(email, code)
} catch (err) {
console.error('[email]', err)
return c.json({ error: '邮件发送失败,请稍后再试' }, 500)
}
return c.json({ success: true })
})
// 验证邮箱绑定验证码
authRouter.post('/email/verify', requireAuth, async (c) => {
const userId = c.get('userId')
const { code } = await c.req.json<{ code: string }>()
if (!code) return c.json({ error: '请输入验证码' }, 400)
const [user] = db.select().from(users).where(eq(users.id, userId)).all()
if (!user) return c.json({ error: '用户不存在' }, 404)
if (!user.emailVerifyCode || user.emailVerifyCode !== code.trim()) return c.json({ error: '验证码错误' }, 400)
if (!user.emailVerifyExpiry || Date.now() > user.emailVerifyExpiry) return c.json({ error: '验证码已过期,请重新发送' }, 400)
// 验证成功:将 pendingEmail 写入正式 email 字段
db.update(users).set({ email: user.pendingEmail, emailVerified: true, pendingEmail: null, emailVerifyCode: null, emailVerifyExpiry: null }).where(eq(users.id, userId)).run()
return c.json({ success: true })
})
// 忘记密码:发送重置验证码
authRouter.post('/forgot-password', async (c) => {
const { email } = await c.req.json<{ email: string }>()
if (!email) return c.json({ error: '请输入邮箱' }, 400)
const [user] = db.select().from(users).where(eq(users.email, email)).all()
// 无论是否找到用户都返回成功,避免枚举攻击
if (!user || !user.emailVerified) return c.json({ success: true })
const code = randomCode()
const expiry = Date.now() + 10 * 60 * 1000
db.update(users).set({ resetCode: code, resetCodeExpiry: expiry }).where(eq(users.id, user.id)).run()
try {
await sendResetCode(email, code)
} catch {
return c.json({ error: '邮件发送失败,请稍后再试' }, 500)
}
return c.json({ success: true })
})
// 重置密码:验证码 + 新密码
authRouter.post('/reset-password', async (c) => {
const { email, code, newPassword } = await c.req.json<{ email: string; code: string; newPassword: string }>()
if (!email || !code || !newPassword) return c.json({ error: '参数不完整' }, 400)
if (newPassword.length < 6) return c.json({ error: '新密码至少 6 位' }, 400)
const [user] = db.select().from(users).where(eq(users.email, email)).all()
if (!user) return c.json({ error: '邮箱未绑定任何账号' }, 404)
if (!user.resetCode || user.resetCode !== code.trim()) return c.json({ error: '验证码错误' }, 400)
if (!user.resetCodeExpiry || Date.now() > user.resetCodeExpiry) return c.json({ error: '验证码已过期,请重新获取' }, 400)
const passwordHash = await bcrypt.hash(newPassword, 10)
db.update(users).set({ passwordHash, resetCode: null, resetCodeExpiry: null }).where(eq(users.id, user.id)).run()
return c.json({ success: true })
})
// 修改密码(需要旧密码)
authRouter.post('/change-password', requireAuth, async (c) => {
const userId = c.get('userId')
const { oldPassword, newPassword } = await c.req.json<{ oldPassword: string; newPassword: string }>()
if (!oldPassword || !newPassword) return c.json({ error: '参数不完整' }, 400)
if (newPassword.length < 6) return c.json({ error: '新密码至少 6 位' }, 400)
const [user] = db.select().from(users).where(eq(users.id, userId)).all()
if (!user) return c.json({ error: '用户不存在' }, 404)
const ok = await bcrypt.compare(oldPassword, user.passwordHash)
if (!ok) return c.json({ error: '当前密码错误' }, 400)
const passwordHash = await bcrypt.hash(newPassword, 10)
db.update(users).set({ passwordHash }).where(eq(users.id, userId)).run()
return c.json({ success: true })
})
+37
View File
@@ -0,0 +1,37 @@
import { Hono } from 'hono'
import { desc, eq } from 'drizzle-orm'
import { db, comments, users } from '../db'
import { requireAuth } from '../middleware/auth'
export const commentsRouter = new Hono()
commentsRouter.get('/', async (c) => {
const rows = db
.select({
id: comments.id,
content: comments.content,
createdAt: comments.createdAt,
username: users.username,
})
.from(comments)
.innerJoin(users, eq(comments.userId, users.id))
.orderBy(desc(comments.createdAt))
.limit(100)
.all()
return c.json(rows)
})
commentsRouter.post('/', requireAuth, async (c) => {
const userId = c.get('userId')
const username = c.get('username')
const { content } = await c.req.json<{ content: string }>()
if (!content?.trim()) return c.json({ error: '评论不能为空' }, 400)
if (content.length > 500) return c.json({ error: '评论不超过 500 字' }, 400)
const id = Math.random().toString(36).slice(2, 11) + Date.now().toString(36)
db.insert(comments).values({ id, userId, content: content.trim(), createdAt: Date.now() }).run()
return c.json({ id, username, content: content.trim(), createdAt: Date.now() })
})
+44
View File
@@ -0,0 +1,44 @@
import { Hono } from 'hono'
import { eq, and } from 'drizzle-orm'
import { db, folders, users } from '../db'
import { requireAuth } from '../middleware/auth'
export const foldersRouter = new Hono()
foldersRouter.use('*', requireAuth)
async function checkCloud(c: any): Promise<string | null> {
const userId = c.get('userId')
const [user] = db.select().from(users).where(eq(users.id, userId)).all()
if (!user?.cloudEnabled) {
await c.json({ error: '请先激活云存储(填写邀请码)' }, 403)
return null
}
return userId
}
foldersRouter.put('/:id', async (c) => {
const userId = await checkCloud(c)
if (!userId) return
const f = await c.req.json<any>()
db.insert(folders).values({
id: c.req.param('id'), userId, name: f.name,
parentId: f.parentId ?? null, order: f.order ?? 0, createdAt: f.createdAt,
}).onConflictDoUpdate({
target: folders.id,
set: { name: f.name, parentId: f.parentId ?? null, order: f.order ?? 0 },
}).run()
return c.json({ ok: true })
})
foldersRouter.delete('/:id', async (c) => {
const userId = await checkCloud(c)
if (!userId) return
db.delete(folders)
.where(and(eq(folders.id, c.req.param('id')), eq(folders.userId, userId)))
.run()
return c.json({ ok: true })
})
+102
View File
@@ -0,0 +1,102 @@
import { Hono } from 'hono'
import { eq, and } from 'drizzle-orm'
import { db, notes, folders, users } from '../db'
import { requireAuth } from '../middleware/auth'
export const notesRouter = new Hono()
notesRouter.use('*', requireAuth)
async function checkCloud(c: any): Promise<string | null> {
const userId = c.get('userId')
const [user] = db.select().from(users).where(eq(users.id, userId)).all()
if (!user?.cloudEnabled) {
await c.json({ error: '请先激活云存储(填写邀请码)' }, 403)
return null
}
return userId
}
notesRouter.get('/sync', async (c) => {
const userId = await checkCloud(c)
if (!userId) return
const userNotes = db.select().from(notes).where(eq(notes.userId, userId)).all()
const userFolders = db.select().from(folders).where(eq(folders.userId, userId)).all()
return c.json({
notes: userNotes.map(n => ({ ...n, tags: JSON.parse(n.tags) })),
folders: userFolders,
})
})
notesRouter.post('/sync', async (c) => {
const userId = await checkCloud(c)
if (!userId) return
const { notes: clientNotes, folders: clientFolders } = await c.req.json<{
notes: any[]
folders: any[]
}>()
for (const n of clientNotes ?? []) {
db.insert(notes).values({
id: n.id, userId, title: n.title, content: n.content,
folderId: n.folderId ?? null, tags: JSON.stringify(n.tags ?? []),
starred: n.starred ? 1 : 0, wordCount: n.wordCount ?? 0,
deletedAt: n.deletedAt ?? null, createdAt: n.createdAt, updatedAt: n.updatedAt,
}).onConflictDoUpdate({
target: notes.id,
set: {
title: n.title, content: n.content, folderId: n.folderId ?? null,
tags: JSON.stringify(n.tags ?? []), starred: n.starred ? 1 : 0,
wordCount: n.wordCount ?? 0, deletedAt: n.deletedAt ?? null, updatedAt: n.updatedAt,
},
}).run()
}
for (const f of clientFolders ?? []) {
db.insert(folders).values({
id: f.id, userId, name: f.name, parentId: f.parentId ?? null,
order: f.order ?? 0, createdAt: f.createdAt,
}).onConflictDoUpdate({
target: folders.id,
set: { name: f.name, parentId: f.parentId ?? null, order: f.order ?? 0 },
}).run()
}
return c.json({ synced: (clientNotes?.length ?? 0) + (clientFolders?.length ?? 0) })
})
notesRouter.put('/:id', async (c) => {
const userId = await checkCloud(c)
if (!userId) return
const n = await c.req.json<any>()
db.insert(notes).values({
id: c.req.param('id'), userId, title: n.title, content: n.content,
folderId: n.folderId ?? null, tags: JSON.stringify(n.tags ?? []),
starred: n.starred ? 1 : 0, wordCount: n.wordCount ?? 0,
deletedAt: n.deletedAt ?? null, createdAt: n.createdAt, updatedAt: n.updatedAt,
}).onConflictDoUpdate({
target: notes.id,
set: {
title: n.title, content: n.content, folderId: n.folderId ?? null,
tags: JSON.stringify(n.tags ?? []), starred: n.starred ? 1 : 0,
wordCount: n.wordCount ?? 0, deletedAt: n.deletedAt ?? null, updatedAt: n.updatedAt,
},
}).run()
return c.json({ ok: true })
})
notesRouter.delete('/:id', async (c) => {
const userId = await checkCloud(c)
if (!userId) return
db.update(notes)
.set({ deletedAt: Date.now() })
.where(and(eq(notes.id, c.req.param('id')), eq(notes.userId, userId)))
.run()
return c.json({ ok: true })
})
+2 -1
View File
@@ -6,7 +6,7 @@ import { useAppStore } from './stores/appStore'
import { seedIfEmpty, deduplicateDB } from './db'
export default function App() {
const { loadAll, theme, focusMode, aiPanelOpen, toggleFocusMode, toggleAiPanel, createNote, activeFolderId } = useAppStore()
const { loadAll, theme, focusMode, aiPanelOpen, toggleFocusMode, toggleAiPanel, createNote, activeFolderId, refreshCurrentUser } = useAppStore()
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
useEffect(() => {
@@ -17,6 +17,7 @@ export default function App() {
seedIfEmpty()
.then(() => deduplicateDB())
.then(() => loadAll())
refreshCurrentUser()
}, [])
useEffect(() => {
+291
View File
@@ -0,0 +1,291 @@
import { useState, useEffect, useCallback } from 'react'
import { X, Users, MessageSquare, Key, FileText, Plus, Ban, CheckCircle, Trash2, RefreshCw } from 'lucide-react'
import {
apiAdminGetSiteContent, apiAdminUpdateSiteContent,
apiAdminGetUsers, apiAdminBanUser, apiAdminUnbanUser,
apiAdminGetComments, apiAdminDeleteComment,
apiAdminGetInviteCodes, apiAdminCreateInviteCode,
type AdminUser, type AdminComment, type InviteCode,
} from '../../lib/auth'
type Tab = 'site' | 'users' | 'comments' | 'invites'
export function AdminPanel({ onClose }: { onClose: () => void }) {
const [tab, setTab] = useState<Tab>('site')
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.6)' }}
onClick={e => { if (e.target === e.currentTarget) onClose() }}
>
<div
className="relative flex flex-col rounded-xl shadow-2xl overflow-hidden"
style={{ width: 720, maxWidth: '96vw', maxHeight: '88vh', background: 'var(--bg)', border: '1px solid var(--border)' }}
>
{/* 标题栏 */}
<div className="flex items-center justify-between px-5 py-4 border-b" style={{ borderColor: 'var(--border)' }}>
<span className="font-semibold text-sm" style={{ color: 'var(--text)' }}>管理员面板</span>
<button onClick={onClose} style={{ color: 'var(--text-faint)' }} className="hover:opacity-70 transition-opacity">
<X size={16} />
</button>
</div>
{/* Tab 导航 */}
<div className="flex gap-1 px-4 pt-3" style={{ borderBottom: '1px solid var(--border)' }}>
{([
['site', FileText, '站点内容'],
['users', Users, '用户管理'],
['comments', MessageSquare, '评论管理'],
['invites', Key, '邀请码'],
] as [Tab, typeof FileText, string][]).map(([id, Icon, label]) => (
<button
key={id}
onClick={() => setTab(id)}
className="flex items-center gap-1.5 px-3 py-2 text-xs rounded-t transition-colors"
style={{
color: tab === id ? 'var(--accent)' : 'var(--text-faint)',
borderBottom: tab === id ? '2px solid var(--accent)' : '2px solid transparent',
marginBottom: -1,
}}
>
<Icon size={12} />
{label}
</button>
))}
</div>
{/* 内容区 */}
<div className="flex-1 overflow-y-auto p-5">
{tab === 'site' && <SiteContentTab />}
{tab === 'users' && <UsersTab />}
{tab === 'comments' && <CommentsTab />}
{tab === 'invites' && <InvitesTab />}
</div>
</div>
</div>
)
}
// ── 站点内容 ──────────────────────────────────────────────────────────────────
function SiteContentTab() {
const [content, setContent] = useState<Record<string, string>>({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState('')
useEffect(() => {
apiAdminGetSiteContent().then(setContent).finally(() => setLoading(false))
}, [])
async function save() {
setSaving(true)
setMsg('')
try {
await apiAdminUpdateSiteContent(content)
setMsg('已保存')
} catch (e: any) {
setMsg(e.message)
} finally {
setSaving(false)
}
}
if (loading) return <div className="text-xs" style={{ color: 'var(--text-faint)' }}>加载中…</div>
const fields: [string, string, boolean][] = [
['tagline', '标语', false],
['subtitle', '副标题', false],
['news', '动态列表(JSON 数组)', true],
['projects', '项目卡片(JSON 数组)', true],
]
return (
<div className="flex flex-col gap-4">
{fields.map(([key, label, multi]) => (
<div key={key} className="flex flex-col gap-1.5">
<label className="text-xs font-medium" style={{ color: 'var(--text-faint)' }}>{label}</label>
{multi ? (
<textarea
rows={6}
value={content[key] ?? ''}
onChange={e => setContent(prev => ({ ...prev, [key]: e.target.value }))}
className="text-xs rounded-lg p-2.5 font-mono resize-y"
style={{ background: 'var(--bg-muted)', border: '1px solid var(--border)', color: 'var(--text)', outline: 'none' }}
/>
) : (
<input
type="text"
value={content[key] ?? ''}
onChange={e => setContent(prev => ({ ...prev, [key]: e.target.value }))}
className="text-xs rounded-lg px-3 py-2"
style={{ background: 'var(--bg-muted)', border: '1px solid var(--border)', color: 'var(--text)', outline: 'none' }}
/>
)}
</div>
))}
<div className="flex items-center gap-3 mt-1">
<button
onClick={save}
disabled={saving}
className="px-4 py-1.5 rounded-lg text-xs font-medium transition-opacity"
style={{ background: 'var(--accent)', color: '#fff', opacity: saving ? 0.6 : 1 }}
>
{saving ? '保存中…' : '保存'}
</button>
{msg && <span className="text-xs" style={{ color: msg === '已保存' ? '#22c55e' : '#ef4444' }}>{msg}</span>}
</div>
</div>
)
}
// ── 用户管理 ──────────────────────────────────────────────────────────────────
function UsersTab() {
const [users, setUsers] = useState<AdminUser[]>([])
const [loading, setLoading] = useState(true)
const load = useCallback(() => {
setLoading(true)
apiAdminGetUsers().then(setUsers).finally(() => setLoading(false))
}, [])
useEffect(() => { load() }, [load])
async function toggleBan(user: AdminUser) {
if (user.banned) await apiAdminUnbanUser(user.id)
else await apiAdminBanUser(user.id)
load()
}
if (loading) return <div className="text-xs" style={{ color: 'var(--text-faint)' }}>加载中…</div>
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between mb-1">
<span className="text-xs" style={{ color: 'var(--text-faint)' }}>共 {users.length} 位用户</span>
<button onClick={load} className="flex items-center gap-1 text-xs" style={{ color: 'var(--text-faint)' }}><RefreshCw size={11} />刷新</button>
</div>
{users.map(u => (
<div key={u.id} className="flex items-center gap-3 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-muted)' }}>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium truncate" style={{ color: 'var(--text)' }}>{u.username}</span>
{u.role === 'admin' && <span className="text-[10px] px-1.5 py-0.5 rounded" style={{ background: '#7c3aed22', color: '#a855f7' }}>管理员</span>}
{u.banned && <span className="text-[10px] px-1.5 py-0.5 rounded" style={{ background: '#ef444422', color: '#ef4444' }}>已封禁</span>}
</div>
<div className="text-[10px] mt-0.5" style={{ color: 'var(--text-faint)' }}>
{u.email ?? '未绑定邮箱'} · {new Date(u.createdAt).toLocaleDateString('zh-CN')}
</div>
</div>
{u.role !== 'admin' && (
<button
onClick={() => toggleBan(u)}
className="flex items-center gap-1 text-[10px] px-2 py-1 rounded transition-opacity hover:opacity-70"
style={{ background: u.banned ? '#22c55e22' : '#ef444422', color: u.banned ? '#22c55e' : '#ef4444' }}
>
{u.banned ? <><CheckCircle size={10} />解封</> : <><Ban size={10} />封禁</>}
</button>
)}
</div>
))}
</div>
)
}
// ── 评论管理 ──────────────────────────────────────────────────────────────────
function CommentsTab() {
const [items, setItems] = useState<AdminComment[]>([])
const [loading, setLoading] = useState(true)
const load = useCallback(() => {
setLoading(true)
apiAdminGetComments().then(setItems).finally(() => setLoading(false))
}, [])
useEffect(() => { load() }, [load])
async function del(id: string) {
await apiAdminDeleteComment(id)
setItems(prev => prev.filter(i => i.id !== id))
}
if (loading) return <div className="text-xs" style={{ color: 'var(--text-faint)' }}>加载中…</div>
if (!items.length) return <div className="text-xs" style={{ color: 'var(--text-faint)' }}>暂无评论</div>
return (
<div className="flex flex-col gap-2">
{items.map(item => (
<div key={item.id} className="flex items-start gap-3 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-muted)' }}>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-xs font-medium" style={{ color: 'var(--text)' }}>{item.username ?? '匿名'}</span>
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{new Date(item.createdAt).toLocaleString('zh-CN')}</span>
</div>
<p className="text-xs leading-relaxed" style={{ color: 'var(--text-faint)', whiteSpace: 'pre-wrap' }}>{item.content}</p>
</div>
<button onClick={() => del(item.id)} className="flex-shrink-0 hover:opacity-70 transition-opacity" style={{ color: '#ef4444' }}>
<Trash2 size={13} />
</button>
</div>
))}
</div>
)
}
// ── 邀请码管理 ────────────────────────────────────────────────────────────────
function InvitesTab() {
const [codes, setCodes] = useState<InviteCode[]>([])
const [loading, setLoading] = useState(true)
const [creating, setCreating] = useState(false)
const load = useCallback(() => {
setLoading(true)
apiAdminGetInviteCodes().then(setCodes).finally(() => setLoading(false))
}, [])
useEffect(() => { load() }, [load])
async function create() {
setCreating(true)
try {
await apiAdminCreateInviteCode()
load()
} finally {
setCreating(false)
}
}
if (loading) return <div className="text-xs" style={{ color: 'var(--text-faint)' }}>加载中…</div>
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-xs" style={{ color: 'var(--text-faint)' }}>共 {codes.length} 个邀请码</span>
<button
onClick={create}
disabled={creating}
className="flex items-center gap-1 text-xs px-3 py-1.5 rounded-lg transition-opacity"
style={{ background: 'var(--accent)', color: '#fff', opacity: creating ? 0.6 : 1 }}
>
<Plus size={11} />生成邀请码
</button>
</div>
<div className="flex flex-col gap-1.5">
{codes.map(c => (
<div key={c.code} className="flex items-center gap-3 px-3 py-2 rounded-lg font-mono text-xs" style={{ background: 'var(--bg-muted)' }}>
<span className="flex-1" style={{ color: c.usedByUserId ? 'var(--text-faint)' : 'var(--text)' }}>{c.code}</span>
{c.usedByUserId ? (
<span className="text-[10px] px-1.5 py-0.5 rounded" style={{ background: '#6b728022', color: 'var(--text-faint)' }}>已使用</span>
) : (
<span className="text-[10px] px-1.5 py-0.5 rounded" style={{ background: '#22c55e22', color: '#22c55e' }}>可用</span>
)}
</div>
))}
</div>
</div>
)
}
+4 -4
View File
@@ -22,7 +22,7 @@ export function ModelSettingsModal({ onClose }: { onClose: () => void }) {
const fetchModels = async () => {
try {
const res = await fetch('/api/models')
const res = await fetch('https://www.mikivl.online/api/note/models')
setModels(await res.json())
} finally {
setLoading(false)
@@ -32,12 +32,12 @@ export function ModelSettingsModal({ onClose }: { onClose: () => void }) {
useEffect(() => { fetchModels() }, [])
const activate = async (id: string) => {
await fetch(`/api/models/${id}/activate`, { method: 'PATCH' })
await fetch(`https://www.mikivl.online/api/note/models/${id}/activate`, { method: 'PATCH' })
setModels(prev => prev.map(m => ({ ...m, isActive: m.id === id })))
}
const remove = async (id: string) => {
const res = await fetch(`/api/models/${id}`, { method: 'DELETE' })
const res = await fetch(`https://www.mikivl.online/api/note/models/${id}`, { method: 'DELETE' })
if (res.ok) setModels(prev => {
const next = prev.filter(m => m.id !== id)
if (prev.find(m => m.id === id)?.isActive && next.length > 0) next[0].isActive = true
@@ -52,7 +52,7 @@ export function ModelSettingsModal({ onClose }: { onClose: () => void }) {
setFormError('')
setSaving(true)
try {
const res = await fetch('/api/models', {
const res = await fetch('https://www.mikivl.online/api/note/models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
+176
View File
@@ -0,0 +1,176 @@
import { useState, useRef, useEffect } from 'react'
import { X, CheckCircle } from 'lucide-react'
import { apiSendEmailVerify, apiVerifyEmail, apiGetMe } from '../../lib/auth'
import { useAppStore } from '../../stores/appStore'
type Step = 'enter-email' | 'enter-code'
export function AccountModal({ onClose }: { onClose: () => void }) {
const { currentUser, setCurrentUser } = useAppStore()
const [step, setStep] = useState<Step>('enter-email')
const [email, setEmail] = useState('')
const [code, setCode] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [countdown, setCountdown] = useState(0)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => () => { if (timerRef.current) clearInterval(timerRef.current) }, [])
function startCountdown() {
setCountdown(60)
timerRef.current = setInterval(() => {
setCountdown(n => {
if (n <= 1) { clearInterval(timerRef.current!); return 0 }
return n - 1
})
}, 1000)
}
async function handleSendCode(e: React.FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
try {
await apiSendEmailVerify(email)
setStep('enter-code')
startCountdown()
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
async function handleResend() {
setError('')
setLoading(true)
try {
await apiSendEmailVerify(email)
startCountdown()
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
async function handleVerify(e: React.FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
try {
await apiVerifyEmail(code)
const updated = await apiGetMe()
setCurrentUser(updated)
onClose()
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
const inputStyle = {
background: 'var(--bg-muted)',
border: '1px solid var(--border)',
color: 'var(--text)',
width: '100%',
padding: '0.5rem 0.75rem',
borderRadius: '0.5rem',
fontSize: '0.875rem',
outline: 'none',
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={onClose}
>
<div
className="rounded-2xl shadow-2xl p-6 w-80 flex flex-col gap-4"
style={{ background: 'var(--bg)', border: '1px solid var(--border)' }}
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
{currentUser?.emailVerified ? '邮箱已绑定' : '绑定邮箱'}
</h2>
<button onClick={onClose} className="p-1 rounded" style={{ color: 'var(--text-faint)' }}>
<X size={14} />
</button>
</div>
{currentUser?.emailVerified ? (
<div className="flex items-center gap-1.5">
<CheckCircle size={13} style={{ color: 'var(--accent)' }} />
<span className="text-sm" style={{ color: 'var(--text)' }}>{currentUser.email}</span>
</div>
) : step === 'enter-email' ? (
<form onSubmit={handleSendCode} className="flex flex-col gap-3">
<input
style={inputStyle}
type="email"
placeholder="输入邮箱地址"
value={email}
onChange={e => setEmail(e.target.value)}
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>
</form>
) : (
<form onSubmit={handleVerify} className="flex flex-col gap-3">
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>
验证码已发送至 {email},10 分钟内有效
</p>
<input
style={inputStyle}
placeholder="输入 6 位验证码"
value={code}
onChange={e => setCode(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 items-center justify-between">
<button
type="button"
onClick={() => { setStep('enter-email'); setError(''); setCode('') }}
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)', cursor: countdown > 0 ? 'default' : 'pointer' }}
>
{countdown > 0 ? `${countdown}s 后重发` : '重新发送'}
</button>
</div>
</form>
)}
</div>
</div>
)
}
+438
View File
@@ -0,0 +1,438 @@
import { useState, useEffect, useRef } from 'react'
import { X } from 'lucide-react'
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 }) {
const [tab, setTab] = useState<Tab>(initialTab ?? 'login')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [code, setCode] = useState('')
const [error, setError] = useState('')
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')
const [forgotEmail, setForgotEmail] = useState('')
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')
setForgotEmail('')
setForgotCode('')
setForgotPassword('')
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('')
setLoading(true)
try {
await apiForgotPassword(forgotEmail)
setForgotStep('reset')
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
async function handleForgotReset(e: React.FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
try {
await apiResetPassword(forgotEmail, forgotCode, forgotPassword)
resetForgot()
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
try {
if (tab === 'login') {
const { token, user } = await apiLogin(username, password)
setToken(token)
setCurrentUser(user)
if (user.cloudEnabled) await syncFromCloud()
onClose()
} else {
await apiActivate(code)
setCurrentUser({ ...currentUser!, cloudEnabled: true })
await syncFromCloud()
onClose()
}
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
const inputStyle = {
background: 'var(--bg-muted)',
border: '1px solid var(--border)',
color: 'var(--text)',
width: '100%',
padding: '0.5rem 0.75rem',
borderRadius: '0.5rem',
fontSize: '0.875rem',
outline: 'none',
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={onClose}
>
<div
className="rounded-2xl shadow-2xl p-6 w-80 flex flex-col gap-4"
style={{ background: 'var(--bg)', border: '1px solid var(--border)' }}
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
{forgotMode
? (forgotStep === 'email' ? '找回密码' : '重置密码')
: (tab === 'login' ? '登录' : tab === 'register'
? (registerStep === 'form' ? '注册' : '验证邮箱')
: '激活云存储')}
</h2>
<button onClick={onClose} className="p-1 rounded" style={{ color: 'var(--text-faint)' }}>
<X size={14} />
</button>
</div>
{/* 忘记密码流程 */}
{forgotMode ? (
forgotStep === 'email' ? (
<form onSubmit={handleForgotSend} className="flex flex-col gap-3">
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>
输入绑定的邮箱,我们将发送验证码
</p>
<input
style={inputStyle}
type="email"
placeholder="邮箱地址"
value={forgotEmail}
onChange={e => setForgotEmail(e.target.value)}
autoFocus
/>
{error && <p className="text-xs" style={{ color: '#ef4444' }}>{error}</p>}
<div className="flex gap-2">
<button
type="button"
onClick={resetForgot}
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={handleForgotReset} className="flex flex-col gap-3">
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>
验证码已发送至 {forgotEmail},10 分钟内有效
</p>
<input
style={inputStyle}
placeholder="6 位验证码"
value={forgotCode}
onChange={e => setForgotCode(e.target.value)}
maxLength={6}
autoFocus
/>
<input
style={inputStyle}
type="password"
placeholder="新密码(至少 6 位)"
value={forgotPassword}
onChange={e => setForgotPassword(e.target.value)}
/>
{error && <p className="text-xs" style={{ color: '#ef4444' }}>{error}</p>}
<div className="flex gap-2">
<button
type="button"
onClick={() => { setForgotStep('email'); 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>
)
) : 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' && (
<div className="flex gap-1 p-1 rounded-lg" style={{ background: 'var(--bg-muted)' }}>
{(['login', 'register'] as Tab[]).map(t => (
<button
key={t}
onClick={() => { setTab(t); setError('') }}
className="flex-1 py-1 rounded text-xs font-medium transition-all"
style={{
background: tab === t ? 'var(--bg)' : 'transparent',
color: tab === t ? 'var(--text)' : 'var(--text-faint)',
}}
>
{t === 'login' ? '登录' : '注册'}
</button>
))}
</div>
)}
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
{tab !== 'activate' ? (
<>
<input
style={inputStyle}
placeholder="用户名"
value={username}
onChange={e => setUsername(e.target.value)}
autoFocus
/>
<input
style={inputStyle}
type="password"
placeholder="密码(至少 6 位)"
value={password}
onChange={e => setPassword(e.target.value)}
/>
</>
) : (
<input
style={inputStyle}
placeholder="邀请码(如 MIKI-A7X2-KP9Q)"
value={code}
onChange={e => setCode(e.target.value)}
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 ? '请稍候…' : tab === 'login' ? '登录' : '激活'}
</button>
{tab === 'login' && (
<button
type="button"
onClick={() => { setForgotMode(true); setError('') }}
className="text-xs text-center"
style={{ color: 'var(--text-faint)' }}
>
忘记密码?
</button>
)}
</form>
</>
)}
</div>
</div>
)
}
+464
View File
@@ -0,0 +1,464 @@
import { useState, useRef, useEffect } from 'react'
import { X, CheckCircle, Mail, Camera, Key, LogOut, Cloud } from 'lucide-react'
import {
apiUpdateProfile, apiChangePassword,
apiSendEmailVerify, apiVerifyEmail, apiActivate, apiGetMe,
} from '../../lib/auth'
import { useAppStore } from '../../stores/appStore'
type Section = 'main' | 'email' | 'email-code' | 'password' | 'change-password'
export function ProfileModal({ onClose }: { onClose: () => void }) {
const { currentUser, setCurrentUser, logout, syncStatus, syncFromCloud } = useAppStore()
const [section, setSection] = useState<Section>('main')
// 头像/昵称
const [nickname, setNickname] = useState(currentUser?.nickname ?? '')
const [avatarPreview, setAvatarPreview] = useState(currentUser?.avatar ?? null)
const [avatarData, setAvatarData] = useState<string | undefined>(undefined)
const [profileLoading, setProfileLoading] = useState(false)
const [profileMsg, setProfileMsg] = useState('')
const fileRef = useRef<HTMLInputElement>(null)
// 邀请码
const [inviteCode, setInviteCode] = useState('')
const [inviteLoading, setInviteLoading] = useState(false)
const [inviteError, setInviteError] = useState('')
// 邮箱
const [email, setEmail] = useState('')
const [emailCode, setEmailCode] = useState('')
const [emailLoading, setEmailLoading] = useState(false)
const [emailError, setEmailError] = useState('')
const [resendCountdown, setResendCountdown] = useState(0)
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null)
// 修改密码
const [oldPwd, setOldPwd] = useState('')
const [newPwd, setNewPwd] = useState('')
const [pwdLoading, setPwdLoading] = useState(false)
const [pwdError, setPwdError] = useState('')
const [pwdOk, setPwdOk] = useState(false)
useEffect(() => () => { if (countdownRef.current) clearInterval(countdownRef.current) }, [])
function startCountdown() {
setResendCountdown(60)
countdownRef.current = setInterval(() => {
setResendCountdown(n => {
if (n <= 1) { clearInterval(countdownRef.current!); return 0 }
return n - 1
})
}, 1000)
}
const inputStyle: React.CSSProperties = {
background: 'var(--bg-muted)',
border: '1px solid var(--border)',
color: 'var(--text)',
width: '100%',
padding: '0.5rem 0.75rem',
borderRadius: '0.5rem',
fontSize: '0.875rem',
outline: 'none',
}
function handleAvatarChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = ev => {
const result = ev.target?.result as string
setAvatarPreview(result)
setAvatarData(result)
}
reader.readAsDataURL(file)
}
async function handleSaveProfile(e: React.FormEvent) {
e.preventDefault()
setProfileLoading(true)
setProfileMsg('')
try {
const updated = await apiUpdateProfile(nickname, avatarData)
setCurrentUser(updated)
setAvatarData(undefined)
setProfileMsg('保存成功')
setTimeout(() => setProfileMsg(''), 2000)
} catch (err: any) {
setProfileMsg(err.message)
} finally {
setProfileLoading(false)
}
}
async function handleActivate(e: React.FormEvent) {
e.preventDefault()
setInviteError('')
setInviteLoading(true)
try {
await apiActivate(inviteCode)
const updated = await apiGetMe()
setCurrentUser(updated)
await syncFromCloud()
setInviteCode('')
} catch (err: any) {
setInviteError(err.message)
} finally {
setInviteLoading(false)
}
}
async function handleSendEmailCode(e: React.FormEvent) {
e.preventDefault()
setEmailError('')
setEmailLoading(true)
try {
await apiSendEmailVerify(email)
setSection('email-code')
startCountdown()
} catch (err: any) {
setEmailError(err.message)
} finally {
setEmailLoading(false)
}
}
async function handleResendEmailCode() {
setEmailError('')
setEmailLoading(true)
try {
await apiSendEmailVerify(email)
startCountdown()
} catch (err: any) {
setEmailError(err.message)
} finally {
setEmailLoading(false)
}
}
async function handleVerifyEmail(e: React.FormEvent) {
e.preventDefault()
setEmailError('')
setEmailLoading(true)
try {
await apiVerifyEmail(emailCode)
const updated = await apiGetMe()
setCurrentUser(updated)
setSection('main')
setEmailCode('')
setEmail('')
} catch (err: any) {
setEmailError(err.message)
} finally {
setEmailLoading(false)
}
}
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault()
setPwdError('')
setPwdOk(false)
setPwdLoading(true)
try {
await apiChangePassword(oldPwd, newPwd)
setOldPwd('')
setNewPwd('')
setPwdOk(true)
setTimeout(() => { setPwdOk(false); setSection('main') }, 1500)
} catch (err: any) {
setPwdError(err.message)
} finally {
setPwdLoading(false)
}
}
const displayName = currentUser?.nickname || currentUser?.username || ''
const avatarSrc = avatarPreview || currentUser?.avatar
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={onClose}
>
<div
className="rounded-2xl shadow-2xl p-6 w-96 flex flex-col gap-5 max-h-[90vh] overflow-y-auto"
style={{ background: 'var(--bg)', border: '1px solid var(--border)' }}
onClick={e => e.stopPropagation()}
>
{/* 标题栏 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{section !== 'main' && (
<button
onClick={() => { setSection('main'); setEmailError(''); setPwdError('') }}
className="text-xs"
style={{ color: 'var(--text-faint)' }}
>
←
</button>
)}
<h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
{section === 'main' && '个人中心'}
{section === 'email' && '绑定邮箱'}
{section === 'email-code' && '验证邮箱'}
{section === 'password' && '修改密码'}
{section === 'change-password' && '修改密码'}
</h2>
</div>
<button onClick={onClose} className="p-1 rounded" style={{ color: 'var(--text-faint)' }}>
<X size={14} />
</button>
</div>
{/* 主界面 */}
{section === 'main' && (
<>
{/* 头像 + 昵称 */}
<form onSubmit={handleSaveProfile} className="flex flex-col gap-3">
<div className="flex items-center gap-4">
<div className="relative">
<div
className="w-16 h-16 rounded-full flex items-center justify-center text-xl font-bold overflow-hidden cursor-pointer"
style={{ background: 'var(--accent)', color: '#fff' }}
onClick={() => fileRef.current?.click()}
>
{avatarSrc ? (
<img src={avatarSrc} alt="头像" className="w-full h-full object-cover" />
) : (
displayName[0]?.toUpperCase()
)}
</div>
<button
type="button"
onClick={() => fileRef.current?.click()}
className="absolute bottom-0 right-0 w-5 h-5 rounded-full flex items-center justify-center"
style={{ background: 'var(--bg)', border: '1px solid var(--border)' }}
>
<Camera size={10} style={{ color: 'var(--text-faint)' }} />
</button>
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleAvatarChange} />
</div>
<div className="flex-1">
<label className="text-xs mb-1 block" style={{ color: 'var(--text-faint)' }}>昵称</label>
<input
style={inputStyle}
placeholder={currentUser?.username}
value={nickname}
onChange={e => setNickname(e.target.value)}
maxLength={20}
/>
</div>
</div>
{profileMsg && (
<p className="text-xs" style={{ color: profileMsg === '保存成功' ? 'var(--accent)' : '#ef4444' }}>
{profileMsg}
</p>
)}
<button
type="submit"
disabled={profileLoading}
className="w-full py-2 rounded-lg text-sm font-medium"
style={{ background: 'var(--accent)', color: '#fff', opacity: profileLoading ? 0.7 : 1 }}
>
{profileLoading ? '保存中…' : '保存'}
</button>
</form>
<div style={{ height: 1, background: 'var(--border)' }} />
{/* 云存储 / 邀请码 */}
<div className="flex flex-col gap-2">
<span className="text-xs font-medium" style={{ color: 'var(--text-faint)' }}>云存储</span>
{currentUser?.cloudEnabled ? (
<button
onClick={syncFromCloud}
disabled={syncStatus === 'syncing'}
className="flex items-center gap-2 text-sm"
style={{ color: syncStatus === 'error' ? '#ef4444' : 'var(--accent)' }}
>
<Cloud size={13} />
{syncStatus === 'syncing' ? '同步中…' : syncStatus === 'error' ? '同步失败,点击重试' : '已开启,点击手动同步'}
</button>
) : (
<form onSubmit={handleActivate} className="flex flex-col gap-2">
<input
style={inputStyle}
placeholder="输入邀请码(如 MIKI-A7X2-KP9Q)"
value={inviteCode}
onChange={e => setInviteCode(e.target.value)}
/>
{inviteError && <p className="text-xs" style={{ color: '#ef4444' }}>{inviteError}</p>}
<button
type="submit"
disabled={inviteLoading}
className="w-full py-1.5 rounded-lg text-sm"
style={{ background: 'var(--bg-muted)', color: 'var(--text)', border: '1px solid var(--border)', opacity: inviteLoading ? 0.7 : 1 }}
>
{inviteLoading ? '激活中…' : '激活云存储'}
</button>
</form>
)}
</div>
<div style={{ height: 1, background: 'var(--border)' }} />
{/* 邮箱 */}
<div className="flex flex-col gap-2">
<span className="text-xs font-medium" style={{ color: 'var(--text-faint)' }}>绑定邮箱</span>
{currentUser?.emailVerified ? (
<div className="flex items-center gap-1.5">
<CheckCircle size={13} style={{ color: 'var(--accent)' }} />
<span className="text-sm" style={{ color: 'var(--text)' }}>{currentUser.email}</span>
<button
onClick={() => { setEmail(currentUser.email ?? ''); setSection('email') }}
className="text-xs ml-auto"
style={{ color: 'var(--text-faint)' }}
>
更换
</button>
</div>
) : currentUser?.email ? (
<div className="flex items-center gap-2">
<Mail size={13} style={{ color: 'var(--text-faint)' }} />
<span className="text-xs" style={{ color: 'var(--text-faint)' }}>{currentUser.email}(未验证)</span>
<button
onClick={() => { setEmail(currentUser.email ?? ''); setSection('email-code') }}
className="text-xs ml-auto"
style={{ color: 'var(--accent)' }}
>
输入验证码
</button>
</div>
) : (
<button
onClick={() => setSection('email')}
className="text-sm text-left"
style={{ color: 'var(--accent)' }}
>
+ 绑定邮箱
</button>
)}
</div>
<div style={{ height: 1, background: 'var(--border)' }} />
{/* 修改密码 */}
<button
onClick={() => setSection('change-password')}
className="flex items-center gap-2 text-sm"
style={{ color: 'var(--text-faint)' }}
onMouseEnter={e => (e.currentTarget.style.color = 'var(--text)')}
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-faint)')}
>
<Key size={13} />
修改密码
</button>
{/* 退出登录 */}
<button
onClick={() => { logout(); onClose() }}
className="flex items-center gap-2 text-sm"
style={{ color: '#ef4444' }}
onMouseEnter={e => (e.currentTarget.style.opacity = '0.7')}
onMouseLeave={e => (e.currentTarget.style.opacity = '1')}
>
<LogOut size={13} />
退出登录
</button>
</>
)}
{/* 绑定邮箱 */}
{section === 'email' && (
<form onSubmit={handleSendEmailCode} className="flex flex-col gap-3">
<input
style={inputStyle}
type="email"
placeholder="输入邮箱地址"
value={email}
onChange={e => setEmail(e.target.value)}
autoFocus
/>
{emailError && <p className="text-xs" style={{ color: '#ef4444' }}>{emailError}</p>}
<button
type="submit"
disabled={emailLoading}
className="w-full py-2 rounded-lg text-sm font-medium"
style={{ background: 'var(--accent)', color: '#fff', opacity: emailLoading ? 0.7 : 1 }}
>
{emailLoading ? '发送中…' : '发送验证码'}
</button>
</form>
)}
{/* 验证邮箱验证码 */}
{section === 'email-code' && (
<form onSubmit={handleVerifyEmail} className="flex flex-col gap-3">
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>
验证码已发送至 {email},10 分钟内有效
</p>
<input
style={inputStyle}
placeholder="输入 6 位验证码"
value={emailCode}
onChange={e => setEmailCode(e.target.value)}
maxLength={6}
autoFocus
/>
{emailError && <p className="text-xs" style={{ color: '#ef4444' }}>{emailError}</p>}
<button
type="submit"
disabled={emailLoading}
className="w-full py-2 rounded-lg text-sm font-medium"
style={{ background: 'var(--accent)', color: '#fff', opacity: emailLoading ? 0.7 : 1 }}
>
{emailLoading ? '验证中…' : '验证'}
</button>
<button
type="button"
onClick={handleResendEmailCode}
disabled={resendCountdown > 0 || emailLoading}
className="text-xs text-center"
style={{ color: resendCountdown > 0 ? 'var(--text-faint)' : 'var(--accent)', cursor: resendCountdown > 0 ? 'default' : 'pointer' }}
>
{resendCountdown > 0 ? `${resendCountdown} 秒后可重新发送` : '重新发送验证码'}
</button>
</form>
)}
{/* 修改密码 */}
{section === 'change-password' && (
<form onSubmit={handleChangePassword} className="flex flex-col gap-3">
<input
style={inputStyle}
type="password"
placeholder="当前密码"
value={oldPwd}
onChange={e => setOldPwd(e.target.value)}
autoFocus
/>
<input
style={inputStyle}
type="password"
placeholder="新密码(至少 6 位)"
value={newPwd}
onChange={e => setNewPwd(e.target.value)}
/>
{pwdError && <p className="text-xs" style={{ color: '#ef4444' }}>{pwdError}</p>}
{pwdOk && <p className="text-xs" style={{ color: 'var(--accent)' }}>密码修改成功</p>}
<button
type="submit"
disabled={pwdLoading}
className="w-full py-2 rounded-lg text-sm font-medium"
style={{ background: 'var(--accent)', color: '#fff', opacity: pwdLoading ? 0.7 : 1 }}
>
{pwdLoading ? '修改中…' : '确认修改'}
</button>
</form>
)}
</div>
</div>
)
}
+85
View File
@@ -0,0 +1,85 @@
import { useState } from 'react'
import { User, Cloud, Key } from 'lucide-react'
import { useAppStore } from '../../stores/appStore'
import { LoginModal } from './LoginModal'
import { ProfileModal } from './ProfileModal'
export function UserMenu() {
const { currentUser, syncStatus, syncFromCloud } = useAppStore()
const [showLogin, setShowLogin] = useState(false)
const [showProfile, setShowProfile] = useState(false)
if (!currentUser) {
return (
<>
<button
onClick={() => setShowLogin(true)}
className="flex items-center gap-2 w-full px-3 py-2 rounded-lg text-xs transition-all"
style={{ color: 'var(--text-faint)', background: 'transparent' }}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-muted)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
<User size={13} />
<span>登录 / 注册</span>
</button>
{showLogin && <LoginModal onClose={() => setShowLogin(false)} />}
</>
)
}
const displayName = currentUser.nickname || currentUser.username
return (
<>
<div className="px-3 py-2 flex flex-col gap-1">
<button
onClick={() => setShowProfile(true)}
className="flex items-center gap-2 w-full text-left rounded-lg transition-all"
style={{ background: 'transparent' }}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-muted)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 overflow-hidden"
style={{ background: 'var(--accent)', color: '#fff' }}
>
{currentUser.avatar ? (
<img src={currentUser.avatar} alt="头像" className="w-full h-full object-cover" />
) : (
displayName[0]?.toUpperCase()
)}
</div>
<span className="text-xs font-medium truncate" style={{ color: 'var(--text)' }}>
{displayName}
</span>
</button>
{currentUser.cloudEnabled ? (
<button
onClick={syncFromCloud}
disabled={syncStatus === 'syncing'}
className="flex items-center gap-1.5 text-xs"
style={{ color: syncStatus === 'error' ? '#ef4444' : 'var(--accent)' }}
>
<Cloud size={11} />
{syncStatus === 'syncing' ? '同步中…' : syncStatus === 'error' ? '同步失败' : '云端已开启'}
</button>
) : (
<button
onClick={() => setShowProfile(true)}
className="flex items-center gap-1.5 text-xs"
style={{ color: 'var(--text-faint)' }}
onMouseEnter={e => (e.currentTarget.style.color = 'var(--accent)')}
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-faint)')}
>
<Key size={11} />
<span>输入邀请码开启云存储</span>
</button>
)}
</div>
{showProfile && <ProfileModal onClose={() => setShowProfile(false)} />}
</>
)
}
+160
View File
@@ -0,0 +1,160 @@
import { FileText, Plus, Clock, Hash, Folder } from 'lucide-react'
import { useAppStore } from '../../stores/appStore'
import { formatDate } from '../../lib/utils'
export function AllNotesView() {
const { notes, folders, createNote, setActiveNote } = useAppStore()
const recentNotes = [...notes]
.sort((a, b) => b.updatedAt - a.updatedAt)
.slice(0, 5)
const totalWords = notes.reduce((sum, n) => sum + (n.wordCount ?? 0), 0)
const tagCounts: Record<string, number> = {}
for (const note of notes) {
for (const tag of note.tags) {
tagCounts[tag] = (tagCounts[tag] ?? 0) + 1
}
}
const topTags = Object.entries(tagCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
return (
<div className="flex-1 overflow-y-auto h-full" style={{ background: 'var(--bg)' }}>
<div className="max-w-2xl mx-auto px-12 pt-16 pb-20">
{/* Hero */}
<div className="mb-10">
<div
className="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-6 text-2xl"
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)' }}
>
<FileText size={26} />
</div>
<h1
className="text-4xl font-bold mb-3"
style={{ color: 'var(--text)', letterSpacing: '-0.03em', lineHeight: 1.15 }}
>
所有笔记
</h1>
<p className="text-base" style={{ color: 'var(--text-muted)', lineHeight: 1.7 }}>
共 {notes.length} 篇笔记,{folders.length} 个文件夹,累计约 {totalWords.toLocaleString()} 字。
</p>
<button
onClick={() => createNote(null)}
className="inline-flex items-center gap-2 mt-6 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
style={{ background: 'var(--accent)', color: '#fff' }}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--accent-hover)')}
onMouseLeave={e => (e.currentTarget.style.background = 'var(--accent)')}
>
<Plus size={15} />
新建笔记
</button>
</div>
<div style={{ borderTop: '1px solid var(--border)', marginBottom: '2.5rem' }} />
{/* 统计卡片 */}
<div className="grid grid-cols-3 gap-3 mb-10">
{[
{ label: '全部笔记', value: notes.length, icon: <FileText size={16} /> },
{ label: '文件夹', value: folders.length, icon: <Folder size={16} /> },
{ label: '累计字数', value: totalWords.toLocaleString(), icon: <Hash size={16} /> },
].map(stat => (
<div
key={stat.label}
className="rounded-xl p-4"
style={{ background: 'var(--bg-subtle)', border: '1px solid var(--border)' }}
>
<div
className="inline-flex items-center justify-center w-8 h-8 rounded-lg mb-3"
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)' }}
>
{stat.icon}
</div>
<div className="text-2xl font-bold mb-0.5" style={{ color: 'var(--text)' }}>{stat.value}</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{stat.label}</div>
</div>
))}
</div>
{/* 最近修改 */}
{recentNotes.length > 0 && (
<>
<h2
className="text-xs font-semibold uppercase tracking-widest mb-4"
style={{ color: 'var(--text-faint)' }}
>
最近修改
</h2>
<div
className="rounded-xl overflow-hidden mb-10"
style={{ border: '1px solid var(--border)' }}
>
{recentNotes.map((note, i) => (
<button
key={note.id}
onClick={() => setActiveNote(note.id)}
className="w-full flex items-center justify-between px-5 py-3 text-left transition-colors"
style={{
borderTop: i > 0 ? '1px solid var(--border)' : 'none',
background: 'var(--bg-subtle)',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-muted)')}
onMouseLeave={e => (e.currentTarget.style.background = 'var(--bg-subtle)')}
>
<div className="flex items-center gap-3 min-w-0">
<Clock size={13} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span className="text-sm truncate" style={{ color: 'var(--text)' }}>
{note.title || '无标题笔记'}
</span>
</div>
<span className="text-xs shrink-0 ml-3" style={{ color: 'var(--text-faint)' }}>
{formatDate(note.updatedAt)}
</span>
</button>
))}
</div>
</>
)}
{/* 标签云 */}
{topTags.length > 0 && (
<>
<h2
className="text-xs font-semibold uppercase tracking-widest mb-4"
style={{ color: 'var(--text-faint)' }}
>
常用标签
</h2>
<div className="flex flex-wrap gap-2">
{topTags.map(([tag, count]) => (
<span
key={tag}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm"
style={{ background: 'var(--bg-subtle)', border: '1px solid var(--border)', color: 'var(--text-muted)' }}
>
<Hash size={11} />
{tag}
<span className="text-xs" style={{ color: 'var(--text-faint)' }}>({count})</span>
</span>
))}
</div>
</>
)}
{/* 空状态 */}
{notes.length === 0 && (
<div className="text-center py-12" style={{ color: 'var(--text-faint)' }}>
<FileText size={40} className="mx-auto mb-4 opacity-30" />
<p className="text-sm mb-2">还没有笔记</p>
<p className="text-xs">点击上方「新建笔记」开始记录</p>
</div>
)}
</div>
</div>
)
}
+8 -2
View File
@@ -30,6 +30,9 @@ import { useAppStore } from '../../stores/appStore'
import { countWords } from '../../lib/utils'
import { streamAI } from '../../lib/ai'
import { WelcomeView } from './WelcomeView'
import { AllNotesView } from './AllNotesView'
import { StarredView } from './StarredView'
import { TrashHomeView } from './TrashHomeView'
import { ExportMenu } from './ExportMenu'
const lowlight = createLowlight(common)
@@ -80,7 +83,7 @@ function SlashMenu({ items, selectedIndex, onSelect }: {
}
export function Editor() {
const { activeNoteId, notes, updateNote, toggleStar, focusMode, toggleFocusMode, toggleAiPanel, aiPanelOpen } = useAppStore()
const { activeNoteId, activeFolderId, notes, updateNote, toggleStar, focusMode, toggleFocusMode, toggleAiPanel, aiPanelOpen } = useAppStore()
const activeNote = notes.find(n => n.id === activeNoteId)
const [title, setTitle] = useState(activeNote?.title ?? '')
@@ -352,7 +355,10 @@ export function Editor() {
}
if (!activeNote || activeNoteId === '__welcome__') {
return <WelcomeView />
if (activeNoteId === '__welcome__') return <WelcomeView />
if (activeFolderId === 'starred') return <StarredView />
if (activeFolderId === 'trash') return <TrashHomeView />
return <AllNotesView />
}
const readingTime = wordCount > 0 ? Math.max(1, Math.ceil(wordCount / 250)) : 0
+124
View File
@@ -0,0 +1,124 @@
import { Star, Plus, Hash, Clock } from 'lucide-react'
import { useAppStore } from '../../stores/appStore'
import { formatDate } from '../../lib/utils'
export function StarredView() {
const { notes, createNote, setActiveNote, toggleStar } = useAppStore()
const starredNotes = notes.filter(n => n.starred).sort((a, b) => b.updatedAt - a.updatedAt)
return (
<div className="flex-1 overflow-y-auto h-full" style={{ background: 'var(--bg)' }}>
<div className="max-w-2xl mx-auto px-12 pt-16 pb-20">
{/* Hero */}
<div className="mb-10">
<div
className="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-6"
style={{ background: 'var(--accent-subtle)', color: '#f59e0b' }}
>
<Star size={26} fill="currentColor" />
</div>
<h1
className="text-4xl font-bold mb-3"
style={{ color: 'var(--text)', letterSpacing: '-0.03em', lineHeight: 1.15 }}
>
收藏
</h1>
<p className="text-base" style={{ color: 'var(--text-muted)', lineHeight: 1.7 }}>
{starredNotes.length > 0
? `已收藏 ${starredNotes.length} 篇笔记,点击即可快速访问。`
: '还没有收藏任何笔记。在笔记列表或编辑器标题旁点击星标即可收藏。'}
</p>
{starredNotes.length === 0 && (
<button
onClick={() => createNote(null)}
className="inline-flex items-center gap-2 mt-6 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
style={{ background: 'var(--accent)', color: '#fff' }}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--accent-hover)')}
onMouseLeave={e => (e.currentTarget.style.background = 'var(--accent)')}
>
<Plus size={15} />
新建笔记
</button>
)}
</div>
<div style={{ borderTop: '1px solid var(--border)', marginBottom: '2.5rem' }} />
{starredNotes.length > 0 ? (
<>
<h2
className="text-xs font-semibold uppercase tracking-widest mb-4"
style={{ color: 'var(--text-faint)' }}
>
收藏的笔记
</h2>
<div
className="rounded-xl overflow-hidden"
style={{ border: '1px solid var(--border)' }}
>
{starredNotes.map((note, i) => (
<button
key={note.id}
onClick={() => setActiveNote(note.id)}
className="w-full flex items-center justify-between px-5 py-3.5 text-left transition-colors"
style={{
borderTop: i > 0 ? '1px solid var(--border)' : 'none',
background: 'var(--bg-subtle)',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-muted)')}
onMouseLeave={e => (e.currentTarget.style.background = 'var(--bg-subtle)')}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<Star size={13} style={{ color: '#f59e0b', flexShrink: 0 }} fill="currentColor" />
<div className="min-w-0">
<div className="text-sm font-medium truncate" style={{ color: 'var(--text)' }}>
{note.title || '无标题笔记'}
</div>
{note.tags.length > 0 && (
<div className="flex items-center gap-1 mt-1 flex-wrap">
{note.tags.slice(0, 3).map(tag => (
<span
key={tag}
className="inline-flex items-center gap-0.5 text-xs px-1.5 py-0.5 rounded-full"
style={{ background: 'var(--bg-muted)', color: 'var(--text-muted)' }}
>
<Hash size={9} />{tag}
</span>
))}
</div>
)}
</div>
</div>
<div className="flex items-center gap-3 shrink-0 ml-3">
<span className="text-xs" style={{ color: 'var(--text-faint)' }}>
<Clock size={11} className="inline mr-1" />
{formatDate(note.updatedAt)}
</span>
<button
onClick={e => { e.stopPropagation(); toggleStar(note.id) }}
className="p-1 rounded transition-colors"
title="取消收藏"
style={{ color: 'var(--text-faint)' }}
onMouseEnter={e => (e.currentTarget.style.color = '#ef4444')}
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-faint)')}
>
<Star size={13} />
</button>
</div>
</button>
))}
</div>
</>
) : (
<div className="text-center py-12" style={{ color: 'var(--text-faint)' }}>
<Star size={40} className="mx-auto mb-4 opacity-30" />
<p className="text-sm mb-1">暂无收藏笔记</p>
<p className="text-xs">在笔记列表 hover 或编辑器标题旁点击 ★ 收藏</p>
</div>
)}
</div>
</div>
)
}
+124
View File
@@ -0,0 +1,124 @@
import { Trash2, RotateCcw, AlertTriangle } from 'lucide-react'
import { useAppStore } from '../../stores/appStore'
function daysLeft(deletedAt: number): number {
const elapsed = Date.now() - deletedAt
return Math.max(0, 30 - Math.floor(elapsed / (24 * 60 * 60 * 1000)))
}
export function TrashHomeView() {
const { trashNotes, restoreNote, emptyTrash } = useAppStore()
const notes = trashNotes()
return (
<div className="flex-1 overflow-y-auto h-full" style={{ background: 'var(--bg)' }}>
<div className="max-w-2xl mx-auto px-12 pt-16 pb-20">
{/* Hero */}
<div className="mb-10">
<div
className="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-6"
style={{ background: 'rgba(239,68,68,0.1)', color: '#ef4444' }}
>
<Trash2 size={26} />
</div>
<div className="flex items-start justify-between gap-4">
<div>
<h1
className="text-4xl font-bold mb-3"
style={{ color: 'var(--text)', letterSpacing: '-0.03em', lineHeight: 1.15 }}
>
回收站
</h1>
<p className="text-base" style={{ color: 'var(--text-muted)', lineHeight: 1.7 }}>
{notes.length > 0
? `${notes.length} 篇笔记将在 30 天后永久删除,可随时恢复。`
: '回收站为空,已删除的笔记会在这里保留 30 天。'}
</p>
</div>
{notes.length > 0 && (
<button
onClick={() => {
if (confirm('确认清空回收站?此操作无法撤销。')) emptyTrash()
}}
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium mt-1 transition-colors"
style={{ background: 'rgba(239,68,68,0.1)', color: '#ef4444' }}
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(239,68,68,0.18)')}
onMouseLeave={e => (e.currentTarget.style.background = 'rgba(239,68,68,0.1)')}
>
<Trash2 size={13} />
清空回收站
</button>
)}
</div>
</div>
<div style={{ borderTop: '1px solid var(--border)', marginBottom: '2.5rem' }} />
{notes.length > 0 ? (
<>
{/* 提示 */}
<div
className="flex items-start gap-3 rounded-xl px-4 py-3 mb-6 text-sm"
style={{ background: 'rgba(239,68,68,0.06)', border: '1px solid rgba(239,68,68,0.2)', color: 'var(--text-muted)' }}
>
<AlertTriangle size={15} style={{ color: '#ef4444', flexShrink: 0, marginTop: 1 }} />
笔记将在删除 30 天后自动永久清除,届时无法恢复。
</div>
<h2
className="text-xs font-semibold uppercase tracking-widest mb-4"
style={{ color: 'var(--text-faint)' }}
>
待清除的笔记
</h2>
<div
className="rounded-xl overflow-hidden"
style={{ border: '1px solid var(--border)' }}
>
{notes.map((note, i) => {
const days = daysLeft(note.deletedAt!)
const urgent = days <= 3
return (
<div
key={note.id}
className="flex items-center justify-between px-5 py-3.5 group"
style={{
borderTop: i > 0 ? '1px solid var(--border)' : 'none',
background: 'var(--bg-subtle)',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-muted)')}
onMouseLeave={e => (e.currentTarget.style.background = 'var(--bg-subtle)')}
>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate" style={{ color: 'var(--text)' }}>
{note.title || '无标题笔记'}
</div>
<div className="text-xs mt-0.5" style={{ color: urgent ? '#ef4444' : 'var(--text-faint)' }}>
{days === 0 ? '今天永久删除' : `${days} 天后永久删除`}
</div>
</div>
<button
onClick={() => restoreNote(note.id)}
className="shrink-0 flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity"
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)' }}
>
<RotateCcw size={12} />
恢复
</button>
</div>
)
})}
</div>
</>
) : (
<div className="text-center py-12" style={{ color: 'var(--text-faint)' }}>
<Trash2 size={40} className="mx-auto mb-4 opacity-30" />
<p className="text-sm">回收站为空</p>
</div>
)}
</div>
</div>
)
}
+71 -8
View File
@@ -18,6 +18,7 @@ import { useAppStore } from '../../stores/appStore'
import { TrashView } from './TrashView'
import { formatDate } from '../../lib/utils'
import type { Folder as FolderType, Note } from '../../db'
import { UserMenu } from '../auth/UserMenu'
export function Sidebar() {
const {
@@ -47,6 +48,7 @@ export function Sidebar() {
} | null>(null)
const [draggingNoteId, setDraggingNoteId] = useState<string | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
const [deleteFolderConfirm, setDeleteFolderConfirm] = useState<string | null>(null)
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 6 } }),
@@ -177,6 +179,7 @@ export function Sidebar() {
if (e.key === 'Enter') handleRenameFolder(folder.id)
if (e.key === 'Escape') setEditingFolderId(null)
}}
onDelete={() => setDeleteFolderConfirm(folder.id)}
/>
{expandedFolders.has(folder.id) && renderFolder(folder.id, depth + 1)}
</div>
@@ -198,7 +201,17 @@ export function Sidebar() {
>
{/* Header */}
<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>
</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} />}
@@ -210,7 +223,7 @@ export function Sidebar() {
</div>
{/* Search */}
<div className="px-3 py-2">
<div className="px-3 py-2 shrink-0">
<div
className="flex items-center gap-2 rounded-lg px-3 py-1.5"
style={{ background: 'var(--bg-muted)', border: '1px solid var(--border)' }}
@@ -227,28 +240,28 @@ export function Sidebar() {
</div>
{/* Nav shortcuts */}
<nav className="px-2 space-y-0.5">
<nav className="px-2 space-y-0.5 shrink-0">
<NavItem icon={<BookOpen size={14} />} label="使用指南" active={activeNoteId === '__welcome__'} onClick={() => setActiveNote('__welcome__')} />
<NavItem icon={<FileText size={14} />} label="所有笔记" count={notes.length} active={activeFolderId === 'all' && activeNoteId !== '__welcome__'} onClick={() => { setActiveFolder('all'); if (activeNoteId === '__welcome__') setActiveNote(null) }} />
<NavItem icon={<Star size={14} />} label="收藏" count={notes.filter(n => n.starred).length} active={activeFolderId === 'starred'} onClick={() => { setActiveFolder('starred'); if (activeNoteId === '__welcome__') setActiveNote(null) }} />
<NavItem icon={<FileText size={14} />} label="所有笔记" count={notes.length} active={activeFolderId === 'all' && activeNoteId !== '__welcome__'} onClick={() => { setActiveFolder('all'); setActiveNote(null) }} />
<NavItem icon={<Star size={14} />} label="收藏" count={notes.filter(n => n.starred).length} active={activeFolderId === 'starred'} onClick={() => { setActiveFolder('starred'); setActiveNote(null) }} />
<NavItem
icon={<Trash2 size={14} />}
label="回收站"
count={trashNotes().length || undefined}
active={activeFolderId === 'trash'}
onClick={() => { setActiveFolder('trash'); if (activeNoteId === '__welcome__') setActiveNote(null) }}
onClick={() => { setActiveFolder('trash'); setActiveNote(null) }}
/>
</nav>
{/* Folders section */}
<div className="px-3 pt-3 pb-1 flex items-center justify-between">
<div className="px-3 pt-3 pb-1 flex items-center justify-between shrink-0">
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>文件夹</span>
<button onClick={() => setCreatingFolder(true)} className="toolbar-btn" title="新建文件夹">
<FolderPlus size={13} />
</button>
</div>
<div className="px-2 space-y-0.5">
<div className="px-2 space-y-0.5 overflow-y-auto" style={{ maxHeight: '30%' }}>
{renderFolder(null, 0)}
{creatingFolder && (
@@ -421,6 +434,9 @@ export function Sidebar() {
onChange={handleImportFile}
/>
</div>
<div className="px-3 py-2 shrink-0" style={{ borderTop: '1px solid var(--border)' }}>
<UserMenu />
</div>
</aside>
<AnimatePresence>
@@ -468,6 +484,36 @@ export function Sidebar() {
</div>
</div>
)}
{deleteFolderConfirm && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.4)' }}
onClick={() => setDeleteFolderConfirm(null)}
>
<div
className="rounded-2xl shadow-2xl p-5 flex flex-col gap-4"
style={{ background: 'var(--bg)', border: '1px solid var(--border)', width: 320 }}
onClick={e => e.stopPropagation()}
>
<p className="text-sm font-semibold" style={{ color: 'var(--text)' }}>删除文件夹?</p>
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>文件夹内的笔记将移至「所有笔记」,文件夹本身将被永久删除。</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setDeleteFolderConfirm(null)}
className="px-3 py-1.5 rounded-lg text-sm"
style={{ color: 'var(--text-muted)' }}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-muted)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>取消</button>
<button
onClick={async () => { await deleteFolder(deleteFolderConfirm); setDeleteFolderConfirm(null) }}
className="px-3 py-1.5 rounded-lg text-sm font-medium"
style={{ background: '#ef4444', color: '#fff' }}
>永久删除</button>
</div>
</div>
</div>
)}
</DndContext>
)
}
@@ -485,13 +531,15 @@ function NavItem({ icon, label, count, active, onClick }: { icon: React.ReactNod
)
}
function DroppableFolderItem({ folder, depth, expanded, active, editing, editValue, editRef, onToggle, onClick, onContextMenu, onEditChange, onEditBlur, onEditKeyDown }: {
function DroppableFolderItem({ folder, depth, expanded, active, editing, editValue, editRef, onToggle, onClick, onContextMenu, onEditChange, onEditBlur, onEditKeyDown, onDelete }: {
folder: FolderType; depth: number; expanded: boolean; active: boolean; editing: boolean
editValue: string; editRef: React.RefObject<HTMLInputElement | null>
onToggle: () => void; onClick: () => void; onContextMenu: (e: React.MouseEvent) => void
onEditChange: (v: string) => void; onEditBlur: () => void; onEditKeyDown: (e: React.KeyboardEvent) => void
onDelete: () => void
}) {
const { isOver, setNodeRef } = useDroppable({ id: folder.id })
const [hovered, setHovered] = useState(false)
return (
<div
@@ -499,6 +547,7 @@ function DroppableFolderItem({ folder, depth, expanded, active, editing, editVal
className="flex items-center gap-1.5 py-1.5 rounded-lg cursor-pointer text-sm"
style={{
paddingLeft: 8 + depth * 16,
paddingRight: 8,
background: isOver ? 'var(--accent-subtle)' : active ? 'var(--accent-subtle)' : 'transparent',
color: active || isOver ? 'var(--accent)' : 'var(--text-muted)',
outline: isOver ? '2px solid var(--accent)' : 'none',
@@ -506,6 +555,8 @@ function DroppableFolderItem({ folder, depth, expanded, active, editing, editVal
}}
onClick={onClick}
onContextMenu={onContextMenu}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<button onClick={e => { e.stopPropagation(); onToggle() }} className="toolbar-btn" style={{ width: 16, height: 16, flexShrink: 0 }}>
{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
@@ -519,6 +570,18 @@ function DroppableFolderItem({ folder, depth, expanded, active, editing, editVal
) : (
<span className="flex-1 truncate" style={{ fontWeight: active ? 500 : 400 }}>{folder.name}</span>
)}
{hovered && !editing && (
<button
className="toolbar-btn"
style={{ width: 20, height: 20, color: '#ef4444', opacity: 0.8, flexShrink: 0 }}
title="删除文件夹"
onClick={e => { e.stopPropagation(); onDelete() }}
onMouseEnter={e => (e.currentTarget.style.opacity = '1')}
onMouseLeave={e => (e.currentTarget.style.opacity = '0.8')}
>
<Trash2 size={12} />
</button>
)}
</div>
)
}
+6 -2
View File
@@ -64,8 +64,12 @@ export function seedIfEmpty(): Promise<void> {
}
async function _doSeed() {
const count = await db.notes.count()
if (count > 0) return
// 用文件夹数量做判断,防止 notes 为空但文件夹已存在时重复创建
const [noteCount, folderCount] = await Promise.all([
db.notes.count(),
db.folders.count(),
])
if (noteCount > 0 || folderCount > 0) return
const folderId1 = crypto.randomUUID()
const folderId2 = crypto.randomUUID()
+1 -1
View File
@@ -11,7 +11,7 @@ export async function streamAI(
onChunk: (text: string) => void,
signal?: AbortSignal,
): Promise<void> {
const res = await fetch('/api/ai/stream', {
const res = await fetch('https://www.mikivl.online/api/note/ai/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
+208
View File
@@ -0,0 +1,208 @@
const TOKEN_KEY = 'mikivl_token'
const API = 'https://www.mikivl.online/api'
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)
}
export function setToken(token: string) {
localStorage.setItem(TOKEN_KEY, token)
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY)
}
export function authHeaders(): Record<string, string> {
const token = getToken()
return token ? { Authorization: `Bearer ${token}` } : {}
}
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({ email, code }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? '注册失败')
return data
}
export async function apiLogin(username: string, password: string): Promise<{ token: string; user: CurrentUser }> {
const res = await fetch(`${API}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? '登录失败')
return data
}
export async function apiActivate(code: string): Promise<void> {
const res = await fetch(`${API}/auth/activate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ code }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? '激活失败')
}
export async function apiUpdateProfile(nickname?: string, avatar?: string): Promise<CurrentUser> {
const res = await fetch(`${API}/auth/me`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ nickname, avatar }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? '更新失败')
return data
}
export async function apiChangePassword(oldPassword: string, newPassword: string): Promise<void> {
const res = await fetch(`${API}/auth/change-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ oldPassword, newPassword }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? '修改失败')
}
export async function apiGetMe(): Promise<CurrentUser> {
const res = await fetch(`${API}/auth/me`, { headers: authHeaders() })
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? '获取用户信息失败')
return data
}
export async function apiSendEmailVerify(email: string): Promise<void> {
const res = await fetch(`${API}/auth/email/send-verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ email }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? '发送失败')
}
export async function apiVerifyEmail(code: string): Promise<void> {
const res = await fetch(`${API}/auth/email/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ code }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? '验证失败')
}
export async function apiForgotPassword(email: string): Promise<void> {
const res = await fetch(`${API}/auth/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? '发送失败')
}
export async function apiResetPassword(email: string, code: string, newPassword: string): Promise<void> {
const res = await fetch(`${API}/auth/reset-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, code, newPassword }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? '重置失败')
}
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, 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
}
+72
View File
@@ -0,0 +1,72 @@
import type { Note, Folder } from '../db'
import { authHeaders } from './auth'
const API = 'https://www.mikivl.online/api/note'
export type SyncPayload = { notes: Note[]; folders: Folder[] }
export async function pullAll(): Promise<SyncPayload> {
const res = await fetch(`${API}/notes/sync`, { headers: authHeaders() })
if (!res.ok) throw new Error('同步失败')
return res.json()
}
export async function pushAll(payload: SyncPayload): Promise<void> {
const res = await fetch(`${API}/notes/sync`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(payload),
})
if (!res.ok) throw new Error('推送失败')
}
export async function pushNote(note: Note): Promise<void> {
fetch(`${API}/notes/${note.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(note),
}).catch(() => {})
}
export async function pushFolder(folder: Folder): Promise<void> {
fetch(`${API}/folders/${folder.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(folder),
}).catch(() => {})
}
export async function pushDeleteNote(id: string): Promise<void> {
fetch(`${API}/notes/${id}`, {
method: 'DELETE',
headers: authHeaders(),
}).catch(() => {})
}
export async function pushDeleteFolder(id: string): Promise<void> {
fetch(`${API}/folders/${id}`, {
method: 'DELETE',
headers: authHeaders(),
}).catch(() => {})
}
export function mergeNotes(local: Note[], remote: Note[]): Note[] {
const map = new Map<string, Note>()
for (const n of local) map.set(n.id, n)
for (const n of remote) {
const existing = map.get(n.id)
if (!existing || n.updatedAt > existing.updatedAt) {
map.set(n.id, { ...n, tags: Array.isArray(n.tags) ? n.tags : JSON.parse(n.tags as any) })
}
}
return Array.from(map.values())
}
export function mergeFolders(local: Folder[], remote: Folder[]): Folder[] {
const map = new Map<string, Folder>()
for (const f of local) map.set(f.id, f)
for (const f of remote) {
if (!map.has(f.id)) map.set(f.id, f)
}
return Array.from(map.values())
}
+66
View File
@@ -1,6 +1,8 @@
import { create } from 'zustand'
import { db, type Note, type Folder } from '../db'
import { generateId, extractTextFromJSON } from '../lib/utils'
import { getToken, clearToken, parseToken, apiGetMe, type CurrentUser } from '../lib/auth'
import { pullAll, pushAll, pushNote, pushFolder, pushDeleteNote, pushDeleteFolder } from '../lib/sync'
interface AppState {
notes: Note[]
@@ -42,6 +44,13 @@ interface AppState {
restoreNote: (id: string) => Promise<void>
emptyTrash: () => Promise<void>
trashNotes: () => Note[]
currentUser: CurrentUser | null
syncStatus: 'idle' | 'syncing' | 'error'
setCurrentUser: (user: CurrentUser | null) => void
logout: () => void
refreshCurrentUser: () => Promise<void>
syncFromCloud: () => Promise<void>
}
export const useAppStore = create<AppState>((set, get) => ({
@@ -58,6 +67,8 @@ export const useAppStore = create<AppState>((set, get) => ({
sortOrder: 'desc',
_notesVersion: 0,
_filteredCache: null,
currentUser: (() => { const token = getToken(); return token ? parseToken(token) : null })(),
syncStatus: 'idle' as const,
loadAll: async () => {
let [notes, folders] = await Promise.all([
@@ -95,6 +106,7 @@ export const useAppStore = create<AppState>((set, get) => ({
}
await db.notes.add(note)
set(s => ({ notes: [note, ...s.notes], activeNoteId: id, _notesVersion: s._notesVersion + 1, _filteredCache: null }))
if (get().currentUser?.cloudEnabled) pushNote(note)
return id
},
@@ -110,6 +122,8 @@ export const useAppStore = create<AppState>((set, get) => ({
_notesVersion: s._notesVersion + 1,
_filteredCache: null,
}))
const updated = get().notes.find(n => n.id === id)
if (updated && get().currentUser?.cloudEnabled) pushNote(updated)
},
deleteNote: async (id) => {
@@ -120,6 +134,7 @@ export const useAppStore = create<AppState>((set, get) => ({
const activeNoteId = s.activeNoteId === id ? '__welcome__' : s.activeNoteId
return { notes, activeNoteId, _notesVersion: s._notesVersion + 1, _filteredCache: null }
})
if (get().currentUser?.cloudEnabled) pushDeleteNote(id)
},
restoreNote: async (id) => {
@@ -155,12 +170,15 @@ export const useAppStore = create<AppState>((set, get) => ({
const folder: Folder = { id, name, parentId, order, createdAt: Date.now() }
await db.folders.add(folder)
set(s => ({ folders: [...s.folders, folder] }))
if (get().currentUser?.cloudEnabled) pushFolder(folder)
return id
},
updateFolder: async (id, patch) => {
await db.folders.update(id, patch)
set(s => ({ folders: s.folders.map(f => f.id === id ? { ...f, ...patch } : f) }))
const updatedF = get().folders.find(f => f.id === id)
if (updatedF && get().currentUser?.cloudEnabled) pushFolder(updatedF)
},
deleteFolder: async (id) => {
@@ -170,6 +188,7 @@ export const useAppStore = create<AppState>((set, get) => ({
const notesToMove = get().notes.filter(n => n.folderId === id)
for (const n of notesToMove) await get().updateNote(n.id, { folderId: null })
set(s => ({ folders: s.folders.filter(f => f.id !== id) }))
if (get().currentUser?.cloudEnabled) pushDeleteFolder(id)
},
setActiveNote: (id) => set({ activeNoteId: id }),
@@ -238,4 +257,51 @@ export const useAppStore = create<AppState>((set, get) => ({
.filter(n => n.deletedAt !== null)
.sort((a, b) => (b.deletedAt ?? 0) - (a.deletedAt ?? 0))
},
setCurrentUser: (user) => set({ currentUser: user }),
logout: () => {
clearToken()
set({ currentUser: null, syncStatus: 'idle' })
},
refreshCurrentUser: async () => {
const token = getToken()
if (!token) return
try {
const user = await apiGetMe()
set({ currentUser: user })
if (user.cloudEnabled) get().syncFromCloud()
} catch {
// token 失效则清除
clearToken()
set({ currentUser: null })
}
},
syncFromCloud: async () => {
const { notes, folders, currentUser } = get()
if (!currentUser?.cloudEnabled) return
set({ syncStatus: 'syncing' })
try {
const remote = await pullAll()
// 远端有数据时以远端为准,避免本地种子数据污染云端
// 只有远端完全为空(首次激活)才把本地数据上传
if (remote.notes.length > 0 || remote.folders.length > 0) {
await db.notes.clear()
await db.folders.clear()
await Promise.all(remote.notes.map((n: any) => db.notes.put({ ...n, tags: Array.isArray(n.tags) ? n.tags : JSON.parse(n.tags as any) })))
await Promise.all(remote.folders.map((f: any) => db.folders.put(f)))
set({ notes: remote.notes.map((n: any) => ({ ...n, tags: Array.isArray(n.tags) ? n.tags : JSON.parse(n.tags as any) })), folders: remote.folders, syncStatus: 'idle', _notesVersion: get()._notesVersion + 1, _filteredCache: null })
} else {
// 远端为空:首次激活,把本地数据推上去
const localNotes = notes.filter(n => n.deletedAt === null)
const localFolders = folders
await pushAll({ notes: localNotes, folders: localFolders })
set({ syncStatus: 'idle' })
}
} catch {
set({ syncStatus: 'error' })
}
},
}))
+10 -1
View File
@@ -3,7 +3,7 @@ import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
base: '/app/',
base: '/',
plugins: [react(), tailwindcss()],
server: {
proxy: {
@@ -23,6 +23,15 @@ export default defineConfig({
if (id.includes('framer-motion') || id.includes('@dnd-kit') || id.includes('lucide-react')) {
return 'vendor-ui'
}
if (id.includes('node_modules/pdfjs-dist')) {
return 'vendor-pdf'
}
if (id.includes('node_modules/mammoth') || id.includes('node_modules/docx')) {
return 'vendor-docx'
}
if (id.includes('node_modules/zustand') || id.includes('node_modules/dexie')) {
return 'vendor-state'
}
},
},
},