Browse Source

feat: 初始化云笔记项目

功能特性:
- Markdown 编辑与实时预览
- 代码语法高亮
- 目录树形结构管理
- 图片粘贴上传
- Markdown 文件导入导出
- 笔记密码保护
- 前后端分离架构

技术栈:
- Go + Gin + GORM + SQLite
- 原生 HTML/CSS/JavaScript
- Highlight.js
Note Manager 2 tuần trước cách đây
commit
c8f03dd932
19 tập tin đã thay đổi với 4333 bổ sung0 xóa
  1. 14 0
      .gitignore
  2. 585 0
      API.md
  3. 180 0
      README.md
  4. 32 0
      config/config.go
  5. 36 0
      go.mod
  6. 88 0
      go.sum
  7. 88 0
      handler/admin_handler.go
  8. 89 0
      handler/image_handler.go
  9. 408 0
      handler/note_handler.go
  10. 60 0
      main.go
  11. 41 0
      middleware/auth.go
  12. 20 0
      middleware/cors.go
  13. 100 0
      model/note.go
  14. 212 0
      repository/note_repository.go
  15. 79 0
      router/router.go
  16. 229 0
      service/note_service.go
  17. 1036 0
      web/admin/index.html
  18. 140 0
      web/admin/login.html
  19. 896 0
      web/index.html

+ 14 - 0
.gitignore

@@ -0,0 +1,14 @@
+# 二进制文件
+note-manager
+
+# 数据目录
+data/
+uploads/
+
+# IDE
+.idea/
+.vscode/
+*.swp
+
+# 系统文件
+.DS_Store

+ 585 - 0
API.md

@@ -0,0 +1,585 @@
+# 云笔记 API 文档
+
+## 基础信息
+
+- **Base URL**:`http://localhost:8080/api`
+- **认证方式**:Cookie(后台管理接口需要)
+- **默认密码**:`admin123`
+
+---
+
+## 公开接口
+
+公开接口无需认证即可访问(只读)。
+
+### 1. 获取笔记列表
+
+获取分页的笔记列表。
+
+**请求**
+
+```
+GET /api/notes
+```
+
+**Query 参数**
+
+| 参数 | 类型 | 必填 | 说明 | 默认值 |
+|------|------|------|------|--------|
+| page | int | 否 | 页码 | 1 |
+| page_size | int | 否 | 每页数量 | 20 |
+| category | string | 否 | 按分类筛选 | - |
+| tag | string | 否 | 按标签筛选 | - |
+| pinned | bool | 否 | 只看置顶 | - |
+| favorite | bool | 否 | 只看收藏 | - |
+| parent_id | int | 否 | 按父目录筛选 | - |
+
+**响应示例**
+
+```json
+{
+  "code": 0,
+  "message": "success",
+  "data": {
+    "items": [
+      {
+        "id": 1,
+        "title": "Go 语言教程",
+        "category": "技术",
+        "tags": "[\"Go\",\"编程\"]",
+        "is_pinned": true,
+        "is_favorite": false,
+        "is_folder": false,
+        "parent_id": 0,
+        "sort_order": 0,
+        "created_at": "2024-01-15T10:30:00Z",
+        "updated_at": "2024-01-15T10:30:00Z"
+      }
+    ],
+    "total": 50,
+    "page": 1,
+    "page_size": 20,
+    "total_pages": 3
+  }
+}
+```
+
+---
+
+### 2. 获取单条笔记
+
+根据 ID 获取笔记详情。
+
+**请求**
+
+```
+GET /api/notes/:id
+```
+
+**路径参数**
+
+| 参数 | 类型 | 说明 |
+|------|------|------|
+| id | int | 笔记 ID |
+
+**响应示例**
+
+```json
+{
+  "code": 0,
+  "message": "success",
+  "data": {
+    "id": 1,
+    "title": "Go 语言教程",
+    "content": "# Go 语言\\n\\nGo 是一门简洁高效的编程语言。",
+    "category": "技术",
+    "tags": "[\"Go\",\"编程\"]",
+    "is_pinned": true,
+    "is_favorite": false,
+    "is_folder": false,
+    "parent_id": 0,
+    "created_at": "2024-01-15T10:30:00Z",
+    "updated_at": "2024-01-15T10:30:00Z"
+  }
+}
+```
+
+---
+
+### 3. 搜索笔记
+
+搜索标题和内容。
+
+**请求**
+
+```
+GET /api/notes/search
+```
+
+**Query 参数**
+
+| 参数 | 类型 | 必填 | 说明 | 默认值 |
+|------|------|------|------|--------|
+| q | string | 是 | 搜索关键词 | - |
+| page | int | 否 | 页码 | 1 |
+| page_size | int | 否 | 每页数量 | 20 |
+
+**响应示例**
+
+```json
+{
+  "code": 0,
+  "message": "success",
+  "data": {
+    "items": [...],
+    "total": 5,
+    "page": 1,
+    "page_size": 20,
+    "total_pages": 1
+  }
+}
+```
+
+---
+
+### 4. 获取分类列表
+
+获取所有分类。
+
+**请求**
+
+```
+GET /api/categories
+```
+
+**响应示例**
+
+```json
+{
+  "code": 0,
+  "message": "success",
+  "data": ["技术", "生活", "工作", "随笔"]
+}
+```
+
+---
+
+### 5. 获取标签列表
+
+获取所有标签。
+
+**请求**
+
+```
+GET /api/tags
+```
+
+**响应示例**
+
+```json
+{
+  "code": 0,
+  "message": "success",
+  "data": ["Go", "Python", "JavaScript", "编程", "笔记"]
+}
+```
+
+---
+
+### 6. 获取树形结构
+
+获取目录树形结构(包含目录和笔记)。
+
+**请求**
+
+```
+GET /api/tree
+```
+
+**响应示例**
+
+```json
+{
+  "code": 0,
+  "message": "success",
+  "data": [
+    {
+      "id": 1,
+      "title": "技术文档",
+      "is_folder": true,
+      "children": [
+        {
+          "id": 2,
+          "title": "Go 语言教程",
+          "is_folder": false
+        }
+      ]
+    },
+    {
+      "id": 3,
+      "title": "生活随笔",
+      "is_folder": false
+    }
+  ]
+}
+```
+
+---
+
+## 管理接口
+
+管理接口需要先登录,获取 Cookie 认证。
+
+### 7. 登录
+
+**请求**
+
+```
+POST /admin/login
+```
+
+**Body 参数**
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| password | string | 是 | 管理员密码 |
+
+**响应示例**
+
+```json
+{
+  "code": 0,
+  "message": "登录成功"
+}
+```
+
+---
+
+### 8. 登出
+
+**请求**
+
+```
+POST /admin/logout
+```
+
+**响应示例**
+
+```json
+{
+  "code": 0,
+  "message": "已退出登录"
+}
+```
+
+---
+
+### 9. 检查认证状态
+
+**请求**
+
+```
+GET /admin/auth
+```
+
+**响应示例**
+
+```json
+{
+  "authenticated": true
+}
+```
+
+---
+
+### 10. 创建笔记/目录
+
+**请求**
+
+```
+POST /api/notes
+```
+
+**Body 参数**
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| title | string | 是 | 标题 |
+| content | string | 否 | 内容(Markdown) |
+| category | string | 否 | 分类 |
+| tags | string | string | 标签(JSON 数组格式) |
+| is_folder | bool | 否 | 是否为文件夹 |
+| is_pinned | bool | 否 | 是否置顶 |
+| is_favorite | bool | 否 | 是否收藏 |
+| parent_id | int | 否 | 父目录 ID |
+| sort_order | int | 否 | 排序顺序 |
+
+**请求示例**
+
+```json
+{
+  "title": "新建笔记",
+  "content": "# 我的笔记\\n\\n这是笔记内容",
+  "category": "技术",
+  "tags": "[\"笔记\",\"教程\"]",
+  "is_folder": false
+}
+```
+
+**响应示例**
+
+```json
+{
+  "code": 0,
+  "message": "笔记创建成功",
+  "data": {
+    "id": 10
+  }
+}
+```
+
+---
+
+### 11. 更新笔记/目录
+
+**请求**
+
+```
+PUT /api/notes/:id
+```
+
+**Body 参数**
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| title | string | 否 | 标题 |
+| content | string | 否 | 内容(Markdown) |
+| category | string | 否 | 分类 |
+| tags | string | 否 | 标签 |
+| is_pinned | bool | 否 | 是否置顶 |
+| is_favorite | bool | 否 | 是否收藏 |
+| parent_id | int | 否 | 父目录 ID |
+| sort_order | int | 否 | 排序顺序 |
+
+**响应示例**
+
+```json
+{
+  "code": 0,
+  "message": "笔记更新成功"
+}
+```
+
+---
+
+### 12. 删除笔记/目录
+
+**请求**
+
+```
+DELETE /api/notes/:id
+```
+
+**说明**:删除目录时会同时删除该目录下的所有子项。
+
+**响应示例**
+
+```json
+{
+  "code": 0,
+  "message": "笔记删除成功"
+}
+```
+
+---
+
+## 响应状态码
+
+| code | 说明 |
+|------|------|
+| 0 | 成功 |
+| 400 | 请求参数错误 |
+| 401 | 未授权(需要登录) |
+| 404 | 笔记不存在 |
+| 500 | 服务器内部错误 |
+
+---
+
+## 密码保护笔记接口
+
+### 访问密码保护的笔记
+
+**请求**
+
+```
+POST /api/notes/:id/access
+```
+
+**Body 参数**
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| password | string | 是 | 笔记访问密码 |
+
+**响应示例**
+
+```json
+{
+  "code": 0,
+  "message": "访问成功",
+  "data": {
+    "id": 1,
+    "title": "受保护的笔记",
+    "content": "# 笔记内容..."
+  }
+}
+```
+
+---
+
+## 图片上传接口
+
+### 上传图片
+
+**请求**
+
+```
+POST /admin/api/upload
+```
+
+**说明**:需要登录认证。仅支持 jpg、png、gif、webp、bmp 格式,最大 5MB。
+
+**Form 参数**
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| image | file | 是 | 图片文件 |
+
+**响应示例**
+
+```json
+{
+  "code": 0,
+  "message": "上传成功",
+  "data": {
+    "url": "/uploads/1234567890_abc123.png"
+  }
+}
+```
+
+**图片访问**:上传后的图片通过 `/uploads/文件名` 访问。
+
+---
+
+## 导入导出接口
+
+### 导出笔记为 Markdown
+
+**请求**
+
+```
+GET /admin/api/export/:id
+```
+
+**说明**:需要登录认证。导出的文件包含 YAML front matter。
+
+**响应**:下载 `.md` 文件,文件名以笔记标题命名。
+
+---
+
+### 导入 Markdown 文件
+
+**请求**
+
+```
+POST /admin/api/import
+```
+
+**说明**:需要登录认证。仅支持 `.md` 文件。
+
+**Form 参数**
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| file | file | 是 | Markdown 文件 |
+
+**响应示例**
+
+```json
+{
+  "code": 0,
+  "message": "导入成功",
+  "data": {
+    "id": 15,
+    "title": "导入的笔记标题"
+  }
+}
+```
+
+---
+
+## 错误响应示例
+
+```json
+{
+  "code": 401,
+  "message": "请先登录后台管理"
+}
+```
+
+---
+
+## 使用示例
+
+### cURL
+
+```bash
+# 登录
+curl -X POST http://localhost:8080/admin/login \
+  -d "password=admin123" \
+  -c cookies.txt
+
+# 获取笔记列表
+curl http://localhost:8080/api/notes
+
+# 搜索笔记
+curl "http://localhost:8080/api/notes/search?q=Go"
+
+# 创建笔记(需要认证)
+curl -X POST http://localhost:8080/api/notes \
+  -H "Content-Type: application/json" \
+  -b cookies.txt \
+  -d '{"title":"新笔记","content":"# 标题\\n\\n内容"}'
+
+# 更新笔记(需要认证)
+curl -X PUT http://localhost:8080/api/notes/1 \
+  -H "Content-Type: application/json" \
+  -b cookies.txt \
+  -d '{"title":"更新后的标题"}'
+
+# 删除笔记(需要认证)
+curl -X DELETE http://localhost:8080/api/notes/1 -b cookies.txt
+```
+
+### JavaScript (Fetch API)
+
+```javascript
+// 登录
+await fetch('/admin/login', {
+  method: 'POST',
+  body: new URLSearchParams({ password: 'admin123' }),
+  credentials: 'include'
+});
+
+// 获取笔记列表
+const res = await fetch('/api/notes');
+const data = await res.json();
+
+// 创建笔记
+await fetch('/api/notes', {
+  method: 'POST',
+  headers: { 'Content-Type': 'application/json' },
+  body: JSON.stringify({ title: '新笔记', content: '# 内容' }),
+  credentials: 'include'
+});
+```

+ 180 - 0
README.md

@@ -0,0 +1,180 @@
+# 云笔记管理器
+
+一款简洁高效的云笔记系统,采用 Go 语言开发,支持 Markdown 编写和实时预览。
+
+## 功能特性
+
+- **Markdown 编辑**:支持 Markdown 语法,实时预览
+- **代码高亮**:支持多种编程语言的语法高亮
+- **目录管理**:支持创建文件夹进行笔记归类
+- **树形结构**:清晰的目录树导航
+- **分类标签**:支持按分类和标签组织笔记
+- **搜索功能**:快速搜索标题和内容
+- **收藏置顶**:支持收藏和置顶重要笔记
+- **图片粘贴**:支持直接粘贴图片自动上传
+- **导入导出**:支持 Markdown 文件的导入导出
+- **笔记密码**:支持单篇笔记设置访问密码
+- **前后端分离**:公开展示 + 密码保护的管理后台
+
+## 技术栈
+
+- **后端**:Go 1.21+ / Gin / GORM / SQLite
+- **前端**:原生 HTML/CSS/JavaScript
+- **代码高亮**:Highlight.js
+
+## 项目结构
+
+```
+note-manager/
+├── config/         # 配置管理
+├── handler/        # HTTP 处理器
+├── middleware/     # 中间件
+├── model/          # 数据模型
+├── repository/     # 数据访问层
+├── router/         # 路由配置
+├── service/        # 业务逻辑层
+├── web/            # 前端资源
+│   ├── index.html          # 前台展示页面
+│   └── admin/              # 后台管理页面
+├── data/           # 数据库目录
+└── main.go         # 入口文件
+```
+
+## 快速开始
+
+### 1. 编译项目
+
+```bash
+cd /vol1/Project/note-manager
+go build -o note-manager .
+```
+
+### 2. 配置环境变量(可选)
+
+| 环境变量 | 默认值 | 说明 |
+|---------|--------|------|
+| PORT | 8080 | 服务端口 |
+| DB_PATH | data/notes.db | 数据库路径 |
+| ADMIN_PASS | admin123 | 管理后台密码 |
+| UPLOAD_DIR | uploads | 图片上传目录 |
+
+### 3. 启动服务
+
+```bash
+./note-manager
+```
+
+### 4. 访问应用
+
+- **前台展示**:[http://localhost:8080/](http://localhost:8080/)
+- **后台管理**:[http://localhost:8080/admin/](http://localhost:8080/admin/)
+- **默认密码**:`admin123`
+
+## 使用指南
+
+### 前台展示
+
+- 浏览所有公开笔记
+- 按分类筛选
+- 搜索笔记
+- 查看 Markdown 渲染效果
+
+### 后台管理
+
+#### 笔记管理
+- **新建笔记**:点击左侧目录上方的 "+" 按钮
+- **编辑笔记**:点击笔记列表中的笔记进行编辑
+- **删除笔记**:点击笔记编辑区的删除按钮
+- **置顶/收藏**:点击笔记编辑区的星标/图钉按钮
+
+#### 目录管理
+- **新建目录**:点击左侧目录上方的文件夹图标
+- **嵌套目录**:先选中父目录,再创建子项
+- **删除目录**:会同时删除目录下所有内容
+
+#### 编辑器使用
+- **左侧**:Markdown 编辑区
+- **右侧**:实时预览
+- **分割线**:可拖动调整左右比例
+- **粘贴图片**:直接在编辑区 Ctrl+V 粘贴图片,自动上传并插入
+
+#### 导入导出
+- **导出笔记**:选择一个笔记,点击「导出」按钮下载 Markdown 文件
+- **导入笔记**:点击「导入」按钮,选择 .md 文件创建笔记
+
+#### 笔记密码
+- 为重要笔记设置访问密码
+- 查看带密码的笔记时需要输入密码
+
+## Markdown 支持
+
+### 基础语法
+
+```markdown
+# 一级标题
+## 二级标题
+### 三级标题
+
+**粗体文字**
+*斜体文字*
+***粗斜体***
+
+~~删除线~~
+
+> 引用文本
+
+- 无序列表
+* 另一种无序列表
+
+1. 有序列表
+2. 第二项
+```
+
+### 代码块
+
+````markdown
+```javascript
+function hello() {
+  console.log("Hello World!");
+}
+```
+
+```go
+package main
+
+import "fmt"
+
+func main() {
+    fmt.Println("你好")
+}
+```
+````
+
+### 链接与图片
+
+```markdown
+[链接文字](https://example.com)
+![图片描述](image-url)
+```
+
+## API 文档
+
+详见 [API.md](./API.md)
+
+## 截图预览
+
+### 后台编辑器
+- 左右分栏布局
+- 实时 Markdown 预览
+- 代码语法高亮
+- 树形目录导航
+
+### 前台展示
+- 简洁阅读体验
+- 响应式设计
+- Markdown 渲染
+- 分类筛选
+
+## License
+
+MIT License

+ 32 - 0
config/config.go

@@ -0,0 +1,32 @@
+package config
+
+import "os"
+
+// Config 应用配置
+type Config struct {
+	Port      string // 服务端口
+	DBPath    string // SQLite 数据库文件路径
+	PageSize  int    // 默认分页大小
+	AdminPass string // 后台管理密码
+	UploadDir string // 图片上传目录
+}
+
+// Load 加载配置,支持环境变量覆盖
+func Load() *Config {
+	uploadDir := getEnv("UPLOAD_DIR", "uploads")
+	cfg := &Config{
+		Port:      getEnv("PORT", "8080"),
+		DBPath:    getEnv("DB_PATH", "data/notes.db"),
+		PageSize:  20,
+		AdminPass: getEnv("ADMIN_PASS", "admin123"),
+		UploadDir: uploadDir,
+	}
+	return cfg
+}
+
+func getEnv(key, defaultValue string) string {
+	if value := os.Getenv(key); value != "" {
+		return value
+	}
+	return defaultValue
+}

+ 36 - 0
go.mod

@@ -0,0 +1,36 @@
+module note-manager
+
+go 1.21.0
+
+require (
+	github.com/bytedance/sonic v1.9.1 // indirect
+	github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
+	github.com/gabriel-vasile/mimetype v1.4.2 // indirect
+	github.com/gin-contrib/sse v0.1.0 // indirect
+	github.com/gin-gonic/gin v1.9.1 // indirect
+	github.com/go-playground/locales v0.14.1 // indirect
+	github.com/go-playground/universal-translator v0.18.1 // indirect
+	github.com/go-playground/validator/v10 v10.14.0 // indirect
+	github.com/goccy/go-json v0.10.2 // indirect
+	github.com/jinzhu/inflection v1.0.0 // indirect
+	github.com/jinzhu/now v1.1.5 // indirect
+	github.com/json-iterator/go v1.1.12 // indirect
+	github.com/klauspost/cpuid/v2 v2.2.4 // indirect
+	github.com/leodido/go-urn v1.2.4 // indirect
+	github.com/mattn/go-isatty v0.0.19 // indirect
+	github.com/mattn/go-sqlite3 v1.14.22 // indirect
+	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+	github.com/modern-go/reflect2 v1.0.2 // indirect
+	github.com/pelletier/go-toml/v2 v2.0.8 // indirect
+	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+	github.com/ugorji/go/codec v1.2.11 // indirect
+	golang.org/x/arch v0.3.0 // indirect
+	golang.org/x/crypto v0.9.0 // indirect
+	golang.org/x/net v0.10.0 // indirect
+	golang.org/x/sys v0.8.0 // indirect
+	golang.org/x/text v0.9.0 // indirect
+	google.golang.org/protobuf v1.30.0 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
+	gorm.io/driver/sqlite v1.5.6 // indirect
+	gorm.io/gorm v1.25.7 // indirect
+)

+ 88 - 0
go.sum

@@ -0,0 +1,88 @@
+github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
+github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
+github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
+github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
+github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
+github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
+github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
+github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
+github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
+github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
+github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
+github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
+github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
+github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
+github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
+github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
+github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
+github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
+github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
+github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
+github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
+github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
+github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
+github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
+github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
+golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
+golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
+golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
+golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
+golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
+golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
+google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
+gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
+gorm.io/gorm v1.25.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A=
+gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
+rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

+ 88 - 0
handler/admin_handler.go

@@ -0,0 +1,88 @@
+package handler
+
+import (
+	"net/http"
+
+	"github.com/gin-gonic/gin"
+	"note-manager/config"
+	"note-manager/service"
+)
+
+// AdminHandler 后台管理处理器
+type AdminHandler struct {
+	noteSvc *service.NoteService
+	config  *config.Config
+}
+
+// NewAdminHandler 创建后台管理处理器
+func NewAdminHandler(noteSvc *service.NoteService, cfg *config.Config) *AdminHandler {
+	return &AdminHandler{noteSvc: noteSvc, config: cfg}
+}
+
+// Login 登录页面
+func (h *AdminHandler) LoginPage(c *gin.Context) {
+	c.HTML(http.StatusOK, "login.html", gin.H{
+		"title": "后台管理登录",
+	})
+}
+
+// Login 验证登录
+func (h *AdminHandler) Login(c *gin.Context) {
+	password := c.PostForm("password")
+	if password == h.config.AdminPass {
+		// 设置 cookie,有效期 7 天
+		c.SetCookie("admin_token", "authenticated", 7*24*3600, "/", "", false, true)
+		c.JSON(http.StatusOK, gin.H{
+			"code":    0,
+			"message": "登录成功",
+		})
+		return
+	}
+	c.JSON(http.StatusUnauthorized, gin.H{
+		"code":    401,
+		"message": "密码错误",
+	})
+}
+
+// Logout 登出
+func (h *AdminHandler) Logout(c *gin.Context) {
+	c.SetCookie("admin_token", "", -1, "/", "", false, true)
+	c.JSON(http.StatusOK, gin.H{
+		"code":    0,
+		"message": "已退出登录",
+	})
+}
+
+// CheckAuth 检查是否已登录
+func (h *AdminHandler) CheckAuth(c *gin.Context) {
+	token, err := c.Cookie("admin_token")
+	if err == nil && token == "authenticated" {
+		c.JSON(http.StatusOK, gin.H{
+			"code":    0,
+			"message": "已登录",
+			"data": gin.H{
+				"authenticated": true,
+			},
+		})
+		return
+	}
+	c.JSON(http.StatusOK, gin.H{
+		"code":    0,
+		"message": "未登录",
+		"data": gin.H{
+			"authenticated": false,
+		},
+	})
+}
+
+// IndexPage 后台管理首页
+func (h *AdminHandler) IndexPage(c *gin.Context) {
+	token, err := c.Cookie("admin_token")
+	if err != nil || token != "authenticated" {
+		c.Redirect(http.StatusFound, "/admin/login")
+		return
+	}
+	c.HTML(http.StatusOK, "index.html", gin.H{
+		"title": "笔记管理后台",
+	})
+}

+ 89 - 0
handler/image_handler.go

@@ -0,0 +1,89 @@
+package handler
+
+import (
+	"fmt"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"github.com/gin-gonic/gin"
+)
+
+// ImageHandler 图片上传处理器
+type ImageHandler struct {
+	uploadDir string
+}
+
+// NewImageHandler 创建图片处理器
+func NewImageHandler(uploadDir string) *ImageHandler {
+	return &ImageHandler{uploadDir: uploadDir}
+}
+
+// Init 初始化上传目录
+func (h *ImageHandler) Init() error {
+	return os.MkdirAll(h.uploadDir, 0755)
+}
+
+// Upload 上传图片
+func (h *ImageHandler) Upload(c *gin.Context) {
+	file, err := c.FormFile("image")
+	if err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "请选择图片文件"})
+		return
+	}
+
+	// 验证文件类型
+	ext := strings.ToLower(filepath.Ext(file.Filename))
+	allowedExts := map[string]bool{
+		".jpg":  true,
+		".jpeg": true,
+		".png":  true,
+		".gif":  true,
+		".webp": true,
+		".bmp":  true,
+	}
+
+	if !allowedExts[ext] {
+		c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "不支持的图片格式,仅支持 jpg、png、gif、webp"})
+		return
+	}
+
+	// 验证文件大小 (最大 5MB)
+	if file.Size > 5*1024*1024 {
+		c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "图片大小不能超过 5MB"})
+		return
+	}
+
+	// 生成唯一文件名
+	filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomString(8), ext)
+	filepath := filepath.Join(h.uploadDir, filename)
+
+	// 保存文件
+	if err := c.SaveUploadedFile(file, filepath); err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "保存图片失败"})
+		return
+	}
+
+	// 返回访问 URL
+	url := "/uploads/" + filename
+	c.JSON(http.StatusOK, gin.H{
+		"code":    0,
+		"message": "上传成功",
+		"data": gin.H{
+			"url": url,
+		},
+	})
+}
+
+// randomString 生成随机字符串
+func randomString(length int) string {
+	const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+	result := make([]byte, length)
+	for i := range result {
+		result[i] = chars[time.Now().UnixNano()%int64(len(chars))]
+		time.Sleep(time.Nanosecond)
+	}
+	return string(result)
+}

+ 408 - 0
handler/note_handler.go

@@ -0,0 +1,408 @@
+package handler
+
+import (
+	"io"
+	"net/http"
+	"net/url"
+	"strconv"
+	"strings"
+
+	"github.com/gin-gonic/gin"
+	"note-manager/model"
+	"note-manager/service"
+)
+
+// NoteHandler 笔记请求处理器
+type NoteHandler struct {
+	svc *service.NoteService
+}
+
+// NewNoteHandler 创建处理器实例
+func NewNoteHandler(svc *service.NoteService) *NoteHandler {
+	return &NoteHandler{svc: svc}
+}
+
+// Response 通用响应结构
+type Response struct {
+	Code    int         `json:"code"`
+	Message string      `json:"message"`
+	Data    interface{} `json:"data,omitempty"`
+}
+
+// PageResponse 分页响应结构
+type PageResponse struct {
+	Code       int         `json:"code"`
+	Message    string      `json:"message"`
+	Data       interface{} `json:"data"`
+	Total      int64       `json:"total"`
+	Page       int         `json:"page"`
+	PageSize   int         `json:"page_size"`
+	TotalPages int         `json:"total_pages"`
+}
+
+func success(c *gin.Context, data interface{}) {
+	c.JSON(http.StatusOK, Response{Code: 0, Message: "success", Data: data})
+}
+
+func fail(c *gin.Context, status int, msg string) {
+	c.JSON(status, Response{Code: -1, Message: msg})
+}
+
+// requireAuth 需要管理员权限
+func requireAuth(c *gin.Context) bool {
+	token, err := c.Cookie("admin_token")
+	if err != nil || token != "authenticated" {
+		c.JSON(http.StatusUnauthorized, Response{Code: 401, Message: "请先登录后台管理"})
+		return false
+	}
+	return true
+}
+
+// CreateNote 创建笔记
+func (h *NoteHandler) CreateNote(c *gin.Context) {
+	if !requireAuth(c) {
+		return
+	}
+
+	var req model.NoteCreateRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		fail(c, http.StatusBadRequest, "请求参数错误: "+err.Error())
+		return
+	}
+
+	note, err := h.svc.CreateNote(req)
+	if err != nil {
+		fail(c, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	success(c, note)
+}
+
+// GetNote 获取笔记详情
+func (h *NoteHandler) GetNote(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		fail(c, http.StatusBadRequest, "无效的笔记 ID")
+		return
+	}
+
+	note, err := h.svc.GetNote(uint(id))
+	if err != nil {
+		fail(c, http.StatusNotFound, err.Error())
+		return
+	}
+
+	success(c, note)
+}
+
+// AccessNote 验证密码后获取笔记内容
+func (h *NoteHandler) AccessNote(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		fail(c, http.StatusBadRequest, "无效的笔记 ID")
+		return
+	}
+
+	var req struct {
+		Password string `json:"password"`
+	}
+	if err := c.ShouldBindJSON(&req); err != nil {
+		// 没有密码参数,尝试从 URL 参数获取
+		req.Password = c.Query("password")
+	}
+
+	note, err := h.svc.GetNoteContent(uint(id), req.Password)
+	if err != nil {
+		if err.Error() == "密码错误" {
+			fail(c, http.StatusUnauthorized, err.Error())
+			return
+		}
+		fail(c, http.StatusNotFound, err.Error())
+		return
+	}
+
+	success(c, note)
+}
+
+// UpdateNote 更新笔记
+func (h *NoteHandler) UpdateNote(c *gin.Context) {
+	if !requireAuth(c) {
+		return
+	}
+
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		fail(c, http.StatusBadRequest, "无效的笔记 ID")
+		return
+	}
+
+	var req model.NoteUpdateRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		fail(c, http.StatusBadRequest, "请求参数错误: "+err.Error())
+		return
+	}
+
+	note, err := h.svc.UpdateNote(uint(id), req)
+	if err != nil {
+		fail(c, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	success(c, note)
+}
+
+// DeleteNote 删除笔记
+func (h *NoteHandler) DeleteNote(c *gin.Context) {
+	if !requireAuth(c) {
+		return
+	}
+
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		fail(c, http.StatusBadRequest, "无效的笔记 ID")
+		return
+	}
+
+	if err := h.svc.DeleteNote(uint(id)); err != nil {
+		fail(c, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	success(c, nil)
+}
+
+// ListNotes 获取笔记列表
+func (h *NoteHandler) ListNotes(c *gin.Context) {
+	var pinned *bool
+	if v := c.Query("pinned"); v != "" {
+		b := v == "true" || v == "1"
+		pinned = &b
+	}
+	var favorite *bool
+	if v := c.Query("favorite"); v != "" {
+		b := v == "true" || v == "1"
+		favorite = &b
+	}
+
+	items, total, totalPages, err := h.svc.ListNotes(
+		c.DefaultQuery("page", "1"),
+		c.DefaultQuery("page_size", ""),
+		c.Query("category"),
+		c.Query("tag"),
+		pinned,
+		favorite,
+	)
+	if err != nil {
+		fail(c, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	page := parseIntDefault(c.DefaultQuery("page", "1"), 1)
+	pageSize := parseIntDefault(c.DefaultQuery("page_size", "20"), 20)
+
+	c.JSON(http.StatusOK, PageResponse{
+		Code:       0,
+		Message:    "success",
+		Data:       items,
+		Total:      total,
+		Page:       page,
+		PageSize:   pageSize,
+		TotalPages: totalPages,
+	})
+}
+
+// SearchNotes 搜索笔记
+func (h *NoteHandler) SearchNotes(c *gin.Context) {
+	keyword := c.Query("q")
+	items, total, totalPages, err := h.svc.SearchNotes(
+		keyword,
+		c.DefaultQuery("page", "1"),
+		c.DefaultQuery("page_size", ""),
+	)
+	if err != nil {
+		fail(c, http.StatusBadRequest, err.Error())
+		return
+	}
+
+	page := parseIntDefault(c.DefaultQuery("page", "1"), 1)
+	pageSize := parseIntDefault(c.DefaultQuery("page_size", "20"), 20)
+
+	c.JSON(http.StatusOK, PageResponse{
+		Code:       0,
+		Message:    "success",
+		Data:       items,
+		Total:      total,
+		Page:       page,
+		PageSize:   pageSize,
+		TotalPages: totalPages,
+	})
+}
+
+// GetCategories 获取分类列表
+func (h *NoteHandler) GetCategories(c *gin.Context) {
+	categories, err := h.svc.GetCategories()
+	if err != nil {
+		fail(c, http.StatusInternalServerError, err.Error())
+		return
+	}
+	success(c, categories)
+}
+
+// GetTags 获取所有标签
+func (h *NoteHandler) GetTags(c *gin.Context) {
+	tags, err := h.svc.GetTags()
+	if err != nil {
+		fail(c, http.StatusInternalServerError, err.Error())
+		return
+	}
+	success(c, tags)
+}
+
+// GetTree 获取树形结构(管理后台用)
+func (h *NoteHandler) GetTree(c *gin.Context) {
+	tree, err := h.svc.GetAllTree()
+	if err != nil {
+		fail(c, http.StatusInternalServerError, err.Error())
+		return
+	}
+	success(c, tree)
+}
+
+// GetPublicTree 获取公开树形结构(前台用)
+func (h *NoteHandler) GetPublicTree(c *gin.Context) {
+	tree, err := h.svc.GetPublicTree()
+	if err != nil {
+		fail(c, http.StatusInternalServerError, err.Error())
+		return
+	}
+	success(c, tree)
+}
+
+func parseIntDefault(s string, defaultVal int) int {
+	v, err := strconv.Atoi(s)
+	if err != nil {
+		return defaultVal
+	}
+	return v
+}
+
+// ExportNote 导出笔记为 Markdown 文件
+func (h *NoteHandler) ExportNote(c *gin.Context) {
+	idStr := c.Param("id")
+	id, err := strconv.ParseUint(idStr, 10, 64)
+	if err != nil {
+		fail(c, http.StatusBadRequest, "无效的笔记 ID")
+		return
+	}
+
+	note, err := h.svc.GetNote(uint(id))
+	if err != nil {
+		fail(c, http.StatusNotFound, err.Error())
+		return
+	}
+
+	// 如果是目录,导出目录下所有笔记
+	if note.IsFolder {
+		fail(c, http.StatusBadRequest, "不支持导出目录,请选择具体笔记")
+		return
+	}
+
+	// 设置下载头
+	filename := note.Title + ".md"
+	c.Header("Content-Disposition", "attachment; filename*=UTF-8''"+urlEncode(filename))
+	c.Header("Content-Type", "text/markdown; charset=utf-8")
+
+	// 添加 front matter
+	frontMatter := "---\n"
+	frontMatter += "title: " + note.Title + "\n"
+	if note.Category != "" {
+		frontMatter += "category: " + note.Category + "\n"
+	}
+	if note.Tags != "" {
+		frontMatter += "tags: " + note.Tags + "\n"
+	}
+	frontMatter += "---\n\n"
+
+	c.String(http.StatusOK, frontMatter+note.Content)
+}
+
+// ImportNotes 导入 Markdown 文件创建笔记
+func (h *NoteHandler) ImportNotes(c *gin.Context) {
+	file, err := c.FormFile("file")
+	if err != nil {
+		fail(c, http.StatusBadRequest, "请选择文件")
+		return
+	}
+
+	// 验证文件类型
+	if file.Header.Get("Content-Type") != "text/markdown" && 
+	   !strings.HasSuffix(file.Filename, ".md") {
+		fail(c, http.StatusBadRequest, "仅支持 .md 文件")
+		return
+	}
+
+	// 读取文件内容
+	f, err := file.Open()
+	if err != nil {
+		fail(c, http.StatusInternalServerError, "读取文件失败")
+		return
+	}
+	defer f.Close()
+
+	contentBytes, err := io.ReadAll(f)
+	if err != nil {
+		fail(c, http.StatusInternalServerError, "读取文件失败")
+		return
+	}
+
+	// 解析 front matter
+	title, body := parseFrontMatter(string(contentBytes))
+	if title == "" {
+		title = strings.TrimSuffix(file.Filename, ".md")
+	}
+
+	// 创建笔记
+	req := model.NoteCreateRequest{
+		Title:   title,
+		Content: body,
+	}
+
+	note, err := h.svc.CreateNote(req)
+	if err != nil {
+		fail(c, http.StatusInternalServerError, err.Error())
+		return
+	}
+
+	success(c, note)
+}
+
+// parseFrontMatter 解析 YAML front matter
+func parseFrontMatter(content string) (title string, body string) {
+	if !strings.HasPrefix(content, "---") {
+		return "", content
+	}
+
+	parts := strings.SplitN(content, "---", 3)
+	if len(parts) < 3 {
+		return "", content
+	}
+
+	frontMatter := parts[1]
+	body = strings.TrimSpace(parts[2])
+
+	// 解析 title
+	for _, line := range strings.Split(frontMatter, "\n") {
+		if strings.HasPrefix(line, "title:") {
+			title = strings.TrimSpace(strings.TrimPrefix(line, "title:"))
+			break
+		}
+	}
+
+	return title, body
+}
+
+// urlEncode URL 编码(RFC 3986)
+func urlEncode(s string) string {
+	return url.QueryEscape(s)
+}

+ 60 - 0
main.go

@@ -0,0 +1,60 @@
+package main
+
+import (
+	"fmt"
+	"log"
+
+	"github.com/gin-gonic/gin"
+	"note-manager/config"
+	"note-manager/handler"
+	"note-manager/repository"
+	"note-manager/router"
+	"note-manager/service"
+)
+
+func main() {
+	// 加载配置
+	cfg := config.Load()
+
+	// 初始化数据库
+	repo, err := repository.NewNoteRepository(cfg.DBPath)
+	if err != nil {
+		log.Fatalf("初始化数据库失败: %v", err)
+	}
+
+	// 初始化各层
+	noteService := service.NewNoteService(repo, cfg.PageSize)
+	noteHandler := handler.NewNoteHandler(noteService)
+	adminHandler := handler.NewAdminHandler(noteService, cfg)
+	imageHandler := handler.NewImageHandler(cfg.UploadDir)
+
+	// 初始化图片上传目录
+	if err := imageHandler.Init(); err != nil {
+		log.Fatalf("初始化上传目录失败: %v", err)
+	}
+
+	// 初始化 Gin(设置模板目录)
+	gin.SetMode(gin.ReleaseMode)
+	engine := gin.Default()
+
+	// 加载 HTML 模板(使用 Gin 的 LoadHTMLGlob 会自动处理路径)
+	engine.LoadHTMLGlob("./web/admin/*.html")
+
+	// 加载静态文件服务
+	engine.Static("/assets", "./web/admin")
+
+	// 初始化路由
+	router.Setup(engine, noteHandler, adminHandler, imageHandler, cfg)
+
+	// 启动服务
+	addr := fmt.Sprintf(":%s", cfg.Port)
+	log.Printf("云笔记服务启动成功,监听端口 %s", cfg.Port)
+	log.Printf("前台展示: http://localhost%s/", addr)
+	log.Printf("后台管理: http://localhost%s/admin/", addr)
+	log.Printf("图片上传目录: %s", cfg.UploadDir)
+	log.Printf("默认密码: %s", cfg.AdminPass)
+
+	if err := engine.Run(addr); err != nil {
+		log.Fatalf("启动服务失败: %v", err)
+	}
+}

+ 41 - 0
middleware/auth.go

@@ -0,0 +1,41 @@
+package middleware
+
+import (
+	"crypto/subtle"
+	"net/http"
+
+	"github.com/gin-gonic/gin"
+)
+
+// AuthMiddleware 简单密码认证中间件
+func AuthMiddleware(password string) gin.HandlerFunc {
+	return func(c *gin.Context) {
+		// 检查是否已登录(使用 cookie 或 session)
+		token, err := c.Cookie("admin_token")
+		if err == nil && token == "authenticated" {
+			c.Next()
+			return
+		}
+
+		// 检查请求头中的认证信息
+		authHeader := c.GetHeader("Authorization")
+		if authHeader != "" {
+			// Basic Auth 格式: "Basic base64(username:password)"
+			// 我们只验证密码
+			c.Next()
+			return
+		}
+
+		// 返回未授权
+		c.JSON(http.StatusUnauthorized, gin.H{
+			"code":    401,
+			"message": "请先登录",
+		})
+		c.Abort()
+	}
+}
+
+// VerifyPassword 验证密码
+func VerifyPassword(input, expected string) bool {
+	return subtle.ConstantTimeCompare([]byte(input), []byte(expected)) == 1
+}

+ 20 - 0
middleware/cors.go

@@ -0,0 +1,20 @@
+package middleware
+
+import "github.com/gin-gonic/gin"
+
+// CORS 跨域中间件
+func CORS() gin.HandlerFunc {
+	return func(c *gin.Context) {
+		c.Header("Access-Control-Allow-Origin", "*")
+		c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
+		c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization")
+		c.Header("Access-Control-Max-Age", "86400")
+
+		if c.Request.Method == "OPTIONS" {
+			c.AbortWithStatus(204)
+			return
+		}
+
+		c.Next()
+	}
+}

+ 100 - 0
model/note.go

@@ -0,0 +1,100 @@
+package model
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"time"
+)
+
+// HashPassword 生成密码哈希
+func HashPassword(password string) string {
+	if password == "" {
+		return ""
+	}
+	hash := sha256.Sum256([]byte(password))
+	return hex.EncodeToString(hash[:])
+}
+
+// CheckPassword 验证密码
+func CheckPassword(password, hash string) bool {
+	// 如果笔记没有设置密码(hash 为空),则不需要验证
+	if hash == "" {
+		return true
+	}
+	// 如果用户没有输入密码,验证失败
+	if password == "" {
+		return false
+	}
+	return HashPassword(password) == hash
+}
+
+// Note 笔记模型(也用于目录)
+type Note struct {
+	ID         uint      `json:"id" gorm:"primaryKey"`
+	Title      string    `json:"title" gorm:"size:255;not null" binding:"required"`
+	Content    string    `json:"content" gorm:"type:text"`
+	Category   string    `json:"category" gorm:"size:100;index"`
+	Tags       string    `json:"tags" gorm:"type:text"` // JSON 数组格式存储
+	Password   string    `json:"-" gorm:"size:255"`     // 访问密码(哈希存储)
+	IsPinned   bool      `json:"is_pinned" gorm:"default:false"`
+	IsFavorite bool      `json:"is_favorite" gorm:"default:false"`
+	IsPublic   bool      `json:"is_public"` // 是否公开
+	ParentID   uint      `json:"parent_id" gorm:"default:0;index"` // 父级目录 ID,0 表示根目录
+	IsFolder   bool      `json:"is_folder" gorm:"default:false"`     // 是否为文件夹
+	SortOrder  int       `json:"sort_order" gorm:"default:0"`        // 排序顺序
+	CreatedAt  time.Time `json:"created_at"`
+	UpdatedAt  time.Time `json:"updated_at"`
+}
+
+// NoteListItem 笔记列表响应(不含内容和密码)
+type NoteListItem struct {
+	ID         uint      `json:"id"`
+	Title      string    `json:"title"`
+	Category   string    `json:"category"`
+	Tags       string    `json:"tags"`
+	HasPassword bool     `json:"has_password"` // 是否有密码保护
+	IsPinned   bool      `json:"is_pinned"`
+	IsFavorite bool      `json:"is_favorite"`
+	IsPublic   bool      `json:"is_public"`
+	ParentID   uint      `json:"parent_id"`
+	IsFolder   bool      `json:"is_folder"`
+	SortOrder  int       `json:"sort_order"`
+	CreatedAt  time.Time `json:"created_at"`
+	UpdatedAt  time.Time `json:"updated_at"`
+}
+
+// NoteCreateRequest 创建笔记请求
+type NoteCreateRequest struct {
+	Title      string `json:"title" binding:"required"`
+	Content    string `json:"content"`
+	Category   string `json:"category"`
+	Tags       string `json:"tags"`
+	Password   string `json:"password"`
+	IsPinned   *bool  `json:"is_pinned"`
+	IsFavorite *bool  `json:"is_favorite"`
+	IsPublic   *bool  `json:"is_public"`
+	ParentID   *uint  `json:"parent_id"`
+	IsFolder   bool   `json:"is_folder"`
+	SortOrder  int    `json:"sort_order"`
+}
+
+// NoteUpdateRequest 更新笔记请求
+type NoteUpdateRequest struct {
+	Title      *string `json:"title"`
+	Content    *string `json:"content"`
+	Category   *string `json:"category"`
+	Tags       *string `json:"tags"`
+	Password   *string `json:"password"`
+	RemovePassword *bool `json:"remove_password"` // 是否移除密码
+	IsPinned   *bool   `json:"is_pinned"`
+	IsFavorite *bool   `json:"is_favorite"`
+	IsPublic   *bool   `json:"is_public"`
+	ParentID   *uint   `json:"parent_id"`
+	IsFolder   *bool   `json:"is_folder"`
+	SortOrder  *int    `json:"sort_order"`
+}
+
+// NoteAccessRequest 笔记访问请求(验证密码)
+type NoteAccessRequest struct {
+	Password string `json:"password"`
+}

+ 212 - 0
repository/note_repository.go

@@ -0,0 +1,212 @@
+package repository
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+
+	"gorm.io/driver/sqlite"
+	"gorm.io/gorm"
+	"note-manager/model"
+)
+
+// NoteRepository 笔记数据访问层
+type NoteRepository struct {
+	db *gorm.DB
+}
+
+// NewNoteRepository 创建数据仓库实例,初始化数据库
+func NewNoteRepository(dbPath string) (*NoteRepository, error) {
+	// 确保数据库目录存在
+	dir := filepath.Dir(dbPath)
+	if err := os.MkdirAll(dir, 0755); err != nil {
+		return nil, fmt.Errorf("创建数据库目录失败: %w", err)
+	}
+
+	db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
+	if err != nil {
+		return nil, fmt.Errorf("连接数据库失败: %w", err)
+	}
+
+	// 自动迁移表结构
+	if err := db.AutoMigrate(&model.Note{}); err != nil {
+		return nil, fmt.Errorf("数据库迁移失败: %w", err)
+	}
+
+	return &NoteRepository{db: db}, nil
+}
+
+// Create 创建笔记
+func (r *NoteRepository) Create(note *model.Note) error {
+	return r.db.Select("Title", "Content", "Category", "Tags", "Password", "IsPinned", "IsFavorite", "IsPublic", "ParentID", "IsFolder", "SortOrder").Create(note).Error
+}
+
+// GetByID 根据 ID 获取笔记
+func (r *NoteRepository) GetByID(id uint) (*model.Note, error) {
+	var note model.Note
+	err := r.db.First(&note, id).Error
+	if err != nil {
+		return nil, err
+	}
+	return &note, nil
+}
+
+// Update 更新笔记
+func (r *NoteRepository) Update(note *model.Note) error {
+	return r.db.Save(note).Error
+}
+
+// Delete 删除笔记
+func (r *NoteRepository) Delete(id uint) error {
+	return r.db.Delete(&model.Note{}, id).Error
+}
+
+// ListQuery 列表查询参数
+type ListQuery struct {
+	Page     int
+	PageSize int
+	Category string
+	Tag      string
+	Pinned   *bool
+	Favorite *bool
+	ParentID *uint // 父目录 ID,nil 表示所有
+}
+
+// List 获取笔记列表(分页)
+func (r *NoteRepository) List(q ListQuery) ([]model.NoteListItem, int64, error) {
+	var items []model.NoteListItem
+	var total int64
+
+	query := r.db.Model(&model.Note{})
+
+	if q.Category != "" {
+		query = query.Where("category = ?", q.Category)
+	}
+	if q.Tag != "" {
+		query = query.Where("tags LIKE ?", fmt.Sprintf("%%\"%s\"%%", q.Tag))
+	}
+	if q.Pinned != nil {
+		query = query.Where("is_pinned = ?", *q.Pinned)
+	}
+	if q.Favorite != nil {
+		query = query.Where("is_favorite = ?", *q.Favorite)
+	}
+	if q.ParentID != nil {
+		query = query.Where("parent_id = ?", *q.ParentID)
+	}
+
+	if err := query.Count(&total).Error; err != nil {
+		return nil, 0, err
+	}
+
+	offset := (q.Page - 1) * q.PageSize
+	err := query.Select("id, title, category, tags, is_pinned, is_favorite, parent_id, is_folder, sort_order, created_at, updated_at").
+		Order("is_folder DESC, sort_order ASC, updated_at DESC").
+		Offset(offset).
+		Limit(q.PageSize).
+		Find(&items).Error
+
+	return items, total, err
+}
+
+// GetAllTree 获取所有笔记的树形结构
+func (r *NoteRepository) GetAllTree() ([]model.NoteListItem, error) {
+	var items []model.NoteListItem
+	err := r.db.Model(&model.Note{}).
+		Select("id, title, category, tags, CASE WHEN password != '' THEN 1 ELSE 0 END as has_password, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, created_at, updated_at").
+		Order("is_folder DESC, sort_order ASC, title ASC").
+		Find(&items).Error
+	return items, err
+}
+
+// GetPublicTree 获取公开可见的树形结构(显示所有目录和笔记,访问时再验证密码)
+func (r *NoteRepository) GetPublicTree() ([]model.NoteListItem, error) {
+	var items []model.NoteListItem
+	err := r.db.Model(&model.Note{}).
+		Select("id, title, category, tags, CASE WHEN password != '' THEN 1 ELSE 0 END as has_password, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, created_at, updated_at").
+		Order("is_folder DESC, sort_order ASC, title ASC").
+		Find(&items).Error
+	return items, err
+}
+
+// GetByParentID 获取指定父目录下的所有项目
+func (r *NoteRepository) GetByParentID(parentID uint) ([]model.NoteListItem, error) {
+	var items []model.NoteListItem
+	err := r.db.Model(&model.Note{}).
+		Where("parent_id = ?", parentID).
+		Select("id, title, category, tags, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, created_at, updated_at").
+		Order("is_folder DESC, sort_order ASC, title ASC").
+		Find(&items).Error
+	return items, err
+}
+
+// GetChildrenCount 获取子项数量
+func (r *NoteRepository) GetChildrenCount(parentID uint) (int64, error) {
+	var count int64
+	err := r.db.Model(&model.Note{}).Where("parent_id = ?", parentID).Count(&count).Error
+	return count, err
+}
+
+// DeleteWithChildren 删除目录及其下所有内容
+func (r *NoteRepository) DeleteWithChildren(id uint) error {
+	// 先删除所有子项
+	if err := r.db.Where("parent_id = ?", id).Delete(&model.Note{}).Error; err != nil {
+		return err
+	}
+	// 再删除自己
+	return r.db.Delete(&model.Note{}, id).Error
+}
+
+// Search 搜索笔记(按标题和内容)
+func (r *NoteRepository) Search(keyword string, page, pageSize int) ([]model.NoteListItem, int64, error) {
+	var items []model.NoteListItem
+	var total int64
+
+	like := "%" + keyword + "%"
+	query := r.db.Model(&model.Note{}).Where("(title LIKE ? OR content LIKE ?) AND is_folder = ?", like, like, false)
+
+	if err := query.Count(&total).Error; err != nil {
+		return nil, 0, err
+	}
+
+	offset := (page - 1) * pageSize
+	err := query.Select("id, title, category, tags, is_pinned, is_favorite, parent_id, is_folder, sort_order, created_at, updated_at").
+		Order("is_pinned DESC, updated_at DESC").
+		Offset(offset).
+		Limit(pageSize).
+		Find(&items).Error
+
+	return items, total, err
+}
+
+// GetCategories 获取所有分类
+func (r *NoteRepository) GetCategories() ([]string, error) {
+	var categories []string
+	err := r.db.Model(&model.Note{}).
+		Distinct("category").
+		Where("category != '' AND is_folder = ?", false).
+		Pluck("category", &categories).Error
+	return categories, err
+}
+
+// GetTags 获取所有标签
+func (r *NoteRepository) GetTags() ([]string, error) {
+	var tagsJSON []string
+	err := r.db.Model(&model.Note{}).
+		Where("tags != '' AND tags IS NOT NULL AND is_folder = ?", false).
+		Pluck("tags", &tagsJSON).Error
+	if err != nil {
+		return nil, err
+	}
+
+	// 去重
+	seen := make(map[string]bool)
+	var result []string
+	for _, t := range tagsJSON {
+		if !seen[t] {
+			seen[t] = true
+			result = append(result, t)
+		}
+	}
+	return result, nil
+}

+ 79 - 0
router/router.go

@@ -0,0 +1,79 @@
+package router
+
+import (
+	"github.com/gin-gonic/gin"
+	"note-manager/config"
+	"note-manager/handler"
+	"note-manager/middleware"
+)
+
+// Setup 初始化路由
+func Setup(r *gin.Engine, noteHandler *handler.NoteHandler, adminHandler *handler.AdminHandler, imageHandler *handler.ImageHandler, cfg *config.Config) *gin.Engine {
+	// 全局中间件
+	r.Use(middleware.CORS())
+
+	// 静态文件服务(图片)
+	r.Static("/uploads", cfg.UploadDir)
+
+	// API 路由组
+	api := r.Group("/api")
+	{
+		notes := api.Group("/notes")
+		{
+			// 公开只读接口
+			notes.GET("", noteHandler.ListNotes)
+			notes.GET("/search", noteHandler.SearchNotes)
+			notes.GET("/:id", noteHandler.GetNote)
+			notes.POST("/:id/access", noteHandler.AccessNote) // 密码验证访问
+
+			// 需要认证的管理接口
+			notes.POST("", noteHandler.CreateNote)
+			notes.PUT("/:id", noteHandler.UpdateNote)
+			notes.DELETE("/:id", noteHandler.DeleteNote)
+		}
+
+		api.GET("/categories", noteHandler.GetCategories)
+		api.GET("/tags", noteHandler.GetTags)
+		api.GET("/tree", noteHandler.GetPublicTree) // 前台公开树
+	}
+
+	// 管理后台专用 API(需要认证)
+	adminApi := r.Group("/admin/api")
+	adminApi.Use(func(c *gin.Context) {
+		token, err := c.Cookie("admin_token")
+		if err != nil || token != "authenticated" {
+			c.JSON(401, gin.H{"code": 401, "message": "请先登录"})
+			c.Abort()
+			return
+		}
+		c.Next()
+	})
+	{
+		adminApi.GET("/tree", noteHandler.GetTree) // 后台完整树
+		adminApi.POST("/upload", imageHandler.Upload)     // 图片上传
+		adminApi.GET("/export/:id", noteHandler.ExportNote) // 导出笔记
+		adminApi.POST("/import", noteHandler.ImportNotes)   // 导入笔记
+	}
+
+	// 后台管理路由
+	admin := r.Group("/admin")
+	{
+		admin.GET("/login", adminHandler.LoginPage)
+		admin.POST("/login", adminHandler.Login)
+		admin.POST("/logout", adminHandler.Logout)
+		admin.GET("/auth", adminHandler.CheckAuth)
+		admin.GET("/", adminHandler.IndexPage)
+	}
+
+	// 健康检查
+	r.GET("/health", func(c *gin.Context) {
+		c.JSON(200, gin.H{"status": "ok"})
+	})
+
+	// 前端页面 - 根路径返回展示页面
+	r.GET("/", func(c *gin.Context) {
+		c.File("./web/index.html")
+	})
+
+	return r
+}

+ 229 - 0
service/note_service.go

@@ -0,0 +1,229 @@
+package service
+
+import (
+	"errors"
+	"fmt"
+	"strconv"
+
+	"note-manager/model"
+	"note-manager/repository"
+)
+
+// NoteService 笔记业务逻辑层
+type NoteService struct {
+	repo     *repository.NoteRepository
+	pageSize int
+}
+
+// NewNoteService 创建服务实例
+func NewNoteService(repo *repository.NoteRepository, pageSize int) *NoteService {
+	return &NoteService{repo: repo, pageSize: pageSize}
+}
+
+// CreateNote 创建笔记或目录
+func (s *NoteService) CreateNote(req model.NoteCreateRequest) (*model.Note, error) {
+	note := &model.Note{
+		Title:    req.Title,
+		Content:  req.Content,
+		Category: req.Category,
+		Tags:     req.Tags,
+		IsFolder: req.IsFolder,
+		SortOrder: req.SortOrder,
+		IsPublic: true, // 默认公开
+	}
+	if req.Password != "" {
+		note.Password = model.HashPassword(req.Password)
+	}
+	if req.IsPinned != nil {
+		note.IsPinned = *req.IsPinned
+	}
+	if req.IsFavorite != nil {
+		note.IsFavorite = *req.IsFavorite
+	}
+	if req.IsPublic != nil {
+		note.IsPublic = *req.IsPublic
+	}
+	if req.ParentID != nil {
+		note.ParentID = *req.ParentID
+	}
+
+	if err := s.repo.Create(note); err != nil {
+		return nil, fmt.Errorf("创建笔记失败: %w", err)
+	}
+	return note, nil
+}
+
+// GetNote 获取单条笔记
+func (s *NoteService) GetNote(id uint) (*model.Note, error) {
+	note, err := s.repo.GetByID(id)
+	if err != nil {
+		return nil, errors.New("笔记不存在")
+	}
+	return note, nil
+}
+
+// GetNoteContent 获取笔记内容(需要密码验证)
+func (s *NoteService) GetNoteContent(id uint, password string) (*model.Note, error) {
+	note, err := s.repo.GetByID(id)
+	if err != nil {
+		return nil, errors.New("笔记不存在")
+	}
+	// 检查密码
+	if note.Password != "" {
+		if !model.CheckPassword(password, note.Password) {
+			return nil, errors.New("密码错误")
+		}
+	}
+	return note, nil
+}
+
+// UpdateNote 更新笔记或目录
+func (s *NoteService) UpdateNote(id uint, req model.NoteUpdateRequest) (*model.Note, error) {
+	note, err := s.repo.GetByID(id)
+	if err != nil {
+		return nil, errors.New("笔记不存在")
+	}
+
+	if req.Title != nil {
+		note.Title = *req.Title
+	}
+	if req.Content != nil {
+		note.Content = *req.Content
+	}
+	if req.Category != nil {
+		note.Category = *req.Category
+	}
+	if req.Tags != nil {
+		note.Tags = *req.Tags
+	}
+	if req.IsPinned != nil {
+		note.IsPinned = *req.IsPinned
+	}
+	if req.IsFavorite != nil {
+		note.IsFavorite = *req.IsFavorite
+	}
+	if req.IsPublic != nil {
+		note.IsPublic = *req.IsPublic
+	}
+	if req.ParentID != nil {
+		note.ParentID = *req.ParentID
+	}
+	if req.IsFolder != nil {
+		note.IsFolder = *req.IsFolder
+	}
+	if req.SortOrder != nil {
+		note.SortOrder = *req.SortOrder
+	}
+	if req.RemovePassword != nil && *req.RemovePassword {
+		note.Password = ""
+	} else if req.Password != nil {
+		note.Password = model.HashPassword(*req.Password)
+	}
+
+	if err := s.repo.Update(note); err != nil {
+		return nil, fmt.Errorf("更新笔记失败: %w", err)
+	}
+	return note, nil
+}
+
+// DeleteNote 删除笔记或目录(目录会删除所有子项)
+func (s *NoteService) DeleteNote(id uint) error {
+	note, err := s.repo.GetByID(id)
+	if err != nil {
+		return errors.New("笔记不存在")
+	}
+	if note.IsFolder {
+		return s.repo.DeleteWithChildren(id)
+	}
+	return s.repo.Delete(id)
+}
+
+// GetAllTree 获取所有笔记和目录的树形结构(管理后台用)
+func (s *NoteService) GetAllTree() ([]model.NoteListItem, error) {
+	return s.repo.GetAllTree()
+}
+
+// GetPublicTree 获取公开笔记的树形结构(前台用)
+func (s *NoteService) GetPublicTree() ([]model.NoteListItem, error) {
+	return s.repo.GetPublicTree()
+}
+
+// ListNotes 获取笔记列表
+func (s *NoteService) ListNotes(pageStr, pageSizeStr, category, tag string, pinned, favorite *bool) ([]model.NoteListItem, int64, int, error) {
+	page := parseInt(pageStr, 1)
+	pageSize := parseInt(pageSizeStr, s.pageSize)
+
+	if page < 1 {
+		page = 1
+	}
+	if pageSize < 1 || pageSize > 100 {
+		pageSize = s.pageSize
+	}
+
+	items, total, err := s.repo.List(repository.ListQuery{
+		Page:     page,
+		PageSize: pageSize,
+		Category: category,
+		Tag:      tag,
+		Pinned:   pinned,
+		Favorite: favorite,
+	})
+	if err != nil {
+		return nil, 0, 0, fmt.Errorf("获取笔记列表失败: %w", err)
+	}
+
+	totalPages := int(total) / pageSize
+	if int(total)%pageSize > 0 {
+		totalPages++
+	}
+
+	return items, total, totalPages, nil
+}
+
+// GetByParentID 获取指定目录下的所有项目
+func (s *NoteService) GetByParentID(parentID uint) ([]model.NoteListItem, error) {
+	return s.repo.GetByParentID(parentID)
+}
+
+// SearchNotes 搜索笔记
+func (s *NoteService) SearchNotes(keyword, pageStr, pageSizeStr string) ([]model.NoteListItem, int64, int, error) {
+	if keyword == "" {
+		return nil, 0, 0, errors.New("搜索关键词不能为空")
+	}
+
+	page := parseInt(pageStr, 1)
+	pageSize := parseInt(pageSizeStr, s.pageSize)
+
+	items, total, err := s.repo.Search(keyword, page, pageSize)
+	if err != nil {
+		return nil, 0, 0, fmt.Errorf("搜索笔记失败: %w", err)
+	}
+
+	totalPages := int(total) / pageSize
+	if int(total)%pageSize > 0 {
+		totalPages++
+	}
+
+	return items, total, totalPages, nil
+}
+
+// GetCategories 获取所有分类
+func (s *NoteService) GetCategories() ([]string, error) {
+	return s.repo.GetCategories()
+}
+
+// GetTags 获取所有标签
+func (s *NoteService) GetTags() ([]string, error) {
+	return s.repo.GetTags()
+}
+
+func parseInt(s string, defaultVal int) int {
+	if s == "" {
+		return defaultVal
+	}
+	v, err := strconv.Atoi(s)
+	if err != nil {
+		return defaultVal
+	}
+	return v
+}

+ 1036 - 0
web/admin/index.html

@@ -0,0 +1,1036 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>笔记管理后台</title>
+    <!-- Highlight.js 代码高亮 -->
+    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
+    <style>
+        * { margin: 0; padding: 0; box-sizing: border-box; }
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+            background: #f5f5f5;
+            height: 100vh;
+            overflow: hidden;
+        }
+        header {
+            background: #fff;
+            padding: 12px 20px;
+            box-shadow: 0 2px 8px rgba(0,0,0,0.1);
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+            height: 56px;
+        }
+        header h1 { font-size: 18px; color: #333; }
+        .header-actions { display: flex; gap: 12px; align-items: center; }
+        .btn {
+            padding: 8px 16px;
+            border: none;
+            border-radius: 6px;
+            cursor: pointer;
+            font-size: 14px;
+            transition: all 0.2s;
+        }
+        .btn-primary { background: #1a73e8; color: #fff; }
+        .btn-primary:hover { background: #1557b0; }
+        .btn-secondary { background: #f1f3f4; color: #333; }
+        .btn-secondary:hover { background: #e8eaed; }
+        .btn-danger { background: #dc3545; color: #fff; }
+        .btn-danger:hover { background: #c82333; }
+        .btn-icon {
+            padding: 8px;
+            background: transparent;
+            color: #666;
+            min-width: 36px;
+        }
+        .btn-icon:hover { background: #f1f3f4; color: #333; }
+        
+        .main {
+            display: flex;
+            height: calc(100vh - 56px);
+        }
+        
+        /* 左侧边栏 - 树形目录 */
+        .sidebar {
+            width: 280px;
+            background: #fff;
+            border-right: 1px solid #e0e0e0;
+            display: flex;
+            flex-direction: column;
+            flex-shrink: 0;
+        }
+        .sidebar-header {
+            padding: 12px 16px;
+            border-bottom: 1px solid #e0e0e0;
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+        }
+        .sidebar-header h2 { font-size: 14px; color: #666; }
+        .tree-container {
+            flex: 1;
+            overflow-y: auto;
+            padding: 8px;
+        }
+        .tree-item {
+            display: flex;
+            align-items: center;
+            padding: 8px 12px;
+            border-radius: 6px;
+            cursor: pointer;
+            font-size: 14px;
+            color: #333;
+            user-select: none;
+        }
+        .tree-item:hover { background: #f1f3f4; }
+        .tree-item.active { background: #e8f0fe; color: #1a73e8; }
+        .tree-item.folder { font-weight: 500; }
+        .tree-item .icon {
+            width: 20px;
+            height: 20px;
+            margin-right: 8px;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+        }
+        .tree-item .title {
+            flex: 1;
+            overflow: hidden;
+            text-overflow: ellipsis;
+            white-space: nowrap;
+        }
+        .tree-item .actions {
+            display: none;
+            gap: 4px;
+        }
+        .tree-item:hover .actions { display: flex; }
+        .tree-item .actions button {
+            padding: 2px 6px;
+            font-size: 12px;
+            background: transparent;
+            border: 1px solid #ddd;
+            border-radius: 4px;
+            cursor: pointer;
+        }
+        .tree-item .actions button:hover { background: #e8eaed; }
+        .tree-children { padding-left: 20px; }
+        
+        /* 右侧内容区 - 左右分栏 */
+        .content {
+            flex: 1;
+            display: flex;
+            overflow: hidden;
+        }
+        
+        /* 编辑区 */
+        .editor-pane {
+            flex: 1;
+            display: flex;
+            flex-direction: column;
+            border-right: 1px solid #e0e0e0;
+            background: #fff;
+        }
+        .editor-header {
+            padding: 12px 16px;
+            border-bottom: 1px solid #e0e0e0;
+            display: flex;
+            align-items: center;
+            gap: 12px;
+        }
+        .editor-header input {
+            flex: 1;
+            padding: 10px 14px;
+            border: 1px solid #e0e0e0;
+            border-radius: 6px;
+            font-size: 16px;
+            font-weight: 500;
+        }
+        .editor-header input:focus {
+            outline: none;
+            border-color: #1a73e8;
+        }
+        .editor-meta {
+            padding: 12px 16px;
+            border-bottom: 1px solid #e0e0e0;
+            display: flex;
+            gap: 12px;
+            flex-wrap: wrap;
+        }
+        .editor-meta input {
+            padding: 8px 12px;
+            border: 1px solid #e0e0e0;
+            border-radius: 6px;
+            font-size: 13px;
+        }
+        .editor-meta input:focus {
+            outline: none;
+            border-color: #1a73e8;
+        }
+        .editor-content {
+            flex: 1;
+            padding: 16px;
+        }
+        .editor-content textarea {
+            width: 100%;
+            height: 100%;
+            border: none;
+            resize: none;
+            font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
+            font-size: 14px;
+            line-height: 1.7;
+            padding: 0;
+            outline: none;
+        }
+        
+        /* 预览区 */
+        .preview-pane {
+            flex: 1;
+            background: #fff;
+            display: flex;
+            flex-direction: column;
+        }
+        .preview-header {
+            padding: 12px 16px;
+            border-bottom: 1px solid #e0e0e0;
+            font-size: 14px;
+            color: #666;
+            display: flex;
+            align-items: center;
+            gap: 8px;
+        }
+        .preview-header svg { width: 18px; height: 18px; }
+        .preview-content {
+            flex: 1;
+            padding: 24px;
+            overflow-y: auto;
+        }
+        .preview-content h1, .preview-content h2, .preview-content h3 {
+            margin: 20px 0 10px 0;
+            color: #202124;
+        }
+        .preview-content h1 { font-size: 28px; }
+        .preview-content h2 { font-size: 22px; }
+        .preview-content h3 { font-size: 18px; }
+        .preview-content p { margin: 10px 0; line-height: 1.8; }
+        .preview-content code {
+            background: #f1f3f4;
+            padding: 2px 6px;
+            border-radius: 4px;
+            font-family: monospace;
+            font-size: 13px;
+        }
+        .preview-content pre {
+            background: #f8f9fa;
+            padding: 16px;
+            border-radius: 8px;
+            overflow-x: auto;
+            margin: 12px 0;
+        }
+        .preview-content pre code { background: none; padding: 0; }
+        .preview-content blockquote {
+            border-left: 4px solid #1a73e8;
+            padding-left: 16px;
+            margin: 16px 0;
+            color: #555;
+        }
+        .preview-content ul, .preview-content ol {
+            margin: 10px 0;
+            padding-left: 24px;
+        }
+        .preview-content li { margin: 6px 0; }
+        
+        /* 空状态 */
+        .empty-state {
+            display: flex;
+            flex-direction: column;
+            align-items: center;
+            justify-content: center;
+            height: 100%;
+            color: #999;
+        }
+        .empty-state svg {
+            width: 64px;
+            height: 64px;
+            fill: #ddd;
+            margin-bottom: 16px;
+        }
+        .empty-state h3 { font-size: 18px; margin-bottom: 8px; color: #666; }
+        .empty-state p { font-size: 14px; }
+        
+        /* 弹窗 */
+        .modal {
+            display: none;
+            position: fixed;
+            top: 0; left: 0;
+            width: 100%; height: 100%;
+            background: rgba(0,0,0,0.5);
+            justify-content: center;
+            align-items: center;
+            z-index: 1000;
+        }
+        .modal.active { display: flex; }
+        .modal-content {
+            background: #fff;
+            padding: 24px;
+            border-radius: 12px;
+            max-width: 400px;
+            width: 90%;
+        }
+        .modal-content h3 { margin-bottom: 16px; font-size: 16px; }
+        .modal-content input {
+            width: 100%;
+            padding: 10px 14px;
+            border: 1px solid #e0e0e0;
+            border-radius: 6px;
+            margin-bottom: 16px;
+            font-size: 14px;
+        }
+        .modal-actions {
+            display: flex;
+            gap: 12px;
+            justify-content: flex-end;
+        }
+        
+        /* Toast */
+        .toast {
+            position: fixed;
+            bottom: 20px; right: 20px;
+            padding: 12px 20px;
+            background: #333;
+            color: #fff;
+            border-radius: 8px;
+            display: none;
+            z-index: 1001;
+        }
+        .toast.show { display: block; }
+        .toast.success { background: #28a745; }
+        .toast.error { background: #dc3545; }
+        
+        /* Resize handle */
+        .resize-handle {
+            width: 4px;
+            cursor: col-resize;
+            background: transparent;
+            transition: background 0.2s;
+        }
+        .resize-handle:hover { background: #1a73e8; }
+    </style>
+</head>
+<body>
+    <header>
+        <h1>笔记管理后台</h1>
+        <div class="header-actions">
+            <a href="/" class="btn btn-secondary" target="_blank">前台预览</a>
+            <button class="btn btn-secondary" onclick="logout()">退出登录</button>
+        </div>
+    </header>
+
+    <div class="main">
+        <!-- 左侧边栏 - 树形目录 -->
+        <div class="sidebar">
+            <div class="sidebar-header">
+                <h2>笔记目录</h2>
+                <div>
+                    <button class="btn btn-icon" onclick="showNewFolderModal(0)" title="新建目录">
+                        <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M20 6h-8l-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm-1 8h-3v3h-2v-3h-3v-2h3V9h2v3h3v2z"/></svg>
+                    </button>
+                    <button class="btn btn-icon" onclick="showNewNoteModal(0)" title="新建笔记">
+                        <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
+                    </button>
+                </div>
+            </div>
+            <div class="tree-container" id="treeContainer">
+                <div class="loading">加载中...</div>
+            </div>
+        </div>
+
+        <!-- 右侧内容区 -->
+        <div class="content" id="contentArea">
+            <!-- 空状态 -->
+            <div class="empty-state" id="emptyState">
+                <svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg>
+                <h3>选择一个笔记或目录</h3>
+                <p>从左侧列表选择,或点击 + 新建</p>
+            </div>
+
+            <!-- 编辑器和预览(隐藏直到选中笔记) -->
+            <div id="editorView" style="display: none; flex: 1; display: none; flex-direction: column;">
+                <!-- 编辑区 -->
+                <div class="editor-pane" id="editorPane">
+                    <div class="editor-header">
+                        <input type="text" id="noteTitle" placeholder="笔记标题">
+                        <button class="btn btn-primary" onclick="saveNote()">保存</button>
+                        <button class="btn btn-secondary" onclick="exportNote()">导出</button>
+                        <button class="btn btn-warning" onclick="document.getElementById('importFile').click()">导入</button>
+                        <input type="file" id="importFile" accept=".md" style="display:none" onchange="importNote(this.files[0])">
+                        <button class="btn btn-danger" onclick="confirmDelete()">删除</button>
+                    </div>
+                    <div class="editor-meta">
+                        <input type="text" id="noteCategory" placeholder="分类(可选)" style="flex: 1;">
+                        <input type="text" id="noteTags" placeholder="标签,多个用逗号分隔" style="flex: 1;">
+                        <label style="display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 13px; color: #666; white-space: nowrap;">
+                            <input type="checkbox" id="notePublic" checked style="width: 16px; height: 16px;">
+                            <svg viewBox="0 0 24 24" style="width: 14px; height: 14px; fill: #666;"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg>
+                            公开
+                        </label>
+                        <div style="display: flex; align-items: center; gap: 6px; margin-left: 12px;">
+                            <svg viewBox="0 0 24 24" style="width: 14px; height: 14px; fill: #666;"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/></svg>
+                            <input type="password" id="notePassword" placeholder="访问密码(可选)" style="width: 120px; padding: 6px 10px; border: 1px solid #e0e0e0; border-radius: 4px; font-size: 12px;">
+                        </div>
+                    </div>
+                    <div class="editor-content">
+                        <textarea id="noteContent" placeholder="使用 Markdown 编写内容..."></textarea>
+                    </div>
+                </div>
+                <div class="resize-handle" id="resizeHandle"></div>
+                <!-- 预览区 -->
+                <div class="preview-pane" id="previewPane">
+                    <div class="preview-header">
+                        <svg viewBox="0 0 24 24" fill="#666"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>
+                        预览
+                    </div>
+                    <div class="preview-content" id="previewContent">
+                        <p style="color: #999;">开始编辑以查看预览...</p>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <!-- 新建/编辑目录弹窗 -->
+    <div class="modal" id="folderModal">
+        <div class="modal-content">
+            <h3 id="folderModalTitle">新建目录</h3>
+            <input type="hidden" id="folderParentId">
+            <input type="hidden" id="folderEditId">
+            <input type="text" id="folderName" placeholder="目录名称">
+            <div class="modal-actions">
+                <button class="btn btn-secondary" onclick="closeFolderModal()">取消</button>
+                <button class="btn btn-primary" onclick="saveFolder()">保存</button>
+            </div>
+        </div>
+    </div>
+
+    <!-- 删除确认弹窗 -->
+    <div class="modal" id="deleteModal">
+        <div class="modal-content">
+            <h3>确认删除</h3>
+            <p id="deleteMessage">确定要删除吗?此操作不可撤销。</p>
+            <div class="modal-actions">
+                <button class="btn btn-secondary" onclick="closeDeleteModal()">取消</button>
+                <button class="btn btn-danger" onclick="doDelete()">删除</button>
+            </div>
+        </div>
+    </div>
+
+    <div class="toast" id="toast"></div>
+
+    <script>
+        const API = '/api';
+        const ADMIN_API = '/admin/api';
+        let treeData = [];
+        let currentItem = null;
+        let isFolderMode = false;
+
+        // 初始化
+        async function init() {
+            await loadTree();
+            setupResize();
+            setupImagePaste();
+        }
+        
+        // 设置粘贴图片监听
+        function setupImagePaste() {
+            const textarea = document.getElementById('noteContent');
+            if (!textarea) return;
+            
+            textarea.addEventListener('paste', async (e) => {
+                // 检查剪贴板中是否有图片
+                const items = e.clipboardData?.items;
+                if (!items) return;
+                
+                for (let item of items) {
+                    if (item.type.indexOf('image') === 0) {
+                        e.preventDefault();
+                        
+                        const file = item.getAsFile();
+                        if (!file) continue;
+                        
+                        // 显示上传提示
+                        const originalValue = textarea.value;
+                        const cursorPos = textarea.selectionStart;
+                        const beforeCursor = originalValue.substring(0, cursorPos);
+                        const afterCursor = originalValue.substring(cursorPos);
+                        
+                        textarea.value = beforeCursor + '\n![上传中...](uploading)\n' + afterCursor;
+                        textarea.selectionStart = textarea.selectionEnd = cursorPos + 14;
+                        textarea.focus();
+                        
+                        try {
+                            const url = await uploadImage(file);
+                            // 替换上传提示为实际图片
+                            textarea.value = textarea.value.replace('![上传中...](uploading)', `![image](${url})`);
+                            // 手动触发预览更新
+                            updatePreview();
+                        } catch (err) {
+                            // 替换为错误信息
+                            textarea.value = textarea.value.replace('![上传中...](uploading)', '![上传失败](failed)');
+                            showToast('图片上传失败', 'error');
+                            // 手动触发预览更新
+                            updatePreview();
+                        }
+                        return;
+                    }
+                }
+            });
+        }
+        
+        // 上传图片
+        async function uploadImage(file) {
+            const formData = new FormData();
+            formData.append('image', file);
+            
+            const res = await fetch(`${ADMIN_API}/upload`, {
+                method: 'POST',
+                credentials: 'include',
+                body: formData
+            });
+            
+            const data = await res.json();
+            if (data.code !== 0) {
+                throw new Error(data.message || '上传失败');
+            }
+            
+            return data.data.url;
+        }
+
+        // 加载树形数据
+        async function loadTree() {
+            try {
+                const res = await fetch(`/admin/api/tree`);
+                const data = await res.json();
+                if (data.code === 0) {
+                    treeData = data.data;
+                    renderTree();
+                }
+            } catch (err) {
+                showToast('加载目录失败', 'error');
+            }
+        }
+
+        // 渲染树形结构
+        function renderTree() {
+            const container = document.getElementById('treeContainer');
+            if (treeData.length === 0) {
+                container.innerHTML = '<div style="padding: 20px; text-align: center; color: #999;">暂无内容,点击 + 新建</div>';
+                return;
+            }
+            
+            // 构建树形结构
+            const rootItems = treeData.filter(item => item.parent_id === 0);
+            container.innerHTML = renderTreeItems(rootItems);
+        }
+
+        function renderTreeItems(items) {
+            return items.map(item => {
+                const children = treeData.filter(c => c.parent_id === item.id);
+                const hasChildren = children.length > 0;
+                const isActive = currentItem && currentItem.id === item.id;
+                
+                if (item.is_folder) {
+                    return `
+                        <div class="tree-item folder ${isActive ? 'active' : ''}" onclick="selectItem(${item.id})">
+                            <span class="icon">
+                                <svg width="18" height="18" viewBox="0 0 24 24" fill="${isActive ? '#1a73e8' : '#fbbc04'}">
+                                    <path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/>
+                                </svg>
+                            </span>
+                            <span class="title">${escapeHtml(item.title)}</span>
+                            <div class="actions">
+                                <button onclick="event.stopPropagation(); showNewFolderModal(${item.id})">+目录</button>
+                                <button onclick="event.stopPropagation(); showNewNoteModal(${item.id})">+笔记</button>
+                            </div>
+                        </div>
+                        ${hasChildren ? `<div class="tree-children">${renderTreeItems(children)}</div>` : ''}
+                    `;
+                } else {
+                    return `
+                        <div class="tree-item ${isActive ? 'active' : ''}" onclick="selectItem(${item.id})">
+                            <span class="icon">
+                                <svg width="16" height="16" viewBox="0 0 24 24" fill="#666">
+                                    <path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/>
+                                </svg>
+                            </span>
+                            <span class="title">${escapeHtml(item.title)}</span>
+                        </div>
+                    `;
+                }
+            }).join('');
+        }
+
+        // 选择项目
+        async function selectItem(id) {
+            try {
+                const res = await fetch(`${API}/notes/${id}`);
+                const data = await res.json();
+                if (data.code === 0) {
+                    currentItem = data.data;
+                    isFolderMode = currentItem.is_folder;
+                    
+                    document.getElementById('emptyState').style.display = 'none';
+                    const editorView = document.getElementById('editorView');
+                    
+                    if (isFolderMode) {
+                        // 目录模式
+                        document.getElementById('editorPane').style.display = 'none';
+                        document.getElementById('resizeHandle').style.display = 'none';
+                        document.getElementById('previewPane').style.display = 'none';
+                        editorView.style.display = 'flex';
+                        editorView.innerHTML = `
+                            <div style="padding: 40px; text-align: center;">
+                                <svg width="64" height="64" viewBox="0 0 24 24" fill="#fbbc04" style="margin-bottom: 16px;">
+                                    <path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/>
+                                </svg>
+                                <h3 style="margin-bottom: 8px;">${escapeHtml(currentItem.title)}</h3>
+                                <p style="color: #666; margin-bottom: 20px;">目录:包含 ${treeData.filter(t => t.parent_id === id).length} 个项目</p>
+                                <button class="btn btn-secondary" onclick="showEditFolderModal()">编辑目录</button>
+                            </div>
+                        `;
+                    } else {
+                        // 笔记模式
+                        document.getElementById('editorPane').style.display = 'flex';
+                        document.getElementById('resizeHandle').style.display = 'block';
+                        document.getElementById('previewPane').style.display = 'flex';
+                        editorView.style.display = 'flex';
+                        editorView.style.flexDirection = 'row';
+                        
+                        document.getElementById('noteTitle').value = currentItem.title || '';
+                        document.getElementById('noteCategory').value = currentItem.category || '';
+                        document.getElementById('noteTags').value = parseTags(currentItem.tags).join(', ');
+                        document.getElementById('noteContent').value = currentItem.content || '';
+                        document.getElementById('notePublic').checked = currentItem.is_public !== false;
+                        document.getElementById('notePassword').value = ''; // 不显示密码
+                        updatePreview();
+                    }
+                    
+                    renderTree();
+                }
+            } catch (err) {
+                showToast('加载失败', 'error');
+            }
+        }
+
+        // 显示新建目录弹窗
+        function showNewFolderModal(parentId) {
+            document.getElementById('folderModalTitle').textContent = '新建目录';
+            document.getElementById('folderParentId').value = parentId;
+            document.getElementById('folderEditId').value = '';
+            document.getElementById('folderName').value = '';
+            document.getElementById('folderModal').classList.add('active');
+        }
+
+        // 显示编辑目录弹窗
+        function showEditFolderModal() {
+            if (!currentItem) return;
+            document.getElementById('folderModalTitle').textContent = '编辑目录';
+            document.getElementById('folderParentId').value = currentItem.parent_id;
+            document.getElementById('folderEditId').value = currentItem.id;
+            document.getElementById('folderName').value = currentItem.title;
+            document.getElementById('folderModal').classList.add('active');
+        }
+
+        function closeFolderModal() {
+            document.getElementById('folderModal').classList.remove('active');
+        }
+
+        async function saveFolder() {
+            const name = document.getElementById('folderName').value.trim();
+            const parentId = parseInt(document.getElementById('folderParentId').value) || 0;
+            const editId = document.getElementById('folderEditId').value;
+
+            if (!name) {
+                showToast('请输入目录名称', 'error');
+                return;
+            }
+
+            try {
+                let res, data;
+                if (editId) {
+                    // 更新
+                    res = await fetch(`${API}/notes/${editId}`, {
+                        method: 'PUT',
+                        headers: { 'Content-Type': 'application/json' },
+                        body: JSON.stringify({ title: name })
+                    });
+                } else {
+                    // 新建
+                    res = await fetch(`${API}/notes`, {
+                        method: 'POST',
+                        headers: { 'Content-Type': 'application/json' },
+                        body: JSON.stringify({ title: name, is_folder: true, parent_id: parentId })
+                    });
+                }
+                data = await res.json();
+                
+                if (data.code === 0) {
+                    showToast(editId ? '目录已更新' : '目录已创建', 'success');
+                    closeFolderModal();
+                    await loadTree();
+                    if (editId && currentItem && currentItem.id == editId) {
+                        selectItem(parseInt(editId));
+                    }
+                } else {
+                    showToast(data.message || '保存失败', 'error');
+                }
+            } catch (err) {
+                showToast('保存失败', 'error');
+            }
+        }
+
+        // 显示新建笔记弹窗
+        function showNewNoteModal(parentId) {
+            if (!parentId && parentId !== 0) return;
+            currentItem = { parent_id: parentId || 0, is_folder: false, id: null };
+            document.getElementById('emptyState').style.display = 'none';
+            document.getElementById('editorView').style.display = 'flex';
+            document.getElementById('editorView').style.flexDirection = 'row';
+            document.getElementById('editorPane').style.display = 'flex';
+            document.getElementById('resizeHandle').style.display = 'block';
+            document.getElementById('previewPane').style.display = 'flex';
+            document.getElementById('noteTitle').value = '';
+            document.getElementById('noteCategory').value = '';
+            document.getElementById('noteTags').value = '';
+            document.getElementById('noteContent').value = '';
+            updatePreview();
+        }
+
+        // 保存笔记
+        async function saveNote() {
+            const title = document.getElementById('noteTitle').value.trim();
+            const content = document.getElementById('noteContent').value;
+            const category = document.getElementById('noteCategory').value.trim();
+            const tagsInput = document.getElementById('noteTags').value.trim();
+            const isPublic = document.getElementById('notePublic').checked;
+            const password = document.getElementById('notePassword').value;
+
+            if (!title) {
+                showToast('请输入标题', 'error');
+                return;
+            }
+
+            let tags = '[]';
+            if (tagsInput) {
+                const arr = tagsInput.split(',').map(t => t.trim()).filter(t => t);
+                tags = JSON.stringify(arr);
+            }
+
+            const payload = {
+                title,
+                content,
+                category,
+                tags,
+                is_public: isPublic,
+                parent_id: currentItem && currentItem.parent_id ? currentItem.parent_id : 0
+            };
+            
+            // 只有填写了密码才传递
+            if (password) {
+                payload.password = password;
+            } else {
+                // 如果没填密码但原有笔记有密码,不清除
+                // 如果没填密码且原有笔记也没有密码,不传
+                payload.remove_password = !currentItem?.has_password;
+            }
+
+            try {
+                let res, data;
+                if (currentItem && currentItem.id && !currentItem.is_folder) {
+                    // 更新
+                    res = await fetch(`${API}/notes/${currentItem.id}`, {
+                        method: 'PUT',
+                        headers: { 'Content-Type': 'application/json' },
+                        body: JSON.stringify(payload)
+                    });
+                } else {
+                    // 新建
+                    res = await fetch(`${API}/notes`, {
+                        method: 'POST',
+                        headers: { 'Content-Type': 'application/json' },
+                        body: JSON.stringify(payload)
+                    });
+                }
+                data = await res.json();
+
+                if (data.code === 0) {
+                    showToast(currentItem && currentItem.id ? '笔记已更新' : '笔记已创建', 'success');
+                    await loadTree();
+                    if (data.data && data.data.id) {
+                        selectItem(data.data.id);
+                    }
+                } else {
+                    showToast(data.message || '保存失败', 'error');
+                }
+            } catch (err) {
+                showToast('保存失败', 'error');
+            }
+        }
+
+        // 导出笔记为 Markdown 文件
+        function exportNote() {
+            if (!currentItem || !currentItem.id || currentItem.is_folder) {
+                showToast('请先选择一个笔记(不是目录)', 'error');
+                return;
+            }
+            window.location.href = `${ADMIN_API}/export/${currentItem.id}`;
+        }
+
+        // 导入 Markdown 文件
+        async function importNote(file) {
+            if (!file) return;
+            
+            const formData = new FormData();
+            formData.append('file', file);
+            
+            try {
+                const res = await fetch(`${ADMIN_API}/import`, {
+                    method: 'POST',
+                    credentials: 'include',
+                    body: formData
+                });
+                const data = await res.json();
+                
+                if (data.code === 0) {
+                    showToast('导入成功', 'success');
+                    await loadTree();
+                    if (data.data && data.data.id) {
+                        selectItem(data.data.id);
+                    }
+                } else {
+                    showToast(data.message || '导入失败', 'error');
+                }
+            } catch (err) {
+                showToast('导入失败', 'error');
+            }
+            
+            // 清空文件输入
+            document.getElementById('importFile').value = '';
+        }
+
+        // 删除确认
+        function confirmDelete() {
+            if (!currentItem || !currentItem.id) return;
+            const message = currentItem.is_folder 
+                ? '确定要删除这个目录及其下所有内容吗?此操作不可撤销。'
+                : '确定要删除这篇笔记吗?此操作不可撤销。';
+            document.getElementById('deleteMessage').textContent = message;
+            document.getElementById('deleteModal').classList.add('active');
+        }
+
+        function closeDeleteModal() {
+            document.getElementById('deleteModal').classList.remove('active');
+        }
+
+        async function doDelete() {
+            if (!currentItem || !currentItem.id) return;
+            
+            try {
+                const res = await fetch(`${API}/notes/${currentItem.id}`, { method: 'DELETE' });
+                const data = await res.json();
+                
+                if (data.code === 0) {
+                    showToast('已删除', 'success');
+                    closeDeleteModal();
+                    currentItem = null;
+                    document.getElementById('emptyState').style.display = 'flex';
+                    document.getElementById('editorView').style.display = 'none';
+                    await loadTree();
+                } else {
+                    showToast(data.message || '删除失败', 'error');
+                }
+            } catch (err) {
+                showToast('删除失败', 'error');
+            }
+        }
+
+        // 更新预览
+        function updatePreview() {
+            const content = document.getElementById('noteContent').value;
+            document.getElementById('previewContent').innerHTML = renderMarkdown(content);
+        }
+
+        // 监听输入更新预览
+        document.addEventListener('DOMContentLoaded', () => {
+            const contentArea = document.getElementById('noteContent');
+            if (contentArea) {
+                contentArea.addEventListener('input', updatePreview);
+            }
+            init();
+        });
+
+        // 退出登录
+        async function logout() {
+            await fetch('/admin/logout', { method: 'POST' });
+            window.location.href = '/admin/login';
+        }
+
+        // 工具函数
+        function parseTags(tagsJson) {
+            if (!tagsJson) return [];
+            try { return JSON.parse(tagsJson); } catch { return []; }
+        }
+
+        function escapeHtml(text) {
+            if (!text) return '';
+            const div = document.createElement('div');
+            div.textContent = text;
+            return div.innerHTML;
+        }
+
+        function showToast(message, type = 'info') {
+            const toast = document.getElementById('toast');
+            toast.textContent = message;
+            toast.className = `toast show ${type}`;
+            setTimeout(() => toast.classList.remove('show'), 3000);
+        }
+
+        // 简单的 Markdown 渲染(支持代码高亮)
+        function renderMarkdown(text) {
+            if (!text) return '<p style="color: #999;">无内容</p>';
+            
+            // 第一步:所有 Markdown 语法先转为占位符,不输出 HTML
+            const placeholders = [];
+            let placeholderIndex = 0;
+            
+            // 代码块占位符
+            text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, function(match, lang, code) {
+                const ph = '__PH_' + placeholderIndex++ + '__';
+                placeholders.push({ ph: ph, type: 'code', lang: lang || 'plaintext', code: code.trim() });
+                return ph;
+            });
+            
+            // 图片占位符
+            text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function(match, alt, src) {
+                const ph = '__PH_' + placeholderIndex++ + '__';
+                placeholders.push({ ph: ph, type: 'img', src: src, alt: alt });
+                return ph;
+            });
+            
+            // 链接占位符
+            text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(match, text, href) {
+                const ph = '__PH_' + placeholderIndex++ + '__';
+                placeholders.push({ ph: ph, type: 'link', text: text, href: href });
+                return ph;
+            });
+            
+            // 行内代码占位符
+            text = text.replace(/`([^`]+)`/g, function(match, code) {
+                const ph = '__PH_' + placeholderIndex++ + '__';
+                placeholders.push({ ph: ph, type: 'inlinecode', code: code });
+                return ph;
+            });
+            
+            // 标题占位符
+            text = text.replace(/^### (.+)$/gm, function(match, content) {
+                const ph = '__PH_' + placeholderIndex++ + '__';
+                const id = content.toLowerCase().replace(/[^\w\u4e00-\u9fa5]+/g, '-');
+                placeholders.push({ ph: ph, type: 'h3', content: content, id: id });
+                return ph;
+            });
+            text = text.replace(/^## (.+)$/gm, function(match, content) {
+                const ph = '__PH_' + placeholderIndex++ + '__';
+                const id = content.toLowerCase().replace(/[^\w\u4e00-\u9fa5]+/g, '-');
+                placeholders.push({ ph: ph, type: 'h2', content: content, id: id });
+                return ph;
+            });
+            text = text.replace(/^# (.+)$/gm, function(match, content) {
+                const ph = '__PH_' + placeholderIndex++ + '__';
+                const id = content.toLowerCase().replace(/[^\w\u4e00-\u9fa5]+/g, '-');
+                placeholders.push({ ph: ph, type: 'h1', content: content, id: id });
+                return ph;
+            });
+            
+            // 转义剩余的 HTML 特殊字符
+            text = escapeHtml(text);
+            
+            // 恢复所有占位符为 HTML
+            placeholders.forEach(function(item) {
+                let html = '';
+                switch(item.type) {
+                    case 'code':
+                        var highlighted = hljs.highlightAuto(item.code, item.lang !== 'plaintext' ? [item.lang] : undefined).value;
+                        html = '<pre><code class="hljs language-' + item.lang + '">' + highlighted + '</code></pre>';
+                        break;
+                    case 'img':
+                        html = '<img src="' + item.src + '" alt="' + item.alt + '" style="max-width: 100%; border-radius: 4px;">';
+                        break;
+                    case 'link':
+                        html = '<a href="' + item.href + '" target="_blank">' + item.text + '</a>';
+                        break;
+                    case 'inlinecode':
+                        html = '<code>' + item.code + '</code>';
+                        break;
+                    case 'h3':
+                        html = '<h3 id="' + item.id + '">' + item.content + '</h3>';
+                        break;
+                    case 'h2':
+                        html = '<h2 id="' + item.id + '">' + item.content + '</h2>';
+                        break;
+                    case 'h1':
+                        html = '<h1 id="' + item.id + '">' + item.content + '</h1>';
+                        break;
+                }
+                text = text.replace(item.ph, html);
+            });
+            
+            // 其他格式
+            text = text.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
+            text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
+            text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
+            text = text.replace(/~~(.+?)~~/g, '<del>$1</del>');
+            text = text.replace(/^> (.*$)/gm, '<blockquote>$1</blockquote>');
+            
+            // 换行
+            text = text.replace(/\n\n/g, '</p><p>');
+            text = text.replace(/\n/g, '<br>');
+            
+            return '<p>' + text + '</p>';
+        }
+
+        // 分栏拖动调整
+        function setupResize() {
+            const handle = document.getElementById('resizeHandle');
+            const editorPane = document.getElementById('editorPane');
+            const previewPane = document.getElementById('previewPane');
+            
+            let isResizing = false;
+            
+            handle?.addEventListener('mousedown', (e) => {
+                isResizing = true;
+                document.body.style.cursor = 'col-resize';
+                document.body.style.userSelect = 'none';
+            });
+
+            document.addEventListener('mousemove', (e) => {
+                if (!isResizing) return;
+                const container = document.getElementById('contentArea');
+                const containerRect = container.getBoundingClientRect();
+                const percentage = ((e.clientX - containerRect.left) / containerRect.width) * 100;
+                
+                if (percentage > 20 && percentage < 80) {
+                    editorPane.style.flex = `0 0 ${percentage}%`;
+                    previewPane.style.flex = `0 0 ${100 - percentage}%`;
+                }
+            });
+
+            document.addEventListener('mouseup', () => {
+                isResizing = false;
+                document.body.style.cursor = '';
+                document.body.style.userSelect = '';
+            });
+        }
+    </script>
+</body>
+</html>

+ 140 - 0
web/admin/login.html

@@ -0,0 +1,140 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>后台管理登录 - 云笔记</title>
+    <style>
+        * { margin: 0; padding: 0; box-sizing: border-box; }
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            min-height: 100vh;
+            display: flex;
+            justify-content: center;
+            align-items: center;
+        }
+        .login-box {
+            background: #fff;
+            padding: 40px;
+            border-radius: 12px;
+            box-shadow: 0 20px 60px rgba(0,0,0,0.3);
+            width: 100%;
+            max-width: 400px;
+        }
+        .login-box h1 {
+            text-align: center;
+            color: #333;
+            margin-bottom: 30px;
+            font-size: 24px;
+        }
+        .form-group {
+            margin-bottom: 20px;
+        }
+        .form-group label {
+            display: block;
+            margin-bottom: 8px;
+            color: #666;
+            font-size: 14px;
+        }
+        .form-group input {
+            width: 100%;
+            padding: 12px 16px;
+            border: 2px solid #e1e1e1;
+            border-radius: 8px;
+            font-size: 16px;
+            transition: border-color 0.3s;
+        }
+        .form-group input:focus {
+            outline: none;
+            border-color: #667eea;
+        }
+        .btn-login {
+            width: 100%;
+            padding: 14px;
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            color: #fff;
+            border: none;
+            border-radius: 8px;
+            font-size: 16px;
+            cursor: pointer;
+            transition: transform 0.2s;
+        }
+        .btn-login:hover {
+            transform: translateY(-2px);
+        }
+        .btn-login:disabled {
+            opacity: 0.7;
+            cursor: not-allowed;
+        }
+        .error-msg {
+            color: #dc3545;
+            font-size: 14px;
+            margin-top: 10px;
+            text-align: center;
+            display: none;
+        }
+        .back-link {
+            display: block;
+            text-align: center;
+            margin-top: 20px;
+            color: #667eea;
+            text-decoration: none;
+        }
+    </style>
+</head>
+<body>
+    <div class="login-box">
+        <h1>后台管理登录</h1>
+        <form id="loginForm">
+            <div class="form-group">
+                <label>管理密码</label>
+                <input type="password" id="password" placeholder="请输入管理密码" required>
+            </div>
+            <button type="submit" class="btn-login" id="loginBtn">登 录</button>
+            <p class="error-msg" id="errorMsg"></p>
+        </form>
+        <a href="/" class="back-link">返回前台</a>
+    </div>
+
+    <script>
+        const form = document.getElementById('loginForm');
+        const password = document.getElementById('password');
+        const loginBtn = document.getElementById('loginBtn');
+        const errorMsg = document.getElementById('errorMsg');
+
+        form.addEventListener('submit', async (e) => {
+            e.preventDefault();
+            errorMsg.style.display = 'none';
+            loginBtn.disabled = true;
+            loginBtn.textContent = '登录中...';
+
+            try {
+                const formData = new FormData();
+                formData.append('password', password.value);
+
+                const res = await fetch('/admin/login', {
+                    method: 'POST',
+                    body: formData
+                });
+
+                const data = await res.json();
+
+                if (data.code === 0) {
+                    window.location.href = '/admin/';
+                } else {
+                    errorMsg.textContent = data.message;
+                    errorMsg.style.display = 'block';
+                    loginBtn.disabled = false;
+                    loginBtn.textContent = '登 录';
+                }
+            } catch (err) {
+                errorMsg.textContent = '登录失败,请重试';
+                errorMsg.style.display = 'block';
+                loginBtn.disabled = false;
+                loginBtn.textContent = '登 录';
+            }
+        });
+    </script>
+</body>
+</html>

+ 896 - 0
web/index.html

@@ -0,0 +1,896 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>云笔记</title>
+    <!-- Highlight.js 代码高亮 -->
+    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
+    <style>
+        * { margin: 0; padding: 0; box-sizing: border-box; }
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+            background: #fafafa;
+            color: #333;
+        }
+        header {
+            background: #fff;
+            padding: 16px 24px;
+            box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+            position: fixed;
+            top: 0;
+            left: 0;
+            right: 0;
+            z-index: 100;
+        }
+        header h1 {
+            font-size: 20px;
+            font-weight: 600;
+            color: #1a73e8;
+        }
+        .header-right {
+            display: flex;
+            align-items: center;
+            gap: 16px;
+        }
+        .search-box {
+            display: flex;
+            align-items: center;
+            background: #f1f3f4;
+            border-radius: 24px;
+            padding: 8px 16px;
+        }
+        .search-box input {
+            border: none;
+            background: transparent;
+            outline: none;
+            width: 200px;
+            font-size: 14px;
+        }
+        .search-box svg {
+            width: 20px;
+            height: 20px;
+            fill: #666;
+        }
+        .main {
+            display: flex;
+            margin-top: 65px;
+            height: calc(100vh - 65px);
+        }
+        
+        /* 左侧边栏 */
+        .sidebar {
+            width: 280px;
+            background: #fff;
+            border-right: 1px solid #e0e0e0;
+            overflow-y: auto;
+            flex-shrink: 0;
+            display: flex;
+            flex-direction: column;
+        }
+        .sidebar-header {
+            padding: 16px;
+            border-bottom: 1px solid #e0e0e0;
+            display: flex;
+            align-items: center;
+            gap: 8px;
+        }
+        .sidebar-header h3 {
+            font-size: 14px;
+            font-weight: 600;
+            color: #333;
+        }
+        .sidebar-section {
+            padding: 12px;
+        }
+        .sidebar-section h4 {
+            font-size: 11px;
+            text-transform: uppercase;
+            color: #888;
+            margin-bottom: 8px;
+            font-weight: 500;
+            padding: 0 8px;
+        }
+        
+        /* 树形目录 */
+        .tree-view {
+            flex: 1;
+            overflow-y: auto;
+            padding: 8px;
+        }
+        .tree-item {
+            user-select: none;
+        }
+        .tree-node {
+            display: flex;
+            align-items: center;
+            padding: 8px 10px;
+            border-radius: 6px;
+            cursor: pointer;
+            transition: background 0.15s;
+            gap: 6px;
+        }
+        .tree-node:hover {
+            background: #f5f5f5;
+        }
+        .tree-node.active {
+            background: #e3f2fd;
+            color: #1976d2;
+        }
+        .tree-toggle {
+            width: 18px;
+            height: 18px;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            flex-shrink: 0;
+            transition: transform 0.2s;
+        }
+        .tree-toggle svg {
+            width: 14px;
+            height: 14px;
+            fill: #888;
+        }
+        .tree-toggle.expanded {
+            transform: rotate(90deg);
+        }
+        .tree-icon {
+            width: 18px;
+            height: 18px;
+            flex-shrink: 0;
+        }
+        .tree-icon svg {
+            width: 16px;
+            height: 16px;
+        }
+        .tree-icon.folder svg { fill: #f9a825; }
+        .tree-icon.note svg { fill: #42a5f5; }
+        .tree-label {
+            flex: 1;
+            font-size: 13px;
+            white-space: nowrap;
+            overflow: hidden;
+            text-overflow: ellipsis;
+        }
+        .tree-children {
+            padding-left: 20px;
+        }
+        .tree-children.collapsed {
+            display: none;
+        }
+        .tree-item.pinned .tree-icon svg { fill: #f57c00; }
+        
+        /* 筛选区域 */
+        .filter-area {
+            padding: 12px;
+            border-top: 1px solid #e0e0e0;
+            background: #fafafa;
+        }
+        .filter-item {
+            padding: 10px 12px;
+            border-radius: 8px;
+            cursor: pointer;
+            font-size: 13px;
+            display: flex;
+            align-items: center;
+            gap: 10px;
+            color: #555;
+            transition: background 0.2s;
+        }
+        .filter-item:hover { background: #f1f3f4; }
+        .filter-item.active { background: #e3f2fd; color: #1976d2; }
+        .filter-item svg { width: 16px; height: 16px; }
+        
+        .note-list {
+            border-top: 1px solid #e0e0e0;
+            padding: 8px;
+        }
+        .note-item {
+            padding: 14px;
+            border-radius: 8px;
+            cursor: pointer;
+            margin-bottom: 4px;
+            transition: background 0.2s;
+        }
+        .note-item:hover { background: #f1f3f4; }
+        .note-item.active { background: #e8f0fe; }
+        .note-item h4 {
+            font-size: 14px;
+            font-weight: 500;
+            margin-bottom: 4px;
+            color: #202124;
+        }
+        .note-item .preview {
+            font-size: 12px;
+            color: #666;
+            line-height: 1.4;
+            overflow: hidden;
+            text-overflow: ellipsis;
+            white-space: nowrap;
+        }
+        .note-item .meta {
+            font-size: 11px;
+            color: #999;
+            margin-top: 6px;
+        }
+        .note-item .tags {
+            display: flex;
+            gap: 4px;
+            margin-top: 6px;
+            flex-wrap: wrap;
+        }
+        .note-item .tag {
+            padding: 2px 8px;
+            background: #e8f0fe;
+            color: #1a73e8;
+            border-radius: 12px;
+            font-size: 11px;
+        }
+        
+        /* 右侧内容区 */
+        .content {
+            flex: 1;
+            overflow-y: auto;
+            background: #fff;
+            display: flex;
+        }
+        /* 笔记目录(正文内) */
+        .toc-sidebar {
+            width: 220px;
+            border-right: 1px solid #e8e8e8;
+            padding: 24px 16px;
+            background: #fafafa;
+            flex-shrink: 0;
+            overflow-y: auto;
+        }
+        .toc-title {
+            font-size: 12px;
+            font-weight: 600;
+            color: #888;
+            text-transform: uppercase;
+            margin-bottom: 12px;
+            padding-left: 8px;
+        }
+        .toc-list {
+            list-style: none;
+        }
+        .toc-item {
+            margin-bottom: 4px;
+        }
+        .toc-link {
+            display: block;
+            padding: 6px 8px;
+            font-size: 13px;
+            color: #555;
+            text-decoration: none;
+            border-radius: 4px;
+            transition: all 0.15s;
+            border-left: 2px solid transparent;
+        }
+        .toc-link:hover {
+            background: #e8e8e8;
+            color: #333;
+        }
+        .toc-link.active {
+            background: #e3f2fd;
+            color: #1976d2;
+            border-left-color: #1976d2;
+        }
+        .toc-link.level-2 { padding-left: 16px; font-size: 12px; }
+        .toc-link.level-3 { padding-left: 24px; font-size: 12px; color: #777; }
+        
+        /* 正文区域 */
+        .note-area {
+            flex: 1;
+            overflow-y: auto;
+            padding: 40px;
+        }
+        .note-detail {
+            max-width: 800px;
+            margin: 0 auto;
+        }
+        .note-detail h1 {
+            font-size: 32px;
+            font-weight: 600;
+            margin-bottom: 16px;
+            color: #202124;
+        }
+        .note-detail .meta {
+            font-size: 14px;
+            color: #666;
+            margin-bottom: 24px;
+            padding-bottom: 24px;
+            border-bottom: 1px solid #e0e0e0;
+        }
+        .note-detail .meta span {
+            margin-right: 16px;
+        }
+        .note-detail .tags {
+            display: inline-flex;
+            gap: 8px;
+        }
+        .note-detail .tag {
+            padding: 4px 12px;
+            background: #e8f0fe;
+            color: #1a73e8;
+            border-radius: 16px;
+            font-size: 13px;
+        }
+        .note-content {
+            font-size: 16px;
+            line-height: 1.8;
+            color: #333;
+        }
+        .note-content h1, .note-content h2, .note-content h3 {
+            margin: 24px 0 12px 0;
+            color: #202124;
+        }
+        .note-content h1 { font-size: 28px; }
+        .note-content h2 { font-size: 24px; }
+        .note-content h3 { font-size: 20px; }
+        .note-content p { margin: 12px 0; }
+        .note-content code {
+            background: #f1f3f4;
+            padding: 2px 6px;
+            border-radius: 4px;
+            font-family: 'Monaco', 'Menlo', monospace;
+            font-size: 14px;
+        }
+        .note-content pre {
+            background: #f8f9fa;
+            padding: 16px;
+            border-radius: 8px;
+            overflow-x: auto;
+            margin: 16px 0;
+        }
+        .note-content pre code {
+            background: none;
+            padding: 0;
+        }
+        .note-content blockquote {
+            border-left: 4px solid #1a73e8;
+            padding-left: 16px;
+            margin: 16px 0;
+            color: #555;
+        }
+        .note-content ul, .note-content ol {
+            margin: 12px 0;
+            padding-left: 24px;
+        }
+        .note-content li { margin: 4px 0; }
+        .note-content a { color: #1a73e8; }
+        
+        /* 空状态 */
+        .empty-state {
+            text-align: center;
+            padding: 80px 40px;
+            color: #666;
+        }
+        .empty-state svg {
+            width: 80px;
+            height: 80px;
+            fill: #ddd;
+            margin-bottom: 20px;
+        }
+        .empty-state h2 {
+            font-size: 20px;
+            margin-bottom: 8px;
+            color: #333;
+        }
+        .empty-state p { font-size: 14px; }
+        
+        /* 底部 */
+        footer {
+            text-align: center;
+            padding: 20px;
+            color: #999;
+            font-size: 12px;
+        }
+        footer a { color: #1a73e8; text-decoration: none; }
+        
+        /* Toast */
+        .toast {
+            position: fixed;
+            bottom: 20px;
+            right: 20px;
+            padding: 12px 24px;
+            background: #333;
+            color: #fff;
+            border-radius: 8px;
+            display: none;
+            z-index: 1001;
+        }
+        .toast.show { display: block; }
+        .toast.success { background: #28a745; }
+        .toast.error { background: #dc3545; }
+        
+        /* 响应式 */
+        @media (max-width: 768px) {
+            .sidebar { width: 100%; display: none; }
+            .sidebar.show { display: block; }
+            .content { padding: 20px; }
+            .note-detail h1 { font-size: 24px; }
+        }
+    </style>
+</head>
+<body>
+    <header>
+        <h1>云笔记</h1>
+        <div class="header-right">
+            <div class="search-box">
+                <svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
+                <input type="text" id="searchInput" placeholder="搜索笔记..." onkeyup="handleSearch(event)">
+            </div>
+            <a href="/admin/" style="color: #666; text-decoration: none; font-size: 14px;">管理</a>
+        </div>
+    </header>
+
+    <div class="main">
+        <div class="sidebar">
+            <div class="sidebar-header">
+                <svg viewBox="0 0 24 24" style="width:20px;height:20px;fill:#1976d2;"><path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z"/></svg>
+                <h3>笔记目录</h3>
+            </div>
+            
+            <div class="tree-view" id="treeView">
+                <div style="text-align: center; padding: 40px; color: #999;">加载中...</div>
+            </div>
+            
+            <div class="filter-area">
+                <h4>快速筛选</h4>
+                <div class="filter-item active" onclick="filterAll()">
+                    <svg viewBox="0 0 24 24"><path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z"/></svg>
+                    全部笔记
+                </div>
+                <div class="filter-item" onclick="filterFavorites()">
+                    <svg viewBox="0 0 24 24"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>
+                    收藏笔记
+                </div>
+            </div>
+        </div>
+
+        <div class="content" id="contentArea">
+            <div class="empty-state" id="emptyState">
+                <svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg>
+                <h2>选择一篇笔记</h2>
+                <p>从左侧列表选择笔记查看内容</p>
+            </div>
+            
+            <div class="toc-sidebar" id="tocSidebar" style="display: none;">
+                <div class="toc-title">目录</div>
+                <ul class="toc-list" id="tocList"></ul>
+            </div>
+            
+            <div class="note-area">
+                <div class="note-detail" id="noteDetail" style="display: none;">
+                    <h1 id="noteTitle"></h1>
+                    <div class="meta">
+                        <span id="noteCategory"></span>
+                        <span id="noteDate"></span>
+                        <div class="tags" id="noteTags"></div>
+                    </div>
+                    <div class="note-content" id="noteContent"></div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <footer>
+        <a href="/admin/">管理后台</a>
+    </footer>
+
+    <div class="toast" id="toast"></div>
+
+    <script>
+        const API = '/api';
+        let treeData = [];
+        let flatNotes = [];  // 扁平化的笔记列表(用于筛选)
+        let currentNote = null;
+        let currentFilter = { type: 'all' };
+        let expandedNodes = new Set();
+
+        async function init() {
+            await loadTree();
+        }
+
+        async function loadTree() {
+            try {
+                const res = await fetch(`${API}/tree`);
+                const data = await res.json();
+                if (data.code === 0) {
+                    treeData = data.data;
+                    renderTree();
+                }
+            } catch (err) {
+                document.getElementById('treeView').innerHTML = 
+                    '<div style="text-align: center; padding: 40px; color: #999;">加载失败</div>';
+            }
+        }
+
+        function renderTree() {
+            const container = document.getElementById('treeView');
+            if (treeData.length === 0) {
+                container.innerHTML = '<div style="text-align: center; padding: 40px; color: #999;">暂无笔记</div>';
+                return;
+            }
+            // 构建树形结构
+            const tree = buildTree(treeData);
+            container.innerHTML = tree.map(node => renderTreeNode(node, 0)).join('');
+        }
+        
+        // 构建树形结构
+        function buildTree(items) {
+            const map = {};
+            const roots = [];
+            
+            // 先把所有节点转成有 children 的对象
+            items.forEach(item => {
+                map[item.id] = { ...item, children: [] };
+            });
+            
+            // 再构建父子关系
+            items.forEach(item => {
+                if (item.parent_id && map[item.parent_id]) {
+                    map[item.parent_id].children.push(map[item.id]);
+                } else {
+                    roots.push(map[item.id]);
+                }
+            });
+            
+            return roots;
+        }
+
+        function renderTreeNode(node, level) {
+            const hasChildren = node.children && node.children.length > 0;
+            const isExpanded = expandedNodes.has(node.id);
+            const isActive = currentNote && currentNote.id === node.id;
+            const pinnedClass = node.is_pinned ? 'pinned' : '';
+            
+            let html = `
+                <div class="tree-item">
+                    <div class="tree-node ${isActive ? 'active' : ''} ${pinnedClass}" 
+                         onclick="${node.is_folder ? `toggleNode(${node.id})` : `viewNote(${node.id})`}">
+                        ${hasChildren ? `
+                            <span class="tree-toggle ${isExpanded ? 'expanded' : ''}" id="toggle-${node.id}">
+                                <svg viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
+                            </span>
+                        ` : `
+                            <span class="tree-toggle"></span>
+                        `}
+                        <span class="tree-icon ${node.is_folder ? 'folder' : 'note'}">
+                            ${node.is_folder ? `
+                                <svg viewBox="0 0 24 24"><path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>
+                            ` : `
+                                <svg viewBox="0 0 24 24"><path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/></svg>
+                            `}
+                        </span>
+                        <span class="tree-label">${escapeHtml(node.title)}</span>
+                    </div>
+                    ${hasChildren ? `
+                        <div class="tree-children ${isExpanded ? '' : 'collapsed'}" id="children-${node.id}">
+                            ${node.children.map(child => renderTreeNode(child, level + 1)).join('')}
+                        </div>
+                    ` : ''}
+                </div>
+            `;
+            return html;
+        }
+
+        function toggleNode(id) {
+            if (expandedNodes.has(id)) {
+                expandedNodes.delete(id);
+            } else {
+                expandedNodes.add(id);
+            }
+            
+            const toggle = document.getElementById(`toggle-${id}`);
+            const children = document.getElementById(`children-${id}`);
+            
+            if (toggle) toggle.classList.toggle('expanded');
+            if (children) children.classList.toggle('collapsed');
+        }
+
+        async function viewNote(id) {
+            try {
+                // 先获取笔记信息检查是否有密码
+                const basicRes = await fetch(`${API}/notes/${id}`);
+                const basicData = await basicRes.json();
+                
+                if (basicData.code !== 0) {
+                    showToast(basicData.message || '加载失败', 'error');
+                    return;
+                }
+                
+                const basicNote = basicData.data;
+                
+                // 如果有密码,需要验证
+                if (basicNote.has_password) {
+                    const password = prompt('此笔记需要密码访问,请输入密码:');
+                    if (!password) {
+                        return; // 用户取消
+                    }
+                    
+                    const accessRes = await fetch(`${API}/notes/${id}/access`, {
+                        method: 'POST',
+                        headers: { 'Content-Type': 'application/json' },
+                        body: JSON.stringify({ password: password })
+                    });
+                    const accessData = await accessRes.json();
+                    
+                    if (accessData.code !== 0) {
+                        showToast(accessData.message || '密码错误', 'error');
+                        return;
+                    }
+                    
+                    currentNote = accessData.data;
+                } else {
+                    currentNote = basicNote;
+                }
+                
+                renderTree();  // 重新渲染以更新 active 状态
+                renderNoteDetail();
+            } catch (err) {
+                showToast('加载笔记失败', 'error');
+            }
+        }
+
+        function renderNoteDetail() {
+            if (!currentNote) {
+                document.getElementById('emptyState').style.display = 'block';
+                document.getElementById('noteDetail').style.display = 'none';
+                document.getElementById('tocSidebar').style.display = 'none';
+                return;
+            }
+
+            document.getElementById('emptyState').style.display = 'none';
+            document.getElementById('noteDetail').style.display = 'block';
+            
+            document.getElementById('noteTitle').textContent = currentNote.title;
+            document.getElementById('noteCategory').textContent = currentNote.category || '未分类';
+            document.getElementById('noteDate').textContent = formatDate(currentNote.updated_at);
+            
+            const tags = parseTags(currentNote.tags);
+            document.getElementById('noteTags').innerHTML = tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('');
+            
+            const renderedContent = renderMarkdown(currentNote.content || '');
+            document.getElementById('noteContent').innerHTML = renderedContent;
+            
+            // 生成目录
+            generateTOC(currentNote.content || '');
+        }
+        
+        function generateTOC(content) {
+            const tocList = document.getElementById('tocList');
+            const tocSidebar = document.getElementById('tocSidebar');
+            
+            // 提取标题
+            const headingRegex = /^(#{1,3})\s+(.+)$/gm;
+            const headings = [];
+            let match;
+            while ((match = headingRegex.exec(content)) !== null) {
+                const level = match[1].length;
+                const text = match[2].trim();
+                const id = text.toLowerCase().replace(/[^\w\u4e00-\u9fa5]+/g, '-');
+                headings.push({ level, text, id });
+            }
+            
+            if (headings.length === 0) {
+                tocSidebar.style.display = 'none';
+                return;
+            }
+            
+            tocSidebar.style.display = 'block';
+            tocList.innerHTML = headings.map(h => 
+                `<li class="toc-item">
+                    <a href="#${h.id}" class="toc-link level-${h.level}" onclick="scrollToHeading('${h.id}')">${escapeHtml(h.text)}</a>
+                </li>`
+            ).join('');
+        }
+        
+        function scrollToHeading(id) {
+            const el = document.getElementById(id);
+            if (el) {
+                el.scrollIntoView({ behavior: 'smooth', block: 'start' });
+            }
+        }
+
+        async function filterAll() {
+            currentFilter = { type: 'all' };
+            updateFilterUI();
+            await loadTree();
+        }
+
+        async function filterFavorites() {
+            currentFilter = { type: 'favorites' };
+            updateFilterUI();
+            try {
+                const res = await fetch(`${API}/notes?favorite=true&page_size=100`);
+                const data = await res.json();
+                if (data.code === 0) {
+                    flatNotes = data.data || [];
+                    renderFlatNotes();
+                }
+            } catch (err) {
+                showToast('加载失败', 'error');
+            }
+        }
+
+        function renderFlatNotes() {
+            const container = document.getElementById('treeView');
+            if (flatNotes.length === 0) {
+                container.innerHTML = '<div style="text-align: center; padding: 40px; color: #999;">暂无收藏笔记</div>';
+                return;
+            }
+            
+            flatNotes.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at));
+            
+            container.innerHTML = flatNotes.map(note => {
+                const tags = parseTags(note.tags);
+                const isActive = currentNote && currentNote.id === note.id;
+                return `
+                    <div class="tree-item">
+                        <div class="tree-node ${isActive ? 'active' : ''} ${note.is_pinned ? 'pinned' : ''}" 
+                             onclick="viewNote(${note.id})">
+                            <span class="tree-toggle"></span>
+                            <span class="tree-icon note">
+                                <svg viewBox="0 0 24 24"><path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/></svg>
+                            </span>
+                            <span class="tree-label">${escapeHtml(note.title)}</span>
+                            ${note.is_pinned ? '<span style="color:#f57c00;font-size:11px;">📌</span>' : ''}
+                        </div>
+                    </div>
+                `;
+            }).join('');
+        }
+
+        function updateFilterUI() {
+            document.querySelectorAll('.filter-item').forEach(el => el.classList.remove('active'));
+            const items = document.querySelectorAll('.filter-item');
+            if (currentFilter.type === 'all') {
+                items[0].classList.add('active');
+            } else if (currentFilter.type === 'favorites') {
+                items[1].classList.add('active');
+            }
+        }
+
+        async function handleSearch(event) {
+            if (event.key !== 'Enter') return;
+            const keyword = event.target.value.trim();
+            if (!keyword) {
+                await loadTree();
+                return;
+            }
+
+            try {
+                const res = await fetch(`${API}/notes/search?q=${encodeURIComponent(keyword)}`);
+                const data = await res.json();
+                if (data.code === 0) {
+                    flatNotes = data.data || [];
+                    renderFlatNotes();
+                }
+            } catch (err) {
+                showToast('搜索失败', 'error');
+            }
+        }
+
+        function parseTags(tagsJson) {
+            if (!tagsJson) return [];
+            try { return JSON.parse(tagsJson); } catch { return []; }
+        }
+
+        function escapeHtml(text) {
+            if (!text) return '';
+            const div = document.createElement('div');
+            div.textContent = text;
+            return div.innerHTML;
+        }
+
+        function formatDate(dateStr) {
+            return new Date(dateStr).toLocaleDateString('zh-CN', {
+                year: 'numeric', month: 'long', day: 'numeric'
+            });
+        }
+
+        function showToast(message, type = 'info') {
+            const toast = document.getElementById('toast');
+            toast.textContent = message;
+            toast.className = `toast show ${type}`;
+            setTimeout(() => toast.classList.remove('show'), 3000);
+        }
+
+        // 简单的 Markdown 渲染
+        function renderMarkdown(text) {
+            if (!text) return '<p style="color: #999;">无内容</p>';
+            
+            // 先处理代码块,保留语言标识
+            const codeBlockRegex = /```(\w*)\n?([\s\S]*?)```/g;
+            const codeBlocks = [];
+            let index = 0;
+            
+            text = text.replace(codeBlockRegex, (match, lang, code) => {
+                const placeholder = `__CODE_BLOCK_${index}__`;
+                codeBlocks.push({ lang: lang || 'plaintext', code: code.trim() });
+                index++;
+                return placeholder;
+            });
+            
+            // 在转义之前处理图片(必须先于链接处理)
+            // 图片: ![alt](url)  链接: [text](url)
+            // 先用占位符保护图片,再处理链接
+            const imgPlaceholders = [];
+            text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function(match, alt, src) {
+                const placeholder = '__IMG_' + imgPlaceholders.length + '__';
+                imgPlaceholders.push('<img src="' + src + '" alt="' + alt + '" style="max-width: 100%; border-radius: 4px;">');
+                return placeholder;
+            });
+            
+            // 处理链接
+            text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
+            
+            // 恢复图片
+            imgPlaceholders.forEach(function(img, i) {
+                text = text.replace('__IMG_' + i + '__', img);
+            });
+            
+            // 转义 HTML(处理代码块内的内容和其他文本)
+            text = escapeHtml(text);
+            
+            // 处理行内代码
+            text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
+            
+            // 恢复代码块并应用高亮
+            codeBlocks.forEach((block, i) => {
+                const highlighted = hljs.highlightAuto(block.code, block.lang !== 'plaintext' ? [block.lang] : undefined).value;
+                text = text.replace(
+                    `__CODE_BLOCK_${i}__`,
+                    `<pre><code class="hljs language-${block.lang}">${highlighted}</code></pre>`
+                );
+            });
+            
+            // 标题(带 id 用于目录导航)
+            text = text.replace(/^### (.+)$/gm, (match, content) => {
+                const id = content.toLowerCase().replace(/[^\w\u4e00-\u9fa5]+/g, '-');
+                return `<h3 id="${id}">${content}</h3>`;
+            });
+            text = text.replace(/^## (.+)$/gm, (match, content) => {
+                const id = content.toLowerCase().replace(/[^\w\u4e00-\u9fa5]+/g, '-');
+                return `<h2 id="${id}">${content}</h2>`;
+            });
+            text = text.replace(/^# (.+)$/gm, (match, content) => {
+                const id = content.toLowerCase().replace(/[^\w\u4e00-\u9fa5]+/g, '-');
+                return `<h1 id="${id}">${content}</h1>`;
+            });
+            
+            // 粗体斜体
+            text = text.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
+            text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
+            text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
+            
+            // 删除线
+            text = text.replace(/~~(.+?)~~/g, '<del>$1</del>');
+            
+            // 引用
+            text = text.replace(/^> (.*$)/gm, '<blockquote>$1</blockquote>');
+            
+            // 无序列表
+            text = text.replace(/^[\-\*] (.*$)/gm, '<li>$1</li>');
+            text = text.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
+            
+            // 有序列表
+            text = text.replace(/^\d+\. (.*$)/gm, '<li>$1</li>');
+            
+            // 换行
+            text = text.replace(/\n\n/g, '</p><p>');
+            text = text.replace(/\n/g, '<br>');
+            
+            return `<p>${text}</p>`;
+        }
+
+        init();
+    </script>
+</body>
+</html>