feat: 初始化云笔记项目

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

技术栈:
- Go + Gin + GORM + SQLite
- 原生 HTML/CSS/JavaScript
- Highlight.js
This commit is contained in:
Note Manager
2026-05-08 15:07:22 +08:00
commit c8f03dd932
19 fájl változott, egészen pontosan 4333 új sor hozzáadva és 0 régi sor törölve
+14
Fájl megtekintése
@@ -0,0 +1,14 @@
# 二进制文件
note-manager
# 数据目录
data/
uploads/
# IDE
.idea/
.vscode/
*.swp
# 系统文件
.DS_Store
+585
Fájl megtekintése
@@ -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
Fájl megtekintése
@@ -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
Fájl megtekintése
@@ -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
Fájl megtekintése
@@ -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
Fájl megtekintése
@@ -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
Fájl megtekintése
@@ -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
Fájl megtekintése
@@ -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
Fájl megtekintése
@@ -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
Fájl megtekintése
@@ -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
Fájl megtekintése
@@ -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
Fájl megtekintése
@@ -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
Fájl megtekintése
@@ -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"` // 父级目录 ID0 表示根目录
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
Fájl megtekintése
@@ -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 // 父目录 IDnil 表示所有
}
// 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
Fájl megtekintése
@@ -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
Fájl megtekintése
@@ -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
}
A különbségek nem kerülnek megjelenítésre, mivel a fájl túl nagy Load Diff
+140
Fájl megtekintése
@@ -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
Fájl megtekintése
@@ -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>