Compare commits

..

No commits in common. "f2681cb3de0a1198874a40adba0abac454e2ea9a" and "005a608369f74383821396cc9ef6a53b7ecbc965" have entirely different histories.

18 changed files with 336 additions and 3278 deletions

View File

@ -1,3 +1 @@
ANTHROPIC_API_KEY=sk-ant-your-key-here
JWT_SECRET=change-this-to-a-random-secret
JWT_SECRET=change-this-to-a-random-secret

View File

@ -1,238 +0,0 @@
# 用户认证 + 云端同步 + 评论系统设计文档
**日期:** 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 token30 天足够,过期重新登录)
- 密码使用 bcrypt hashcost 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 评论区)

View File

@ -1,624 +1,371 @@
<!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>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet"/>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #06080f;
--border: rgba(255,255,255,0.06);
--text: #d4d8e8;
--muted: #5a6480;
--faint: #2a3050;
--accent: #7c83ff;
--accent2: #4af0b0;
}
html { scroll-behavior: smooth; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Inter', sans-serif;
font-size: 14px;
line-height: 1.7;
min-height: 100vh;
overflow-x: hidden;
}
#starfield { position: fixed; inset: 0; z-index: 0; pointer-events: none; }
#wrapper { position: relative; z-index: 1; display: flex; min-height: 100vh; }
<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; }
/* ── 侧边栏 ── */
#sidebar {
width: 220px; flex-shrink: 0;
position: fixed; top: 0; left: 0; bottom: 0;
background: rgba(8,11,20,0.88);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
padding: 2.5rem 1.5rem; z-index: 10;
}
.site-title {
font-size: 1.05rem; font-weight: 800; color: var(--text);
letter-spacing: -0.03em; margin-bottom: 0.25rem; text-decoration: none;
}
.site-title em { color: var(--accent); font-style: normal; }
.site-desc { font-size: 0.7rem; color: var(--muted); letter-spacing: 0.06em; margin-bottom: 2.5rem; }
.sidebar-constellation { width: 100%; height: 80px; margin-bottom: 2rem; }
.sidebar-constellation canvas { width: 100%; height: 100%; }
nav ul { list-style: none; }
nav ul li { margin-bottom: 0.1rem; }
nav ul li a {
display: block; padding: 0.4rem 0.6rem;
color: var(--muted); text-decoration: none;
font-size: 0.82rem; border-radius: 4px;
transition: all 0.15s; position: relative;
}
nav ul li a:hover, nav ul li a.active { color: var(--text); background: rgba(124,131,255,0.08); }
nav ul li a.active::before {
content: ''; position: absolute; left: 0; top: 25%; bottom: 25%;
width: 2px; background: var(--accent); border-radius: 1px;
}
.sidebar-games { margin-top: auto; padding-top: 1.5rem; border-top: 1px solid var(--border); }
.sidebar-games-label { font-size: 0.6rem; letter-spacing: 0.15em; text-transform: uppercase; color: var(--faint); margin-bottom: 0.75rem; }
.game-tags { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.game-tag {
font-size: 0.65rem; padding: 0.2rem 0.5rem;
border-radius: 3px; border: 1px solid var(--border);
color: var(--muted); transition: all 0.15s; cursor: default; user-select: none;
}
.game-tag:hover { border-color: var(--accent); color: var(--accent); }
.game-tag.mc { border-color: rgba(134,197,100,0.3); color: #86c564; }
.game-tag.oni { border-color: rgba(74,240,176,0.3); color: #4af0b0; }
.game-tag.er { border-color: rgba(234,179,8,0.3); color: #eab308; }
.game-tag.cp { border-color: rgba(0,255,200,0.3); color: #00ffc8; }
.game-tag.ak { border-color: rgba(0,180,255,0.3); color: #00b4ff; }
: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;
}
/* ── 主内容 ── */
#main { margin-left: 220px; flex: 1; padding: 3rem 3rem 4rem; max-width: 800px; }
html { scroll-behavior: smooth; }
/* ── Hero ── */
.hero { padding: 2rem 0 3rem; border-bottom: 1px solid var(--border); margin-bottom: 3rem; position: relative; }
.hero-eyebrow {
font-size: 0.7rem; letter-spacing: 0.15em; text-transform: uppercase;
color: var(--accent2); margin-bottom: 0.75rem;
display: flex; align-items: center; gap: 0.5rem;
}
.hero-eyebrow::before { content: ''; width: 20px; height: 1px; background: var(--accent2); flex-shrink: 0; }
.hero-name { font-size: clamp(2rem, 5vw, 3rem); font-weight: 800; letter-spacing: -0.04em; line-height: 1.05; color: var(--text); margin-bottom: 1rem; }
.hero-name em { color: var(--accent); font-style: normal; }
.hero-bio { font-size: 0.9rem; color: var(--muted); max-width: 480px; line-height: 1.85; font-weight: 300; margin-bottom: 1.5rem; }
.hero-status {
display: inline-flex; align-items: center; gap: 0.5rem;
font-size: 0.72rem; color: var(--muted); padding: 0.3rem 0.75rem;
background: rgba(74,240,176,0.05); border: 1px solid rgba(74,240,176,0.15); border-radius: 20px;
}
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent2); animation: pulse 2s ease-in-out infinite; flex-shrink: 0; }
@keyframes pulse {
0%,100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74,240,176,0.4); }
50% { opacity: 0.7; box-shadow: 0 0 0 4px rgba(74,240,176,0); }
}
.hero-deco { position: absolute; right: 0; top: 2rem; display: flex; flex-direction: column; gap: 0.5rem; opacity: 0.15; pointer-events: none; }
.hero-deco-char { font-size: 1.8rem; color: var(--accent); line-height: 1; }
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;
}
/* ── Section 标题 ── */
.section-label {
font-size: 0.65rem; letter-spacing: 0.18em; text-transform: uppercase;
color: var(--accent); margin-bottom: 1.5rem;
display: flex; align-items: center; gap: 0.75rem;
}
.section-label::after { content: ''; flex: 1; height: 1px; background: linear-gradient(90deg, var(--border), transparent); }
/* ── 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); }
/* ── Flow 时间线 ── */
.flow-list { display: flex; flex-direction: column; }
.flow-item {
display: flex; gap: 1.5rem; padding: 1.75rem 0;
border-bottom: 1px solid var(--border);
transition: transform 0.2s; cursor: default;
}
.flow-item:first-child { padding-top: 0; }
.flow-item:last-child { border-bottom: none; }
.flow-item:hover { transform: translateX(4px); }
.flow-item:hover .flow-title { color: var(--accent); }
.flow-item:hover .flow-dot { background: var(--accent); border-color: var(--accent); box-shadow: 0 0 8px rgba(124,131,255,0.5); }
.flow-timeline { width: 48px; flex-shrink: 0; display: flex; flex-direction: column; align-items: center; padding-top: 4px; }
.flow-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--faint); border: 1px solid rgba(255,255,255,0.1); flex-shrink: 0; transition: all 0.2s; }
.flow-line { width: 1px; flex: 1; background: var(--border); margin-top: 6px; }
.flow-item:last-child .flow-line { display: none; }
.flow-content { flex: 1; min-width: 0; }
.flow-meta { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.4rem; flex-wrap: wrap; }
.flow-category { font-size: 0.65rem; letter-spacing: 0.1em; text-transform: uppercase; padding: 0.15rem 0.5rem; border-radius: 3px; }
.cat-ai { background: rgba(124,131,255,0.1); color: var(--accent); }
.cat-dev { background: rgba(74,240,176,0.1); color: var(--accent2); }
.cat-ops { background: rgba(0,180,255,0.1); color: #00b4ff; }
.cat-game { background: rgba(234,179,8,0.1); color: #eab308; }
.flow-date { font-size: 0.68rem; color: var(--muted); }
.flow-title { font-size: 1rem; font-weight: 600; color: var(--text); letter-spacing: -0.02em; line-height: 1.4; margin-bottom: 0.5rem; transition: color 0.15s; }
.flow-excerpt { font-size: 0.82rem; color: var(--muted); line-height: 1.75; font-weight: 300; }
.flow-game-ref {
display: inline-flex; align-items: center; gap: 0.35rem;
font-size: 0.65rem; color: var(--faint); margin-top: 0.6rem;
padding: 0.2rem 0.5rem; border: 1px solid var(--border); border-radius: 3px;
}
.flow-game-ref span { color: var(--muted); }
/* ── 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; }
/* ── 项目 ── */
.projects-section { margin-top: 3.5rem; }
.project-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.project-card {
background: rgba(255,255,255,0.02); border: 1px solid var(--border);
border-radius: 8px; padding: 1.25rem 1.4rem;
text-decoration: none; display: block;
transition: all 0.2s; position: relative; overflow: hidden;
}
.project-card.full { grid-column: 1 / -1; }
.project-card::after {
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 1px;
background: linear-gradient(90deg, var(--accent), var(--accent2)); opacity: 0; transition: opacity 0.2s;
}
.project-card:hover { background: rgba(124,131,255,0.04); border-color: rgba(124,131,255,0.2); transform: translateY(-2px); }
.project-card:hover::after { opacity: 1; }
.project-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 0.4rem; }
.project-name { font-size: 0.9rem; font-weight: 600; color: var(--text); }
.project-status { font-size: 0.65rem; padding: 0.15rem 0.5rem; border-radius: 20px; white-space: nowrap; background: rgba(74,240,176,0.08); color: var(--accent2); border: 1px solid rgba(74,240,176,0.2); margin-top: 1px; }
.project-desc { font-size: 0.77rem; color: var(--muted); line-height: 1.7; font-weight: 300; margin-bottom: 0.75rem; }
.project-tags { display: flex; flex-wrap: wrap; gap: 0.3rem; }
.ptag { font-size: 0.62rem; padding: 0.15rem 0.4rem; background: rgba(255,255,255,0.04); border: 1px solid var(--border); color: var(--muted); border-radius: 3px; }
.ptag.sky { background: rgba(8,47,73,0.6); color: #7dd3fc; border-color: rgba(125,211,252,0.15); }
.ptag.green { background: rgba(5,46,22,0.6); color: #6ee7b7; border-color: rgba(110,231,183,0.15); }
.ptag.purple { background: rgba(46,16,101,0.6); color: #c4b5fd; border-color: rgba(196,181,253,0.15); }
.ptag.orange { background: rgba(67,20,7,0.6); color: #fdba74; border-color: rgba(253,186,116,0.15); }
/* ── 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;
}
/* ── 联系 ── */
.contact-section { margin-top: 3.5rem; }
.contact-link {
display: inline-flex; align-items: center; gap: 0.6rem;
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); width: 1.1rem; text-align: center; }
/* ── 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); }
/* ── 评论区 ── */
.comments-section { margin-left: 220px; padding: 2.5rem 3rem; border-top: 1px solid var(--border); }
@media (max-width: 768px) { .comments-section { margin-left: 0; padding: 2rem 1.25rem; } }
.comment-item { padding: 0.75rem 0; border-bottom: 1px solid var(--border); }
.comment-item:last-child { border-bottom: none; }
.comment-avatar {
width: 20px; height: 20px; border-radius: 50%;
background: var(--accent); color: #fff;
display: inline-flex; align-items: center; justify-content: center;
font-size: 0.65rem; font-weight: 700; flex-shrink: 0;
}
/* ── Divider ── */
.divider { border: none; border-top: 1px solid var(--border); margin: 0; }
/* ── Footer ── */
footer {
margin-left: 220px; padding: 1.5rem 3rem;
border-top: 1px solid var(--border);
font-size: 0.72rem; color: var(--faint);
display: flex; align-items: center; justify-content: space-between;
}
footer a { color: var(--faint); text-decoration: none; transition: color 0.15s; }
footer a:hover { color: var(--muted); }
/* ── 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; }
/* ── 响应式 ── */
@media (max-width: 768px) {
#sidebar { display: none; }
#main { margin-left: 0; padding: 2rem 1.25rem 3rem; max-width: 100%; }
footer { margin-left: 0; padding: 1.25rem; }
.project-grid { grid-template-columns: 1fr; }
.project-card.full { grid-column: auto; }
}
</style>
/* ── 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>
<canvas id="starfield"></canvas>
<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>
<div id="wrapper">
<main>
<aside id="sidebar">
<a class="site-title" href="/">MikiVL<em>.</em></a>
<div class="site-desc">独立开发者 · 游戏玩家</div>
<div class="sidebar-constellation">
<canvas id="const-canvas"></canvas>
<!-- 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>
<nav>
<ul>
<li><a href="#about" class="active">首页</a></li>
<li><a href="#updates">动态</a></li>
<li><a href="#projects">项目</a></li>
<li><a href="#contact">联系</a></li>
</ul>
</nav>
<div class="sidebar-games">
<div class="sidebar-games-label">正在游玩</div>
<div class="game-tags">
<span class="game-tag mc">⛏ MC</span>
<span class="game-tag oni">⚗ ONI</span>
<span class="game-tag er">⚔ ER</span>
<span class="game-tag cp">◈ CP77</span>
<span class="game-tag ak">▣ AK</span>
</div>
</div>
</aside>
</section>
<main id="main">
<hr class="divider" />
<section class="hero" id="about">
<div class="hero-deco" aria-hidden="true">
<span class="hero-deco-char"></span>
<span class="hero-deco-char"></span>
<span class="hero-deco-char"></span>
</div>
<div class="hero-eyebrow">独立开发者 · AI 工具构建者</div>
<h1 class="hero-name">MikiVL<em>.</em></h1>
<p class="hero-bio">
专注于构建 AI 驱动的生产力工具。<br>
探索代码与星空之间的可能性,用像素砌出真实的解决方案。
</p>
<div class="hero-status">
<span class="status-dot"></span>
当前在线 · 持续迭代中
</div>
</section>
<!-- Projects -->
<section id="projects">
<p class="section-label">// projects</p>
<h2>开发项目</h2>
<div class="projects-grid">
<section id="updates">
<div class="section-label">// 最近动态</div>
<div class="flow-list">
<div class="flow-item">
<div class="flow-timeline"><div class="flow-dot"></div><div class="flow-line"></div></div>
<div class="flow-content">
<div class="flow-meta">
<span class="flow-category cat-dev">新项目</span>
<span class="flow-date">2026年5月</span>
</div>
<div class="flow-title">GitHub Trending 追踪器上线</div>
<div class="flow-excerpt">自动抓取 GitHub 每日热门仓库支持按时间筛选与收藏7 天自动清理保持列表干净。Cheerio 爬取、SQLite 持久化、Express 提供 API。</div>
<div class="flow-game-ref"><span>像缺氧里建自动化流水线,数据进来就会自己跑</span></div>
</div>
<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>
<div class="flow-item">
<div class="flow-timeline"><div class="flow-dot"></div><div class="flow-line"></div></div>
<div class="flow-content">
<div class="flow-meta">
<span class="flow-category cat-ai">AI 工具</span>
<span class="flow-date">2026年4月</span>
</div>
<div class="flow-title">笔记 v2.0 — 重构编辑器,引入 TipTap</div>
<div class="flow-excerpt">全面迁移至 TipTap + ProseMirror。支持 AI 续写、润色、摘要、翻译,多文件夹管理,标签系统,导入导出 MD / DOCX / PDFIndexedDB 本地存储,隐私优先。</div>
<div class="flow-game-ref"><span>像在我的世界里一层一层往下挖,终于找到了钻石层</span></div>
</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>
<div class="flow-item">
<div class="flow-timeline"><div class="flow-dot"></div><div class="flow-line"></div></div>
<div class="flow-content">
<div class="flow-meta">
<span class="flow-category cat-ops">运维</span>
<span class="flow-date">2026年3月</span>
</div>
<div class="flow-title">迁移到 Caddy + PM2重建部署流水线</div>
<div class="flow-excerpt">抛弃 Nginx 配置地狱,拥抱 Caddy 自动 HTTPS。PM2 管理 Hono 后端进程rsync 增量同步,整套流程一条命令跑通。</div>
<div class="flow-game-ref"><span>罗德岛的后勤系统——每个节点都要保持在线</span></div>
</div>
<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>
<div class="flow-item">
<div class="flow-timeline"><div class="flow-dot"></div><div class="flow-line"></div></div>
<div class="flow-content">
<div class="flow-meta">
<span class="flow-category cat-game">游戏 · 随想</span>
<span class="flow-date">2026年2月</span>
</div>
<div class="flow-title">艾尔登法环与软件架构——褪色者的地图思维</div>
<div class="flow-excerpt">开放世界设计与模块化系统有什么共同点?在交界地迷路,和在遗留代码里迷路,感受惊人地相似。</div>
<div class="flow-game-ref"><span>黄金树的光芒照不到的地方,才是真正有趣的地方</span></div>
</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>
<section class="projects-section" id="projects">
<div class="section-label">// 项目</div>
<div class="project-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="ptag">React 19</span>
<span class="ptag">TypeScript</span>
<span class="ptag sky">Vite</span>
<span class="ptag green">Hono</span>
<span class="ptag purple">Claude API</span>
<span class="ptag orange">TipTap</span>
<span class="ptag">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="ptag sky">Vite</span>
<span class="ptag">Vanilla JS</span>
</div>
</a>
<a class="project-card full" href="/github/" target="_blank" rel="noopener">
<div class="project-header">
<span class="project-name">GitHub Trending</span>
<span class="project-status">● 上线中</span>
</div>
<p class="project-desc">GitHub 热门仓库追踪器。自动抓取每日 Trending 数据支持按时间筛选、收藏7 天内自动清理未收藏条目,保持列表简洁。</p>
<div class="project-tags">
<span class="ptag">React</span>
<span class="ptag sky">Vite</span>
<span class="ptag green">Express</span>
<span class="ptag orange">SQLite</span>
<span class="ptag">Cheerio</span>
</div>
</a>
</div>
</section>
<section class="contact-section" id="contact">
<div class="section-label">// 联系</div>
<div style="display:flex;flex-direction:column;gap:0.75rem;">
<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>
<a class="contact-link" href="mailto:2569798878@qq.com">
<span class="contact-icon"></span>2569798878@qq.com
</a>
</div>
</section>
</main>
</div>
<!-- 评论区 -->
<section class="comments-section" id="comments">
<div class="section-label">// 留言</div>
<div id="comment-form-area" style="margin-bottom:1.5rem;">
<div id="comment-login-hint" style="font-size:0.78rem;color:var(--muted);">
<a href="javascript:void(0)" onclick="openLoginModal()" style="color:var(--accent);text-decoration:none;">登录</a> 后发表留言
</div>
<form id="comment-form" style="display:none;flex-direction:column;gap:0.75rem;">
<textarea id="comment-input" rows="3" maxlength="500" placeholder="写点什么…(最多 500 字)"
style="width:100%;padding:0.6rem 0.8rem;background:rgba(255,255,255,0.03);
border:1px solid var(--border);border-radius:8px;color:var(--text);
font-size:0.82rem;font-family:inherit;resize:vertical;line-height:1.6;outline:none;"></textarea>
<div style="display:flex;justify-content:flex-end;">
<button type="submit"
style="padding:0.4rem 1rem;background:var(--accent);color:#fff;border:none;border-radius:6px;font-size:0.8rem;cursor:pointer;">
发表
</button>
</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>
</form>
</div>
<div id="comments-list" style="display:flex;flex-direction:column;"></div>
</section>
<!-- 登录弹窗(首页专用) -->
<div id="hp-login-modal" style="display:none;position:fixed;inset:0;z-index:100;background:rgba(0,0,0,0.5);align-items:center;justify-content:center;">
<div style="background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:1.5rem;width:300px;display:flex;flex-direction:column;gap:1rem;">
<h3 style="font-size:0.9rem;font-weight:600;color:var(--text);margin:0;">登录 / 注册</h3>
<div style="display:flex;gap:0.5rem;padding:0.25rem;background:rgba(255,255,255,0.04);border-radius:8px;">
<button id="hp-tab-login" onclick="hpSetTab('login')"
style="flex:1;padding:0.3rem;border:none;border-radius:6px;font-size:0.75rem;background:rgba(255,255,255,0.08);color:var(--text);cursor:pointer;">登录</button>
<button id="hp-tab-register" onclick="hpSetTab('register')"
style="flex:1;padding:0.3rem;border:none;border-radius:6px;font-size:0.75rem;background:transparent;color:var(--muted);cursor:pointer;">注册</button>
</div>
<input id="hp-username" type="text" placeholder="用户名"
style="padding:0.5rem 0.75rem;background:rgba(255,255,255,0.04);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:0.82rem;outline:none;"/>
<input id="hp-password" type="password" placeholder="密码"
style="padding:0.5rem 0.75rem;background:rgba(255,255,255,0.04);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:0.82rem;outline:none;"/>
<p id="hp-login-error" style="color:#ef4444;font-size:0.75rem;display:none;margin:0;"></p>
<div style="display:flex;gap:0.5rem;justify-content:flex-end;">
<button onclick="closeLoginModal()"
style="padding:0.4rem 0.8rem;background:transparent;border:1px solid var(--border);border-radius:6px;color:var(--muted);font-size:0.8rem;cursor:pointer;">取消</button>
<button id="hp-login-submit" onclick="hpSubmit()"
style="padding:0.4rem 1rem;background:var(--accent);color:#fff;border:none;border-radius:6px;font-size:0.8rem;cursor:pointer;">登录</button>
</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>
</div>
</div>
</section>
</main>
<footer>
<span>© 2026 MikiVL</span>
<span>✦ built with <a href="https://claude.ai/code" target="_blank" rel="noopener">Claude Code</a></span>
<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>
<script>
(function () {
const canvas = document.getElementById('starfield');
const ctx = canvas.getContext('2d');
let stars = [], W, H;
function resize() { W = canvas.width = window.innerWidth; H = canvas.height = window.innerHeight; }
function initStars() {
stars = [];
const n = Math.floor((W * H) / 4000);
for (let i = 0; i < n; i++) stars.push({ x: Math.random()*W, y: Math.random()*H, r: Math.random()*1.2+0.2, o: Math.random()*0.6+0.2, spd: Math.random()*0.3+0.05, ph: Math.random()*Math.PI*2 });
}
let t = 0;
function draw() {
ctx.clearRect(0, 0, W, H);
t += 0.008;
for (const s of stars) {
ctx.beginPath();
ctx.arc(s.x, s.y, s.r, 0, Math.PI*2);
ctx.fillStyle = `rgba(255,255,255,${s.o*(0.7+0.3*Math.sin(t*s.spd+s.ph))})`;
ctx.fill();
}
if (Math.random() < 0.002) {
const mx = Math.random()*W, my = Math.random()*H*0.5, len = 40+Math.random()*60;
const g = ctx.createLinearGradient(mx,my,mx+len,my+len*0.4);
g.addColorStop(0,'rgba(255,255,255,0)'); g.addColorStop(0.4,'rgba(200,210,255,0.45)'); g.addColorStop(1,'rgba(255,255,255,0)');
ctx.beginPath(); ctx.moveTo(mx,my); ctx.lineTo(mx+len,my+len*0.4);
ctx.strokeStyle=g; ctx.lineWidth=1; ctx.stroke();
}
requestAnimationFrame(draw);
}
resize(); initStars(); draw();
window.addEventListener('resize', () => { resize(); initStars(); });
})();
(function () {
const canvas = document.getElementById('const-canvas');
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = canvas.offsetWidth * dpr;
canvas.height = canvas.offsetHeight * dpr;
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
// 摩羯座主星坐标(按真实星图比例归一化)
// 0:α²Cap(Algedi) 1:β Cap(Dabih) 2:ψCap 3:ωCap 4:24Cap
// 5:δCap(Deneb Algedi) 6:γCap(Nashira) 7:ιCap 8:θCap 9:ζCap 10:εCap 11:ηCap 12:ρCap
const rawPts = [
[0.08,0.22],[0.18,0.28],[0.30,0.18],[0.42,0.14],[0.52,0.20],
[0.92,0.55],[0.82,0.48],[0.68,0.70],[0.58,0.78],[0.72,0.38],
[0.62,0.32],[0.50,0.58],[0.38,0.64]
];
const sizes = [2.2,1.8,1.2,1.0,1.0, 2.4,1.4,1.0,0.9,1.1,0.9,0.9,0.8];
const pts = rawPts.map(([rx,ry],i) => ({
x: rx*W, y: ry*H,
r: sizes[i] * dpr,
o: 0.55 + sizes[i] * 0.12
}));
// 连线:摩羯座的弓形轮廓
const lines = [
[0,1],[1,2],[2,3],[3,4],[4,9], // 上弧(头部 → 背部)
[9,10],[10,5], // 背部 → 尾部
[5,6],[6,7],[7,8],[8,12],[12,11], // 腹部弧线
[11,1] // 闭合(腹 → 头)
];
ctx.strokeStyle = 'rgba(124,131,255,0.15)'; ctx.lineWidth = dpr;
for (const [a,b] of lines) { ctx.beginPath(); ctx.moveTo(pts[a].x,pts[a].y); ctx.lineTo(pts[b].x,pts[b].y); ctx.stroke(); }
for (const p of pts) { ctx.beginPath(); ctx.arc(p.x,p.y,p.r,0,Math.PI*2); ctx.fillStyle=`rgba(200,210,255,${p.o})`; ctx.fill(); }
})();
(function () {
const sections = document.querySelectorAll('section[id]');
const links = document.querySelectorAll('nav a');
const obs = new IntersectionObserver(entries => {
for (const e of entries) {
if (e.isIntersecting) {
links.forEach(l => l.classList.remove('active'));
const a = document.querySelector(`nav a[href="#${e.target.id}"]`);
if (a) a.classList.add('active');
}
}
}, { threshold: 0.4 });
sections.forEach(s => obs.observe(s));
})();
// ── 评论系统 ──
const HP_TOKEN_KEY = 'mikivl_token'
let hpUser = null
let hpTab = 'login'
function hpGetToken() { return localStorage.getItem(HP_TOKEN_KEY) }
function hpSetToken(t) { localStorage.setItem(HP_TOKEN_KEY, t) }
function hpParseToken(token) {
try { const p = JSON.parse(atob(token.split('.')[1])); return { id: p.userId, username: p.username } }
catch { return null }
}
function hpSetTab(tab) {
hpTab = tab
document.getElementById('hp-tab-login').style.background = tab === 'login' ? 'rgba(255,255,255,0.08)' : 'transparent'
document.getElementById('hp-tab-login').style.color = tab === 'login' ? 'var(--text)' : 'var(--muted)'
document.getElementById('hp-tab-register').style.background = tab === 'register' ? 'rgba(255,255,255,0.08)' : 'transparent'
document.getElementById('hp-tab-register').style.color = tab === 'register' ? 'var(--text)' : 'var(--muted)'
document.getElementById('hp-login-submit').textContent = tab === 'login' ? '登录' : '注册'
}
function openLoginModal() { document.getElementById('hp-login-modal').style.display = 'flex' }
function closeLoginModal() { document.getElementById('hp-login-modal').style.display = 'none' }
async function hpSubmit() {
const username = document.getElementById('hp-username').value.trim()
const password = document.getElementById('hp-password').value
const errEl = document.getElementById('hp-login-error')
errEl.style.display = 'none'
if (!username || !password) { errEl.textContent = '请填写用户名和密码'; errEl.style.display = 'block'; return }
try {
const endpoint = hpTab === 'login' ? '/api/auth/login' : '/api/auth/register'
const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) })
const data = await res.json()
if (!res.ok) { errEl.textContent = data.error || '操作失败'; errEl.style.display = 'block'; return }
hpSetToken(data.token)
hpUser = data.user
closeLoginModal()
updateCommentUI()
} catch { errEl.textContent = '网络错误'; errEl.style.display = 'block' }
}
function updateCommentUI() {
const hint = document.getElementById('comment-login-hint')
const form = document.getElementById('comment-form')
if (hpUser) { hint.style.display = 'none'; form.style.display = 'flex' }
else { hint.style.display = 'block'; form.style.display = 'none' }
}
async function loadComments() {
try {
const res = await fetch('/api/comments')
const list = await res.json()
const el = document.getElementById('comments-list')
if (!Array.isArray(list) || !list.length) {
el.innerHTML = '<p style="font-size:0.78rem;color:var(--muted);padding:0.5rem 0;">还没有留言,来第一个吧!</p>'
return
}
el.innerHTML = list.map(c => `
<div class="comment-item">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.3rem;">
<span class="comment-avatar">${c.username[0].toUpperCase()}</span>
<span style="font-size:0.78rem;font-weight:600;color:var(--text);">${c.username}</span>
<span style="font-size:0.68rem;color:var(--muted);">${new Date(c.createdAt).toLocaleDateString('zh-CN')}</span>
</div>
<p style="font-size:0.82rem;color:var(--muted);line-height:1.7;margin:0 0 0 1.6rem;">${c.content.replace(/&/g,'&amp;').replace(/</g,'&lt;')}</p>
</div>
`).join('')
} catch {}
}
document.getElementById('comment-form').addEventListener('submit', async e => {
e.preventDefault()
const input = document.getElementById('comment-input')
const content = input.value.trim()
if (!content) return
try {
const res = await fetch('/api/comments', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${hpGetToken()}` }, body: JSON.stringify({ content }) })
if (res.ok) { input.value = ''; loadComments() }
else if (res.status === 401) { hpUser = null; updateCommentUI() }
} catch {}
})
const _token = hpGetToken()
if (_token) hpUser = hpParseToken(_token)
updateCommentUI()
loadComments()
</script>
</body>
</html>

1654
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -47,17 +47,13 @@
"@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",
@ -72,16 +68,12 @@
"@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/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",

View File

@ -1,111 +0,0 @@
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),
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 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,
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)
}

View File

@ -5,11 +5,6 @@ import { cors } from 'hono/cors'
import Anthropic from '@anthropic-ai/sdk'
import fs from 'node:fs'
import path from 'node:path'
import { initDb } from './db'
import { authRouter } from './routes/auth'
import { notesRouter } from './routes/notes'
import { foldersRouter } from './routes/folders'
import { commentsRouter } from './routes/comments'
const app = new Hono()
app.use('*', cors())
@ -63,11 +58,6 @@ function getActiveModel(): string {
}
// ── Model CRUD endpoints ──────────────────────────────────────────────────────
app.route('/api/auth', authRouter)
app.route('/api/notes', notesRouter)
app.route('/api/folders', foldersRouter)
app.route('/api/comments', commentsRouter)
app.get('/api/models', (c) => {
const models = readModels()
// strip apiKey from response for security
@ -201,8 +191,6 @@ app.post('/api/ai/stream', async (c) => {
)
})
initDb()
serve({ fetch: app.fetch, port: 3001 }, () => {
console.log('AI proxy server running on http://localhost:3001')
})

View File

@ -1,27 +0,0 @@
import { createMiddleware } from 'hono/factory'
import jwt from 'jsonwebtoken'
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)
}
})

View File

@ -1,58 +0,0 @@
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'
export const authRouter = new Hono()
function nanoid() {
return Math.random().toString(36).slice(2, 11) + Date.now().toString(36)
}
authRouter.post('/register', async (c) => {
const { username, password } = await c.req.json<{ username: string; password: string }>()
if (!username || !password) 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)
const existing = db.select().from(users).where(eq(users.username, username)).all()
if (existing.length > 0) return c.json({ error: '用户名已存在' }, 409)
const passwordHash = await bcrypt.hash(password, 10)
const id = nanoid()
db.insert(users).values({ id, username, passwordHash, cloudEnabled: false, createdAt: Date.now() }).run()
const token = jwt.sign({ userId: id, username }, process.env.JWT_SECRET!, { expiresIn: '30d' })
return c.json({ token, user: { id, username, cloudEnabled: false } })
})
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)
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 } })
})
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 })
})

View File

@ -1,37 +0,0 @@
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() })
})

View File

@ -1,44 +0,0 @@
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 })
})

View File

@ -1,102 +0,0 @@
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 })
})

View File

@ -1,137 +0,0 @@
import { useState } from 'react'
import { X } from 'lucide-react'
import { apiLogin, apiRegister, apiActivate, setToken } from '../../lib/auth'
import { useAppStore } from '../../stores/appStore'
type Tab = 'login' | 'register' | 'activate'
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()
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 if (tab === 'register') {
const { token, user } = await apiRegister(username, password)
setToken(token)
setCurrentUser(user)
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)' }}>
{tab === 'login' ? '登录' : tab === 'register' ? '注册' : '激活云存储'}
</h2>
<button onClick={onClose} className="p-1 rounded" style={{ color: 'var(--text-faint)' }}>
<X size={14} />
</button>
</div>
{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)}
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' ? '登录' : tab === 'register' ? '注册' : '激活'}
</button>
</form>
</div>
</div>
)
}

View File

@ -1,85 +0,0 @@
import { useState } from 'react'
import { User, LogOut, Cloud, Key } from 'lucide-react'
import { useAppStore } from '../../stores/appStore'
import { LoginModal } from './LoginModal'
export function UserMenu() {
const { currentUser, logout, syncStatus, syncFromCloud } = useAppStore()
const [showLogin, setShowLogin] = useState(false)
const [showActivate, setShowActivate] = 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)} />}
</>
)
}
return (
<>
<div className="px-3 py-2 flex flex-col gap-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div
className="w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0"
style={{ background: 'var(--accent)', color: '#fff' }}
>
{currentUser.username[0].toUpperCase()}
</div>
<span className="text-xs font-medium truncate" style={{ color: 'var(--text)' }}>
{currentUser.username}
</span>
</div>
<button
onClick={logout}
title="登出"
className="p-1 rounded flex-shrink-0"
style={{ color: 'var(--text-faint)' }}
onMouseEnter={e => (e.currentTarget.style.color = 'var(--text)')}
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-faint)')}
>
<LogOut size={12} />
</button>
</div>
{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={() => setShowActivate(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>
{showActivate && (
<LoginModal initialTab="activate" onClose={() => setShowActivate(false)} />
)}
</>
)
}

View File

@ -18,7 +18,6 @@ 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 {
@ -422,9 +421,6 @@ export function Sidebar() {
onChange={handleImportFile}
/>
</div>
<div className="px-3 py-2 shrink-0" style={{ borderTop: '1px solid var(--border)' }}>
<UserMenu />
</div>
</aside>
<AnimatePresence>

View File

@ -1,62 +0,0 @@
const TOKEN_KEY = 'mikivl_token'
const API = '/api'
export type CurrentUser = { id: string; username: string; cloudEnabled: boolean }
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 apiRegister(username: string, password: string): Promise<{ token: string; user: CurrentUser }> {
const res = await fetch(`${API}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
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 function parseToken(token: string): CurrentUser | null {
try {
const payload = JSON.parse(atob(token.split('.')[1]))
return { id: payload.userId, username: payload.username, cloudEnabled: false }
} catch {
return null
}
}

View File

@ -1,72 +0,0 @@
import type { Note, Folder } from '../db'
import { authHeaders } from './auth'
const API = '/api'
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())
}

View File

@ -1,8 +1,6 @@
import { create } from 'zustand'
import { db, type Note, type Folder } from '../db'
import { generateId, extractTextFromJSON } from '../lib/utils'
import { getToken, clearToken, parseToken, type CurrentUser } from '../lib/auth'
import { pullAll, pushAll, pushNote, pushFolder, pushDeleteNote, pushDeleteFolder, mergeNotes, mergeFolders } from '../lib/sync'
interface AppState {
notes: Note[]
@ -44,12 +42,6 @@ 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
syncFromCloud: () => Promise<void>
}
export const useAppStore = create<AppState>((set, get) => ({
@ -66,8 +58,6 @@ 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([
@ -105,7 +95,6 @@ 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
},
@ -121,8 +110,6 @@ 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) => {
@ -133,7 +120,6 @@ 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) => {
@ -169,15 +155,12 @@ 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) => {
@ -187,7 +170,6 @@ 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 }),
@ -256,28 +238,4 @@ 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' })
},
syncFromCloud: async () => {
const { notes, folders, currentUser } = get()
if (!currentUser?.cloudEnabled) return
set({ syncStatus: 'syncing' })
try {
const remote = await pullAll()
const mergedNotes = mergeNotes(notes, remote.notes)
const mergedFolders = mergeFolders(folders, remote.folders)
await Promise.all(mergedNotes.map((n: any) => db.notes.put(n)))
await Promise.all(mergedFolders.map((f: any) => db.folders.put(f)))
await pushAll({ notes: mergedNotes, folders: mergedFolders })
set({ notes: mergedNotes, folders: mergedFolders, syncStatus: 'idle', _notesVersion: get()._notesVersion + 1, _filteredCache: null })
} catch {
set({ syncStatus: 'error' })
}
},
}))