commit c8f03dd9328b8d66329a11c7249df315d12019c7 Author: Note Manager Date: Fri May 8 15:07:22 2026 +0800 feat: 初始化云笔记项目 功能特性: - Markdown 编辑与实时预览 - 代码语法高亮 - 目录树形结构管理 - 图片粘贴上传 - Markdown 文件导入导出 - 笔记密码保护 - 前后端分离架构 技术栈: - Go + Gin + GORM + SQLite - 原生 HTML/CSS/JavaScript - Highlight.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d5866ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# 二进制文件 +note-manager + +# 数据目录 +data/ +uploads/ + +# IDE +.idea/ +.vscode/ +*.swp + +# 系统文件 +.DS_Store diff --git a/API.md b/API.md new file mode 100644 index 0000000..2ac2c1b --- /dev/null +++ b/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' +}); +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..a60c33b --- /dev/null +++ b/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 diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..e3ce41b --- /dev/null +++ b/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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4642c85 --- /dev/null +++ b/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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3505b8c --- /dev/null +++ b/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= diff --git a/handler/admin_handler.go b/handler/admin_handler.go new file mode 100644 index 0000000..e5d7f8d --- /dev/null +++ b/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": "笔记管理后台", + }) +} diff --git a/handler/image_handler.go b/handler/image_handler.go new file mode 100644 index 0000000..737640e --- /dev/null +++ b/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) +} diff --git a/handler/note_handler.go b/handler/note_handler.go new file mode 100644 index 0000000..6a405b3 --- /dev/null +++ b/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) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..02f9d39 --- /dev/null +++ b/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) + } +} diff --git a/middleware/auth.go b/middleware/auth.go new file mode 100644 index 0000000..4716a3f --- /dev/null +++ b/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 +} diff --git a/middleware/cors.go b/middleware/cors.go new file mode 100644 index 0000000..14081b8 --- /dev/null +++ b/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() + } +} diff --git a/model/note.go b/model/note.go new file mode 100644 index 0000000..c5f4a45 --- /dev/null +++ b/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"` +} diff --git a/repository/note_repository.go b/repository/note_repository.go new file mode 100644 index 0000000..9105c33 --- /dev/null +++ b/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(¬e, id).Error + if err != nil { + return nil, err + } + return ¬e, 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 +} diff --git a/router/router.go b/router/router.go new file mode 100644 index 0000000..4fd617b --- /dev/null +++ b/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 +} diff --git a/service/note_service.go b/service/note_service.go new file mode 100644 index 0000000..0aedf16 --- /dev/null +++ b/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 +} diff --git a/web/admin/index.html b/web/admin/index.html new file mode 100644 index 0000000..445efac --- /dev/null +++ b/web/admin/index.html @@ -0,0 +1,1036 @@ + + + + + + 笔记管理后台 + + + + + + +
+

笔记管理后台

+
+ 前台预览 + +
+
+ +
+ + + + +
+ +
+ +

选择一个笔记或目录

+

从左侧列表选择,或点击 + 新建

+
+ + + +
+
+ + + + + + + +
+ + + + diff --git a/web/admin/login.html b/web/admin/login.html new file mode 100644 index 0000000..48ede71 --- /dev/null +++ b/web/admin/login.html @@ -0,0 +1,140 @@ + + + + + + 后台管理登录 - 云笔记 + + + +
+

后台管理登录

+
+
+ + +
+ +

+
+ 返回前台 +
+ + + + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..0321cc4 --- /dev/null +++ b/web/index.html @@ -0,0 +1,896 @@ + + + + + + 云笔记 + + + + + + +
+

云笔记

+
+ + 管理 +
+
+ +
+ + +
+
+ +

选择一篇笔记

+

从左侧列表选择笔记查看内容

+
+ + + +
+ +
+
+
+ + + +
+ + + +