feat: 初始化云笔记项目
功能特性: - Markdown 编辑与实时预览 - 代码语法高亮 - 目录树形结构管理 - 图片粘贴上传 - Markdown 文件导入导出 - 笔记密码保护 - 前后端分离架构 技术栈: - Go + Gin + GORM + SQLite - 原生 HTML/CSS/JavaScript - Highlight.js
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
# 二进制文件
|
||||
note-manager
|
||||
|
||||
# 数据目录
|
||||
data/
|
||||
uploads/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
# 系统文件
|
||||
.DS_Store
|
||||
+585
@@ -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'
|
||||
});
|
||||
```
|
||||
@@ -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)
|
||||

|
||||
```
|
||||
|
||||
## API 文档
|
||||
|
||||
详见 [API.md](./API.md)
|
||||
|
||||
## 截图预览
|
||||
|
||||
### 后台编辑器
|
||||
- 左右分栏布局
|
||||
- 实时 Markdown 预览
|
||||
- 代码语法高亮
|
||||
- 树形目录导航
|
||||
|
||||
### 前台展示
|
||||
- 简洁阅读体验
|
||||
- 响应式设计
|
||||
- Markdown 渲染
|
||||
- 分类筛选
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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": "笔记管理后台",
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
// 在转义之前处理图片(必须先于链接处理)
|
||||
// 图片:  链接: [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>
|
||||
Reference in New Issue
Block a user