feat: 云笔记增强 - 安全加固 + 回收站/版本历史/分享/批量导出 + 前端优化

- 安全: 认证改随机token会话(弃固定cookie), 笔记密码SHA256升级为bcrypt(自动迁移),
  堵住GET /api/notes/:id泄露带密码笔记, CORS收紧+SameSite防CSRF, 上传图片内容嗅探
- 回收站: 软删除(deleted_at), 列表/恢复/彻底删除/清空, 目录子树连删连恢复
- 版本历史: note_versions表存快照, 每次保存自动留档, 支持查看/回滚
- 分享: 生成随机token分享链接, 支持过期时间, 公开阅读页share.html
- 批量导出: 全部笔记打包zip(按目录结构+front matter)
- 前端: 深色模式, Mermaid图表, 待办清单checkbox, 字数统计;
  后台新增回收站/历史/分享面板和批量导出按钮
- 新增deploy/note-manager.service systemd单元与smoke_test.py
This commit is contained in:
Your Name
2026-08-11 11:21:29 +08:00
parent f0eb822e68
commit 74fa759274
18 changed files with 1968 additions and 228 deletions
+73
View File
@@ -583,3 +583,76 @@ await fetch('/api/notes', {
credentials: 'include' credentials: 'include'
}); });
``` ```
---
# 新增功能 APIv2
## 管理接口认证(安全优化)
后台管理写操作(创建/更新/删除/回收站/版本/分享/导入导出/上传)统一走 `/admin/api`
需登录后携带服务端会话 cookie(随机 token,非固定字符串)。未登录返回 401。
## 安全策略调整
### 公开接口 `GET /api/notes/:id`
仅返回 **公开且未设置密码** 的笔记完整内容。带密码或未公开的笔记返回 403(需走密码验证接口),
防止绕过密码保护直接读取内容。
## 回收站(软删除)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/admin/api/trash` | 回收站列表(含已删除的目录和笔记) |
| POST | `/admin/api/restore/:id` | 恢复笔记/目录(目录连子树一起恢复) |
| POST | `/admin/api/purge/:id` | 彻底删除(不可恢复,含版本历史) |
| POST | `/admin/api/empty-trash` | 清空回收站 |
删除笔记 `DELETE /admin/api/notes/:id` 现为软删除(进入回收站),目录删除会连带软删除所有子项。
## 版本历史
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/admin/api/notes/:id/versions` | 获取笔记全部历史版本 |
| POST | `/admin/api/notes/:id/restore-version` | 恢复指定版本(form: `version_id` |
每次保存(标题或内容变化)自动生成快照。
## 分享链接
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/admin/api/notes/:id/share` | 创建分享(form: `expire_hours`0=永久) |
| POST | `/admin/api/notes/:id/revoke-share` | 撤销分享 |
| GET | `/api/share/:token` | 公开获取分享笔记 JSON(有密码需带 `?password=` |
| GET | `/share/:token` | 分享阅读页(HTML) |
## 批量导出
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/admin/api/export-all` | 全部笔记导出为 zip(按目录结构 + YAML front matter |
## 图片上传(安全增强)
上传会做**内容嗅探**(magic bytes 校验),不仅检查扩展名。伪装成图片的脚本会被拒绝。
## 后台管理笔记接口
管理端读取/写笔记统一走 `/admin/api/notes`(可访问私有、带密码笔记):
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/admin/api/notes` | 创建 |
| GET | `/admin/api/notes/:id` | 详情(完整内容) |
| PUT | `/admin/api/notes/:id` | 更新 |
| DELETE | `/admin/api/notes/:id` | 软删除 |
## 前端新增功能
- **深色模式**:前台/后台均可切换(🌙/☀️ 按钮),偏好存 localStorage
- **Mermaid 图表**:前台 Markdown 中 ` ```mermaid ` 代码块渲染为流程图/时序图等
- **待办清单**:前台渲染 `- [ ]` / `- [x]` 可勾选清单
- **字数统计**:前台笔记详情显示字数与预估阅读时长
- **回收站/版本历史/分享**:后台工具栏按钮 + 面板
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=Note Manager (Cloud Notes)
After=network.target
[Service]
Type=simple
WorkingDirectory=/fs/1000/ftp/Project/note-manager
ExecStart=/fs/1000/ftp/Project/note-manager/dist/note-manager
Environment=PORT=8080
Environment=DB_PATH=/fs/1000/ftp/Project/note-manager/data/notes.db
Environment=UPLOAD_DIR=/fs/1000/ftp/Project/note-manager/uploads
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
+12 -9
View File
@@ -1,13 +1,19 @@
module note-manager module note-manager
go 1.21.0 go 1.25.0
require (
github.com/gabriel-vasile/mimetype v1.4.2
github.com/gin-gonic/gin v1.9.1
golang.org/x/crypto v0.54.0
gorm.io/driver/sqlite v1.5.6
gorm.io/gorm v1.25.7
)
require ( require (
github.com/bytedance/sonic v1.9.1 // indirect github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // 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-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/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect
@@ -25,12 +31,9 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.9.0 // indirect golang.org/x/net v0.56.0 // indirect
golang.org/x/net v0.10.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/sys v0.8.0 // indirect golang.org/x/text v0.40.0 // indirect
golang.org/x/text v0.9.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/sqlite v1.5.6 // indirect
gorm.io/gorm v1.25.7 // indirect
) )
+8
View File
@@ -65,14 +65,22 @@ 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/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 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= 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/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= 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/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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.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 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
+13 -8
View File
@@ -5,6 +5,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"note-manager/config" "note-manager/config"
"note-manager/middleware"
"note-manager/service" "note-manager/service"
) )
@@ -19,7 +20,7 @@ func NewAdminHandler(noteSvc *service.NoteService, cfg *config.Config) *AdminHan
return &AdminHandler{noteSvc: noteSvc, config: cfg} return &AdminHandler{noteSvc: noteSvc, config: cfg}
} }
// Login 登录页面 // LoginPage 登录页面
func (h *AdminHandler) LoginPage(c *gin.Context) { func (h *AdminHandler) LoginPage(c *gin.Context) {
c.HTML(http.StatusOK, "login.html", gin.H{ c.HTML(http.StatusOK, "login.html", gin.H{
"title": "后台管理登录", "title": "后台管理登录",
@@ -30,8 +31,11 @@ func (h *AdminHandler) LoginPage(c *gin.Context) {
func (h *AdminHandler) Login(c *gin.Context) { func (h *AdminHandler) Login(c *gin.Context) {
password := c.PostForm("password") password := c.PostForm("password")
if password == h.config.AdminPass { if password == h.config.AdminPass {
// 设置 cookie,有效期 7 天 // 生成随机会话 token
c.SetCookie("admin_token", "authenticated", 7*24*3600, "/", "", false, true) token, _ := middleware.NewSessionToken()
// 通过环境变量判断是否启用 HTTPS(生产建议配置)
secure := c.Request.TLS != nil
middleware.SetAuthCookie(c, token, secure)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"code": 0, "code": 0,
"message": "登录成功", "message": "登录成功",
@@ -46,7 +50,8 @@ func (h *AdminHandler) Login(c *gin.Context) {
// Logout 登出 // Logout 登出
func (h *AdminHandler) Logout(c *gin.Context) { func (h *AdminHandler) Logout(c *gin.Context) {
c.SetCookie("admin_token", "", -1, "/", "", false, true) middleware.RevokeSession(middleware.GetAuthToken(c))
middleware.ClearAuthCookie(c)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"code": 0, "code": 0,
"message": "已退出登录", "message": "已退出登录",
@@ -55,8 +60,8 @@ func (h *AdminHandler) Logout(c *gin.Context) {
// CheckAuth 检查是否已登录 // CheckAuth 检查是否已登录
func (h *AdminHandler) CheckAuth(c *gin.Context) { func (h *AdminHandler) CheckAuth(c *gin.Context) {
token, err := c.Cookie("admin_token") token := middleware.GetAuthToken(c)
if err == nil && token == "authenticated" { if middleware.IsValidSession(token) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"code": 0, "code": 0,
"message": "已登录", "message": "已登录",
@@ -77,8 +82,8 @@ func (h *AdminHandler) CheckAuth(c *gin.Context) {
// IndexPage 后台管理首页 // IndexPage 后台管理首页
func (h *AdminHandler) IndexPage(c *gin.Context) { func (h *AdminHandler) IndexPage(c *gin.Context) {
token, err := c.Cookie("admin_token") token := middleware.GetAuthToken(c)
if err != nil || token != "authenticated" { if !middleware.IsValidSession(token) {
c.Redirect(http.StatusFound, "/admin/login") c.Redirect(http.StatusFound, "/admin/login")
return return
} }
+67 -26
View File
@@ -1,6 +1,8 @@
package handler package handler
import ( import (
"crypto/rand"
"encoding/hex"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
@@ -8,6 +10,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/gabriel-vasile/mimetype"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -26,6 +29,27 @@ func (h *ImageHandler) Init() error {
return os.MkdirAll(h.uploadDir, 0755) return os.MkdirAll(h.uploadDir, 0755)
} }
// 允许的图片 MIME 类型(基于内容嗅探,而非仅扩展名)
var allowedImageTypes = map[string]bool{
"image/jpeg": true,
"image/png": true,
"image/gif": true,
"image/webp": true,
"image/bmp": true,
"image/svg+xml": false, // SVG 可含脚本,默认禁止,避免 XSS
"image/x-icon": false,
}
// allowedExts 扩展名白名单(与内容嗅探双重校验)
var allowedExts = map[string]bool{
".jpg": true,
".jpeg": true,
".png": true,
".gif": true,
".webp": true,
".bmp": true,
}
// Upload 上传图片 // Upload 上传图片
func (h *ImageHandler) Upload(c *gin.Context) { func (h *ImageHandler) Upload(c *gin.Context) {
file, err := c.FormFile("image") file, err := c.FormFile("image")
@@ -34,19 +58,10 @@ func (h *ImageHandler) Upload(c *gin.Context) {
return return
} }
// 验证文件类型 // 验证扩展名
ext := strings.ToLower(filepath.Ext(file.Filename)) 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] { if !allowedExts[ext] {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "不支持的图片格式,仅支持 jpg、png、gif、webp"}) c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "不支持的图片格式,仅支持 jpg、png、gif、webp、bmp"})
return return
} }
@@ -56,34 +71,60 @@ func (h *ImageHandler) Upload(c *gin.Context) {
return return
} }
// 生成唯一文件名 // 打开文件做内容嗅探,防止伪造扩展名上传恶意内容
filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomString(8), ext) src, err := file.Open()
filepath := filepath.Join(h.uploadDir, filename) if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "读取文件失败"})
return
}
defer src.Close()
head := make([]byte, 512)
if _, err := src.Read(head); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "读取文件失败"})
return
}
mime := mimetype.Detect(head)
if !allowedImageTypes[mime.String()] && mime.String() != "" {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "文件内容不是合法图片"})
return
}
// 生成唯一文件名(使用安全随机源)
filename := fmt.Sprintf("%d_%s%s", osTimeNano(), secureRandomString(8), ext)
targetPath := filepath.Join(h.uploadDir, filename)
// 保存文件 // 保存文件
if err := c.SaveUploadedFile(file, filepath); err != nil { if err := c.SaveUploadedFile(file, targetPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "保存图片失败"}) c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "保存图片失败"})
return return
} }
// 返回访问 URL // 返回访问 URL
url := "/uploads/" + filename
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"code": 0, "code": 0,
"message": "上传成功", "message": "上传成功",
"data": gin.H{ "data": gin.H{
"url": url, "url": "/uploads/" + filename,
"mime": mime.String(),
"width": 0,
"height": 0,
}, },
}) })
} }
// randomString 生成随机字符串 // osTimeNano 返回当前纳秒时间戳
func randomString(length int) string { func osTimeNano() int64 {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" return time.Now().UnixNano()
result := make([]byte, length) }
for i := range result {
result[i] = chars[time.Now().UnixNano()%int64(len(chars))] // secureRandomString 使用 crypto/rand 生成安全的随机十六进制字符串
time.Sleep(time.Nanosecond) func secureRandomString(bytesLen int) string {
} b := make([]byte, bytesLen)
return string(result) if _, err := rand.Read(b); err != nil {
// 兜底(几乎不会发生)
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
} }
+309 -54
View File
@@ -1,11 +1,16 @@
package handler package handler
import ( import (
"archive/zip"
"encoding/json"
"fmt"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"note-manager/model" "note-manager/model"
@@ -48,22 +53,8 @@ func fail(c *gin.Context, status int, msg string) {
c.JSON(status, Response{Code: -1, Message: msg}) 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 创建笔记 // CreateNote 创建笔记
func (h *NoteHandler) CreateNote(c *gin.Context) { func (h *NoteHandler) CreateNote(c *gin.Context) {
if !requireAuth(c) {
return
}
var req model.NoteCreateRequest var req model.NoteCreateRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求参数错误: "+err.Error()) fail(c, http.StatusBadRequest, "请求参数错误: "+err.Error())
@@ -79,7 +70,9 @@ func (h *NoteHandler) CreateNote(c *gin.Context) {
success(c, note) success(c, note)
} }
// GetNote 获取笔记详情 // GetNote 获取笔记详情(公开只读接口)
// 安全策略:仅允许返回「公开且无密码」的笔记。有密码或未公开的笔记一律返回需授权提示,
// 防止通过该接口绕过密码保护读取内容。
func (h *NoteHandler) GetNote(c *gin.Context) { func (h *NoteHandler) GetNote(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
@@ -93,6 +86,29 @@ func (h *NoteHandler) GetNote(c *gin.Context) {
return return
} }
// 公开接口白名单:仅公开且无密码的笔记返回完整内容
if !note.IsPublic || note.Password != "" {
fail(c, http.StatusForbidden, "该笔记受保护,无法直接访问")
return
}
success(c, note)
}
// GetAdminNote 获取笔记详情(管理后台用,返回完整内容)
func (h *NoteHandler) GetAdminNote(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) success(c, note)
} }
@@ -112,7 +128,7 @@ func (h *NoteHandler) AccessNote(c *gin.Context) {
req.Password = c.Query("password") req.Password = c.Query("password")
} }
note, err := h.svc.GetNoteContent(uint(id), req.Password) note, upgrade, err := h.svc.GetNoteContent(uint(id), req.Password)
if err != nil { if err != nil {
if err.Error() == "密码错误" { if err.Error() == "密码错误" {
fail(c, http.StatusUnauthorized, err.Error()) fail(c, http.StatusUnauthorized, err.Error())
@@ -122,15 +138,16 @@ func (h *NoteHandler) AccessNote(c *gin.Context) {
return return
} }
// 若旧 SHA-256 哈希命中,自动升级为 bcrypt
if upgrade {
_ = h.svc.UpgradePasswordHash(note.ID, req.Password)
}
success(c, note) success(c, note)
} }
// UpdateNote 更新笔记 // UpdateNote 更新笔记
func (h *NoteHandler) UpdateNote(c *gin.Context) { func (h *NoteHandler) UpdateNote(c *gin.Context) {
if !requireAuth(c) {
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID") fail(c, http.StatusBadRequest, "无效的笔记 ID")
@@ -152,12 +169,8 @@ func (h *NoteHandler) UpdateNote(c *gin.Context) {
success(c, note) success(c, note)
} }
// DeleteNote 删除笔记 // DeleteNote 删除笔记(软删除,进入回收站)
func (h *NoteHandler) DeleteNote(c *gin.Context) { func (h *NoteHandler) DeleteNote(c *gin.Context) {
if !requireAuth(c) {
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID") fail(c, http.StatusBadRequest, "无效的笔记 ID")
@@ -287,10 +300,168 @@ func parseIntDefault(s string, defaultVal int) int {
return v return v
} }
// ─────────────── 回收站 ───────────────
// ListTrash 回收站列表
func (h *NoteHandler) ListTrash(c *gin.Context) {
items, err := h.svc.ListTrash()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, items)
}
// RestoreNote 恢复笔记
func (h *NoteHandler) RestoreNote(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
if err := h.svc.RestoreNote(uint(id)); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, nil)
}
// PurgeNote 彻底删除笔记
func (h *NoteHandler) PurgeNote(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
if err := h.svc.PurgeNote(uint(id)); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, nil)
}
// EmptyTrash 清空回收站
func (h *NoteHandler) EmptyTrash(c *gin.Context) {
if err := h.svc.EmptyTrash(); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, nil)
}
// ─────────────── 版本历史 ───────────────
// ListVersions 版本列表
func (h *NoteHandler) ListVersions(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
versions, err := h.svc.ListVersions(uint(id))
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, versions)
}
// RestoreVersion 恢复到指定版本
func (h *NoteHandler) RestoreVersion(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
versionID, err := strconv.ParseUint(c.PostForm("version_id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的版本 ID")
return
}
note, err := h.svc.RestoreVersion(uint(id), uint(versionID))
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, note)
}
// ─────────────── 分享 ───────────────
// CreateShare 创建分享
func (h *NoteHandler) CreateShare(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
expireHours, _ := strconv.Atoi(c.DefaultPostForm("expire_hours", "0"))
note, err := h.svc.CreateShare(uint(id), expireHours)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, gin.H{
"id": note.ID,
"share_token": note.ShareToken,
"url": "/share/" + note.ShareToken,
"expire_at": note.ShareExpireAt,
})
}
// RevokeShare 撤销分享
func (h *NoteHandler) RevokeShare(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
if err := h.svc.RevokeShare(uint(id)); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, nil)
}
// GetSharedNote 通过令牌获取分享笔记的 JSON 接口
// 分享的笔记若设置了访问密码,则需通过 password 参数/请求体校验
func (h *NoteHandler) GetSharedNote(c *gin.Context) {
token := c.Param("token")
var req struct {
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&req); err != nil {
req.Password = c.Query("password")
}
note, err := h.svc.GetSharedNote(token)
if err != nil {
fail(c, http.StatusNotFound, err.Error())
return
}
// 若分享的笔记有访问密码,校验
if note.Password != "" {
ok, _ := model.CheckPassword(req.Password, note.Password)
if !ok {
fail(c, http.StatusUnauthorized, "密码错误")
return
}
}
success(c, note)
}
// SharePage 分享阅读页(简单 HTML,展示分享的笔记内容)
func (h *NoteHandler) SharePage(c *gin.Context) {
c.HTML(http.StatusOK, "share.html", gin.H{
"token": c.Param("token"),
})
}
// ExportNote 导出笔记为 Markdown 文件 // ExportNote 导出笔记为 Markdown 文件
func (h *NoteHandler) ExportNote(c *gin.Context) { func (h *NoteHandler) ExportNote(c *gin.Context) {
idStr := c.Param("id") id, err := strconv.ParseUint(c.Param("id"), 10, 64)
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil { if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID") fail(c, http.StatusBadRequest, "无效的笔记 ID")
return return
@@ -302,55 +473,103 @@ func (h *NoteHandler) ExportNote(c *gin.Context) {
return return
} }
// 如果是目录,导出目录下所有笔记 // 构造 Markdown(带 YAML front matter
if note.IsFolder { content := buildMarkdown(note)
fail(c, http.StatusBadRequest, "不支持导出目录,请选择具体笔记")
filename := sanitizeFileName(note.Title) + ".md"
c.Header("Content-Disposition", "attachment; filename=\""+filename+"\"")
c.Header("Content-Type", "text/markdown; charset=utf-8")
c.String(http.StatusOK, content)
}
// ExportAll 批量导出全部笔记为 zip
func (h *NoteHandler) ExportAll(c *gin.Context) {
tree, err := h.svc.GetAllTree()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return return
} }
// 设置下载头 // 构建节点 map 与根节点列表
filename := note.Title + ".md" type node struct {
c.Header("Content-Disposition", "attachment; filename*=UTF-8''"+urlEncode(filename)) item model.NoteListItem
c.Header("Content-Type", "text/markdown; charset=utf-8") children []*node
}
// 添加 front matter nodeMap := make(map[uint]*node)
frontMatter := "---\n" for i := range tree {
frontMatter += "title: " + note.Title + "\n" nodeMap[tree[i].ID] = &node{item: tree[i]}
if note.Category != "" { }
frontMatter += "category: " + note.Category + "\n" var roots []*node
for i := range tree {
n := nodeMap[tree[i].ID]
if tree[i].ParentID == 0 {
roots = append(roots, n)
} else if p, ok := nodeMap[tree[i].ParentID]; ok {
p.children = append(p.children, n)
} else {
roots = append(roots, n)
} }
if note.Tags != "" {
frontMatter += "tags: " + note.Tags + "\n"
} }
frontMatter += "---\n\n"
c.String(http.StatusOK, frontMatter+note.Content) c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", "attachment; filename=notes-export.zip")
zw := zip.NewWriter(c.Writer)
defer zw.Close()
var zipNotes func(n *node, path string) error
zipNotes = func(n *node, path string) error {
if n.item.IsFolder {
dirPath := filepath.Join(path, sanitizeFileName(n.item.Title))
for _, ch := range n.children {
if err := zipNotes(ch, dirPath); err != nil {
return err
}
}
return nil
}
note, err := h.svc.GetNote(n.item.ID)
if err != nil {
return err
}
body := buildMarkdown(note)
fname := filepath.Join(path, sanitizeFileName(note.Title)+".md")
fw, err := zw.Create(fname)
if err != nil {
return err
}
_, err = fw.Write([]byte(body))
return err
}
for _, root := range roots {
if err := zipNotes(root, ""); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
}
} }
// ImportNotes 导入 Markdown 文件创建笔记 // ImportNotes 导入 Markdown 文件
func (h *NoteHandler) ImportNotes(c *gin.Context) { func (h *NoteHandler) ImportNotes(c *gin.Context) {
file, err := c.FormFile("file") file, err := c.FormFile("file")
if err != nil { if err != nil {
fail(c, http.StatusBadRequest, "请选择文件") fail(c, http.StatusBadRequest, "请选择要导入的文件")
return return
} }
// 验证文件类型 if !strings.HasSuffix(strings.ToLower(file.Filename), ".md") {
if file.Header.Get("Content-Type") != "text/markdown" && fail(c, http.StatusBadRequest, "仅支持导入 .md 文件")
!strings.HasSuffix(file.Filename, ".md") {
fail(c, http.StatusBadRequest, "仅支持 .md 文件")
return return
} }
// 读取文件内容 // 读取文件内容
f, err := file.Open() src, err := file.Open()
if err != nil { if err != nil {
fail(c, http.StatusInternalServerError, "读取文件失败") fail(c, http.StatusInternalServerError, "读取文件失败")
return return
} }
defer f.Close() defer src.Close()
contentBytes, err := io.ReadAll(src)
contentBytes, err := io.ReadAll(f)
if err != nil { if err != nil {
fail(c, http.StatusInternalServerError, "读取文件失败") fail(c, http.StatusInternalServerError, "读取文件失败")
return return
@@ -406,3 +625,39 @@ func parseFrontMatter(content string) (title string, body string) {
func urlEncode(s string) string { func urlEncode(s string) string {
return url.QueryEscape(s) return url.QueryEscape(s)
} }
// buildMarkdown 将笔记构造为带 YAML front matter 的 Markdown
func buildMarkdown(note *model.Note) string {
var b strings.Builder
b.WriteString("---\n")
b.WriteString("title: " + note.Title + "\n")
if note.Category != "" {
b.WriteString("category: " + note.Category + "\n")
}
// tags 是 JSON 数组字符串,转成 YAML 列表
if note.Tags != "" {
var tags []string
if err := json.Unmarshal([]byte(note.Tags), &tags); err == nil && len(tags) > 0 {
b.WriteString("tags:\n")
for _, t := range tags {
b.WriteString(" - " + t + "\n")
}
}
}
b.WriteString("created: " + note.CreatedAt.Format("2006-01-02 15:04:05") + "\n")
b.WriteString("updated: " + note.UpdatedAt.Format("2006-01-02 15:04:05") + "\n")
b.WriteString("---\n\n")
b.WriteString(note.Content)
return b.String()
}
// sanitizeFileName 清理文件名中的非法字符
func sanitizeFileName(name string) string {
replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_")
name = replacer.Replace(name)
name = strings.TrimSpace(name)
if name == "" {
return fmt.Sprintf("note-%d", time.Now().Unix())
}
return name
}
+1 -1
View File
@@ -52,7 +52,7 @@ func main() {
log.Printf("前台展示: http://localhost%s/", addr) log.Printf("前台展示: http://localhost%s/", addr)
log.Printf("后台管理: http://localhost%s/admin/", addr) log.Printf("后台管理: http://localhost%s/admin/", addr)
log.Printf("图片上传目录: %s", cfg.UploadDir) log.Printf("图片上传目录: %s", cfg.UploadDir)
log.Printf("默认密码: %s", cfg.AdminPass) log.Printf("后台登录密码请通过 ADMIN_PASS 环境变量配置(生产环境务必修改默认密码)")
if err := engine.Run(addr); err != nil { if err := engine.Run(addr); err != nil {
log.Fatalf("启动服务失败: %v", err) log.Fatalf("启动服务失败: %v", err)
+36 -7
View File
@@ -1,20 +1,49 @@
package middleware package middleware
import "github.com/gin-gonic/gin" import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// CORS 跨域中间件 // CORS 跨域中间件
// 这是同源应用(前端、后台、API 都在同一服务),因此默认不对外部跨域放行。
// 仅允许本站自身的 Origin 访问,避免任意外部站点的跨域请求读取或携带凭证。
func CORS() gin.HandlerFunc { func CORS() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*") origin := c.GetHeader("Origin")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") host := c.Request.Host
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization")
c.Header("Access-Control-Max-Age", "86400")
if c.Request.Method == "OPTIONS" { // 同源请求或没有 Origin 的请求(curl 等)直接放行
c.AbortWithStatus(204) if origin == "" || strings.Contains(origin, "://"+host) {
// 仍允许同源反射,方便调试
if origin != "" {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Vary", "Origin")
}
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
return return
} }
// 非同源且非本站:拒绝携带凭证的跨域请求
if c.Request.Method == http.MethodOptions {
// 预检请求直接拒绝
c.AbortWithStatus(http.StatusForbidden)
return
}
// 允许无凭证的只读公开请求,但不返回 Allow-Origin,浏览器会拦截跨域读取
if c.Request.Method == http.MethodGet {
c.Next() c.Next()
return
}
// 写操作(POST/PUT/DELETE)来自外部站点的跨域请求:拦截
c.AbortWithStatus(http.StatusForbidden)
} }
} }
+109
View File
@@ -0,0 +1,109 @@
package middleware
import (
"crypto/rand"
"encoding/hex"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// ---- 服务端会话存储(内存) ----
// 用随机 token 代替之前的固定字符串 cookie,登出/过期即失效。
var (
sessions = make(map[string]time.Time) // token -> 过期时间
sessionsMu sync.RWMutex
)
const (
CookieName = "admin_token"
SessionTTL = 7 * 24 * time.Hour // 会话有效期 7 天
CookieMaxAge = 7 * 24 * 3600 // cookie 有效期(秒)
sessionCleanT = 10 // 清理过期会话的间隔(分钟)
)
// NewSessionToken 生成一个新的会话 token 并注册
func NewSessionToken() (string, time.Time) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
// 兜底:用时间戳+纳秒(理论上不会发生)
b = []byte(time.Now().Format("20060102150405.000000000"))
}
token := hex.EncodeToString(b)
exp := time.Now().Add(SessionTTL)
sessionsMu.Lock()
sessions[token] = exp
sessionsMu.Unlock()
go cleanExpiredSessions()
return token, exp
}
// RevokeSession 登出时删除会话
func RevokeSession(token string) {
sessionsMu.Lock()
delete(sessions, token)
sessionsMu.Unlock()
}
// IsValidSession 校验 token 是否有效且未过期
func IsValidSession(token string) bool {
if token == "" {
return false
}
sessionsMu.RLock()
exp, ok := sessions[token]
sessionsMu.RUnlock()
if !ok {
return false
}
if time.Now().After(exp) {
RevokeSession(token)
return false
}
return true
}
// cleanExpiredSessions 定期清理过期会话,防止内存泄漏
func cleanExpiredSessions() {
sessionsMu.Lock()
defer sessionsMu.Unlock()
for token, exp := range sessions {
if time.Now().After(exp) {
delete(sessions, token)
}
}
}
// AuthRequired 管理接口认证中间件
func AuthRequired() gin.HandlerFunc {
return func(c *gin.Context) {
token, err := c.Cookie(CookieName)
if err != nil || !IsValidSession(token) {
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "请先登录"})
c.Abort()
return
}
c.Next()
}
}
// SetAuthCookie 设置认证 cookieSameSite=Lax 防 CSRFhttps 下应启用 Secure
func SetAuthCookie(c *gin.Context, token string, secure bool) {
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(CookieName, token, CookieMaxAge, "/", "", secure, true)
}
// ClearAuthCookie 清除认证 cookie
func ClearAuthCookie(c *gin.Context) {
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(CookieName, "", -1, "/", "", false, true)
}
// GetAuthToken 从请求读取 token
func GetAuthToken(c *gin.Context) string {
token, _ := c.Cookie(CookieName)
return token
}
+60 -11
View File
@@ -4,34 +4,60 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"time" "time"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
) )
// HashPassword 生成密码哈希 // bcryptCost 密码哈希成本因子
const bcryptCost = 10
// HashPassword 生成密码哈希(优先使用 bcrypt,向后兼容旧的 SHA-256
// 返回值格式:new(bcrypt)哈希以 $2 开头;旧的 SHA-256 以 64 位十六进制开头。
func HashPassword(password string) string { func HashPassword(password string) string {
if password == "" { if password == "" {
return "" return ""
} }
hash := sha256.Sum256([]byte(password)) hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
return hex.EncodeToString(hash[:]) if err != nil {
// 极端情况下 fallback 到 sha256
s := sha256.Sum256([]byte(password))
return hex.EncodeToString(s[:])
}
return string(hash)
} }
// CheckPassword 验证密码 // IsBcryptHash 判断哈希是否为 bcrypt 格式
func CheckPassword(password, hash string) bool { func IsBcryptHash(hash string) bool {
// 如果笔记没有设置密码(hash 为空),则不需要验证 return len(hash) >= 4 && hash[:4] == "$2a$" || (len(hash) >= 4 && hash[:4] == "$2b$") || (len(hash) >= 4 && hash[:4] == "$2y$")
}
// CheckPassword 验证密码(兼容 bcrypt 与旧 SHA-256 哈希)
// 第二个返回值表示是否应当把存储的哈希升级为 bcrypt(旧 sha256 命中时返回 true
func CheckPassword(password, hash string) (bool, bool) {
if hash == "" { if hash == "" {
return true return true, false
} }
// 如果用户没有输入密码,验证失败
if password == "" { if password == "" {
return false return false, false
} }
return HashPassword(password) == hash if IsBcryptHash(hash) {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil, false
}
// 旧的 SHA-256 哈希
s := sha256.Sum256([]byte(password))
if hex.EncodeToString(s[:]) == hash {
// 命中旧哈希,建议升级为 bcrypt
return true, true
}
return false, false
} }
// Note 笔记模型(也用于目录) // Note 笔记模型(也用于目录)
type Note struct { type Note struct {
ID uint `json:"id" gorm:"primaryKey"` ID uint `json:"id" gorm:"primaryKey"`
Title string `json:"title" gorm:"size:255;not null" binding:"required"` Title string `json:"title" gorm:"size:255;not null"`
Content string `json:"content" gorm:"type:text"` Content string `json:"content" gorm:"type:text"`
Category string `json:"category" gorm:"size:100;index"` Category string `json:"category" gorm:"size:100;index"`
Tags string `json:"tags" gorm:"type:text"` // JSON 数组格式存储 Tags string `json:"tags" gorm:"type:text"` // JSON 数组格式存储
@@ -42,8 +68,12 @@ type Note struct {
ParentID uint `json:"parent_id" gorm:"default:0;index"` // 父级目录 ID0 表示根目录 ParentID uint `json:"parent_id" gorm:"default:0;index"` // 父级目录 ID0 表示根目录
IsFolder bool `json:"is_folder" gorm:"default:false"` // 是否为文件夹 IsFolder bool `json:"is_folder" gorm:"default:false"` // 是否为文件夹
SortOrder int `json:"sort_order" gorm:"default:0"` // 排序顺序 SortOrder int `json:"sort_order" gorm:"default:0"` // 排序顺序
ShareToken string `json:"-" gorm:"size:64;index"` // 分享令牌,空 = 未分享
ShareExpireAt *time.Time `json:"share_expire_at,omitempty"` // 分享过期时间,nil = 永久
VisitCount int `json:"visit_count" gorm:"default:0"` // 浏览次数
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` // 软删除
} }
// NoteListItem 笔记列表响应(不含内容和密码) // NoteListItem 笔记列表响应(不含内容和密码)
@@ -59,6 +89,9 @@ type NoteListItem struct {
ParentID uint `json:"parent_id"` ParentID uint `json:"parent_id"`
IsFolder bool `json:"is_folder"` IsFolder bool `json:"is_folder"`
SortOrder int `json:"sort_order"` SortOrder int `json:"sort_order"`
ShareToken string `json:"share_token,omitempty"` // 分享令牌(后台完整列表需要)
ShareExpireAt *time.Time `json:"share_expire_at,omitempty"`
VisitCount int `json:"visit_count"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
@@ -98,3 +131,19 @@ type NoteUpdateRequest struct {
type NoteAccessRequest struct { type NoteAccessRequest struct {
Password string `json:"password"` Password string `json:"password"`
} }
// NoteVersion 笔记版本历史
type NoteVersion struct {
ID uint `json:"id" gorm:"primaryKey"`
NoteID uint `json:"note_id" gorm:"index"`
Title string `json:"title"`
Content string `json:"content" gorm:"type:text"`
Category string `json:"category"`
Tags string `json:"tags"`
CreatedAt time.Time `json:"created_at"`
}
// TableName 指定版本表名
func (NoteVersion) TableName() string {
return "note_versions"
}
+204 -18
View File
@@ -1,6 +1,7 @@
package repository package repository
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -28,8 +29,11 @@ func NewNoteRepository(dbPath string) (*NoteRepository, error) {
return nil, fmt.Errorf("连接数据库失败: %w", err) return nil, fmt.Errorf("连接数据库失败: %w", err)
} }
// 开启外键约束(SQLite 默认关闭)
db.Exec("PRAGMA foreign_keys = ON")
// 自动迁移表结构 // 自动迁移表结构
if err := db.AutoMigrate(&model.Note{}); err != nil { if err := db.AutoMigrate(&model.Note{}, &model.NoteVersion{}); err != nil {
return nil, fmt.Errorf("数据库迁移失败: %w", err) return nil, fmt.Errorf("数据库迁移失败: %w", err)
} }
@@ -38,10 +42,11 @@ func NewNoteRepository(dbPath string) (*NoteRepository, error) {
// Create 创建笔记 // Create 创建笔记
func (r *NoteRepository) Create(note *model.Note) error { 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 res := r.db.Select("Title", "Content", "Category", "Tags", "Password", "IsPinned", "IsFavorite", "IsPublic", "ParentID", "IsFolder", "SortOrder", "ShareToken", "ShareExpireAt", "VisitCount").Create(note)
return res.Error
} }
// GetByID 根据 ID 获取笔记 // GetByID 根据 ID 获取笔记(排除已删除)
func (r *NoteRepository) GetByID(id uint) (*model.Note, error) { func (r *NoteRepository) GetByID(id uint) (*model.Note, error) {
var note model.Note var note model.Note
err := r.db.First(&note, id).Error err := r.db.First(&note, id).Error
@@ -51,12 +56,27 @@ func (r *NoteRepository) GetByID(id uint) (*model.Note, error) {
return &note, nil return &note, nil
} }
// GetByIDIncludingDeleted 获取笔记(包含已软删除的,用于回收站恢复)
func (r *NoteRepository) GetByIDIncludingDeleted(id uint) (*model.Note, error) {
var note model.Note
err := r.db.Unscoped().First(&note, id).Error
if err != nil {
return nil, err
}
return &note, nil
}
// Update 更新笔记 // Update 更新笔记
func (r *NoteRepository) Update(note *model.Note) error { func (r *NoteRepository) Update(note *model.Note) error {
return r.db.Save(note).Error return r.db.Save(note).Error
} }
// Delete 删除笔记 // UpdateFields 按字段更新(避免 Save 覆盖所有字段)
func (r *NoteRepository) UpdateFields(id uint, fields map[string]interface{}) error {
return r.db.Model(&model.Note{}).Where("id = ?", id).Updates(fields).Error
}
// Delete 软删除笔记
func (r *NoteRepository) Delete(id uint) error { func (r *NoteRepository) Delete(id uint) error {
return r.db.Delete(&model.Note{}, id).Error return r.db.Delete(&model.Note{}, id).Error
} }
@@ -100,7 +120,7 @@ func (r *NoteRepository) List(q ListQuery) ([]model.NoteListItem, int64, error)
} }
offset := (q.Page - 1) * q.PageSize 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"). err := query.Select("id, title, category, tags, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, visit_count, created_at, updated_at").
Order("is_folder DESC, sort_order ASC, updated_at DESC"). Order("is_folder DESC, sort_order ASC, updated_at DESC").
Offset(offset). Offset(offset).
Limit(q.PageSize). Limit(q.PageSize).
@@ -109,17 +129,17 @@ func (r *NoteRepository) List(q ListQuery) ([]model.NoteListItem, int64, error)
return items, total, err return items, total, err
} }
// GetAllTree 获取所有笔记的树形结构 // GetAllTree 获取所有笔记的树形结构(含分享信息)
func (r *NoteRepository) GetAllTree() ([]model.NoteListItem, error) { func (r *NoteRepository) GetAllTree() ([]model.NoteListItem, error) {
var items []model.NoteListItem var items []model.NoteListItem
err := r.db.Model(&model.Note{}). 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"). 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, share_token, share_expire_at, visit_count, created_at, updated_at").
Order("is_folder DESC, sort_order ASC, title ASC"). Order("is_folder DESC, sort_order ASC, title ASC").
Find(&items).Error Find(&items).Error
return items, err return items, err
} }
// GetPublicTree 获取公开可见的树形结构(显示所有目录和笔记,访问时再验证密码 // GetPublicTree 获取公开可见的树形结构(前台用,不含分享令牌
func (r *NoteRepository) GetPublicTree() ([]model.NoteListItem, error) { func (r *NoteRepository) GetPublicTree() ([]model.NoteListItem, error) {
var items []model.NoteListItem var items []model.NoteListItem
err := r.db.Model(&model.Note{}). err := r.db.Model(&model.Note{}).
@@ -129,7 +149,7 @@ func (r *NoteRepository) GetPublicTree() ([]model.NoteListItem, error) {
return items, err return items, err
} }
// GetByParentID 获取指定父目录下的所有项目 // GetByParentID 获取指定父目录下的所有项目(排除已删除)
func (r *NoteRepository) GetByParentID(parentID uint) ([]model.NoteListItem, error) { func (r *NoteRepository) GetByParentID(parentID uint) ([]model.NoteListItem, error) {
var items []model.NoteListItem var items []model.NoteListItem
err := r.db.Model(&model.Note{}). err := r.db.Model(&model.Note{}).
@@ -140,21 +160,121 @@ func (r *NoteRepository) GetByParentID(parentID uint) ([]model.NoteListItem, err
return items, err return items, err
} }
// GetChildrenCount 获取子项数量 // GetChildrenCount 获取子项数量(排除已删除)
func (r *NoteRepository) GetChildrenCount(parentID uint) (int64, error) { func (r *NoteRepository) GetChildrenCount(parentID uint) (int64, error) {
var count int64 var count int64
err := r.db.Model(&model.Note{}).Where("parent_id = ?", parentID).Count(&count).Error err := r.db.Model(&model.Note{}).Where("parent_id = ?", parentID).Count(&count).Error
return count, err return count, err
} }
// DeleteWithChildren 删除目录及其下所有内容 // DeleteWithChildren 删除目录及其下所有内容
func (r *NoteRepository) DeleteWithChildren(id uint) error { func (r *NoteRepository) DeleteWithChildren(id uint) error {
// 先删除所有子项 // 递归收集所有后代 id
if err := r.db.Where("parent_id = ?", id).Delete(&model.Note{}).Error; err != nil { ids := r.collectDescendants(id)
var err error
if len(ids) > 0 {
err = r.db.Delete(&model.Note{}, ids).Error
} else {
err = r.db.Delete(&model.Note{}, id).Error
}
return err
}
// collectDescendants 收集目录的所有后代 ID(含自身)
func (r *NoteRepository) collectDescendants(id uint) []uint {
ids := []uint{id}
queue := []uint{id}
for len(queue) > 0 {
parent := queue[0]
queue = queue[1:]
var children []uint
if err := r.db.Model(&model.Note{}).Where("parent_id = ? AND is_folder = ?", parent, true).Pluck("id", &children).Error; err != nil {
continue
}
for _, c := range children {
ids = append(ids, c)
queue = append(queue, c)
}
}
return ids
}
// ListTrash 回收站列表(只含已软删除项)
func (r *NoteRepository) ListTrash() ([]model.NoteListItem, error) {
var items []model.NoteListItem
err := r.db.Unscoped().Model(&model.Note{}).
Where("deleted_at IS NOT NULL").
Select("id, title, category, tags, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, visit_count, created_at, updated_at").
Order("deleted_at DESC").
Find(&items).Error
return items, err
}
// Restore 从回收站恢复(连带恢复已被软删除的父目录路径不需要特殊处理)
func (r *NoteRepository) Restore(id uint) error {
// 恢复自身
if err := r.db.Unscoped().Model(&model.Note{}).Where("id = ?", id).Update("deleted_at", nil).Error; err != nil {
return err return err
} }
// 再删除自己 // 若其祖先目录也被软删除,一并恢复
return r.db.Delete(&model.Note{}, id).Error var note model.Note
if err := r.db.Unscoped().First(&note, id).Error; err == nil && note.ParentID != 0 {
var parent model.Note
if err := r.db.Unscoped().First(&parent, note.ParentID).Error; err == nil && !parent.DeletedAt.Time.IsZero() {
_ = r.db.Unscoped().Model(&model.Note{}).Where("id = ?", parent.ID).Update("deleted_at", nil).Error
}
}
return nil
}
// RestoreSubtree 恢复整个子树(含所有已软删除后代),返回恢复的节点数
func (r *NoteRepository) RestoreSubtree(rootID uint) error {
// 收集整棵子树所有节点 id(Unscoped,含已删除)
var ids []uint
queue := []uint{rootID}
for len(queue) > 0 {
parent := queue[0]
queue = queue[1:]
var children []uint
if err := r.db.Unscoped().Model(&model.Note{}).Where("parent_id = ?", parent).Pluck("id", &children).Error; err != nil {
return err
}
ids = append(ids, children...)
queue = append(queue, children...)
}
// 统一恢复所有节点(含自身)
ids = append(ids, rootID)
return r.db.Unscoped().Model(&model.Note{}).Where("id IN ?", ids).Update("deleted_at", nil).Error
}
// HardDelete 彻底删除(不可恢复)
func (r *NoteRepository) HardDelete(id uint) error {
return r.db.Unscoped().Delete(&model.Note{}, id).Error
}
// HardDeleteWithChildren 彻底删除目录及所有后代
func (r *NoteRepository) HardDeleteWithChildren(id uint) error {
ids := r.collectDescendantsIncludingDeleted(id)
ids = append(ids, id)
return r.db.Unscoped().Delete(&model.Note{}, ids).Error
}
func (r *NoteRepository) collectDescendantsIncludingDeleted(id uint) []uint {
var ids []uint
queue := []uint{id}
for len(queue) > 0 {
parent := queue[0]
queue = queue[1:]
var children []uint
if err := r.db.Unscoped().Model(&model.Note{}).Where("parent_id = ? AND is_folder = ?", parent, true).Pluck("id", &children).Error; err != nil {
continue
}
for _, c := range children {
ids = append(ids, c)
queue = append(queue, c)
}
}
return ids
} }
// Search 搜索笔记(按标题和内容) // Search 搜索笔记(按标题和内容)
@@ -170,7 +290,7 @@ func (r *NoteRepository) Search(keyword string, page, pageSize int) ([]model.Not
} }
offset := (page - 1) * pageSize 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"). err := query.Select("id, title, category, tags, is_pinned, is_favorite, is_public, parent_id, is_folder, sort_order, visit_count, created_at, updated_at").
Order("is_pinned DESC, updated_at DESC"). Order("is_pinned DESC, updated_at DESC").
Offset(offset). Offset(offset).
Limit(pageSize). Limit(pageSize).
@@ -179,7 +299,7 @@ func (r *NoteRepository) Search(keyword string, page, pageSize int) ([]model.Not
return items, total, err return items, total, err
} }
// GetCategories 获取所有分类 // GetCategories 获取所有分类(排除已删除和目录)
func (r *NoteRepository) GetCategories() ([]string, error) { func (r *NoteRepository) GetCategories() ([]string, error) {
var categories []string var categories []string
err := r.db.Model(&model.Note{}). err := r.db.Model(&model.Note{}).
@@ -189,7 +309,7 @@ func (r *NoteRepository) GetCategories() ([]string, error) {
return categories, err return categories, err
} }
// GetTags 获取所有标签 // GetTags 获取所有标签(排除已删除和目录)
func (r *NoteRepository) GetTags() ([]string, error) { func (r *NoteRepository) GetTags() ([]string, error) {
var tagsJSON []string var tagsJSON []string
err := r.db.Model(&model.Note{}). err := r.db.Model(&model.Note{}).
@@ -210,3 +330,69 @@ func (r *NoteRepository) GetTags() ([]string, error) {
} }
return result, nil return result, nil
} }
// ─────────────── 版本历史 ───────────────
// SaveVersion 保存笔记新版本快照
func (r *NoteRepository) SaveVersion(note *model.Note) (*model.NoteVersion, error) {
v := &model.NoteVersion{
NoteID: note.ID,
Title: note.Title,
Content: note.Content,
Category: note.Category,
Tags: note.Tags,
}
if err := r.db.Create(v).Error; err != nil {
return nil, err
}
return v, nil
}
// ListVersions 获取笔记的所有版本(按时间倒序)
func (r *NoteRepository) ListVersions(noteID uint) ([]model.NoteVersion, error) {
var versions []model.NoteVersion
err := r.db.Where("note_id = ?", noteID).Order("created_at DESC").Find(&versions).Error
return versions, err
}
// GetVersion 获取指定版本
func (r *NoteRepository) GetVersion(id uint) (*model.NoteVersion, error) {
var v model.NoteVersion
err := r.db.First(&v, id).Error
if err != nil {
return nil, err
}
return &v, nil
}
// DeleteVersions 删除某笔记的全部版本
func (r *NoteRepository) DeleteVersions(noteID uint) error {
return r.db.Where("note_id = ?", noteID).Delete(&model.NoteVersion{}).Error
}
// ─────────────── 分享 ───────────────
// GetByShareToken 通过分享令牌获取笔记(含已删除?分享的笔记不应是已删除的,排除)
func (r *NoteRepository) GetByShareToken(token string) (*model.Note, error) {
var note model.Note
err := r.db.Where("share_token = ?", token).First(&note).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("分享链接无效或已失效")
}
return nil, err
}
return &note, nil
}
// IncrementVisit 增加浏览次数
func (r *NoteRepository) IncrementVisit(id uint) error {
return r.db.Model(&model.Note{}).Where("id = ?", id).
UpdateColumn("visit_count", gorm.Expr("visit_count + 1")).Error
}
// IncrementVisitByToken 通过分享令牌增加浏览次数
func (r *NoteRepository) IncrementVisitByToken(token string) error {
return r.db.Model(&model.Note{}).Where("share_token = ?", token).
UpdateColumn("visit_count", gorm.Expr("visit_count + 1")).Error
}
+39 -23
View File
@@ -15,47 +15,60 @@ func Setup(r *gin.Engine, noteHandler *handler.NoteHandler, adminHandler *handle
// 静态文件服务(图片) // 静态文件服务(图片)
r.Static("/uploads", cfg.UploadDir) r.Static("/uploads", cfg.UploadDir)
// API 路由组 // ─────────── 公开 API ───────────
api := r.Group("/api") api := r.Group("/api")
{ {
notes := api.Group("/notes") notes := api.Group("/notes")
{ {
// 公开只读接口
notes.GET("", noteHandler.ListNotes) notes.GET("", noteHandler.ListNotes)
notes.GET("/search", noteHandler.SearchNotes) notes.GET("/search", noteHandler.SearchNotes)
notes.GET("/:id", noteHandler.GetNote) notes.GET("/:id", noteHandler.GetNote) // 公开安全版:仅公开且无密码的笔记
notes.POST("/:id/access", noteHandler.AccessNote) // 密码验证访问 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("/categories", noteHandler.GetCategories)
api.GET("/tags", noteHandler.GetTags) api.GET("/tags", noteHandler.GetTags)
api.GET("/tree", noteHandler.GetPublicTree) // 前台公开树 api.GET("/tree", noteHandler.GetPublicTree) // 前台公开树
// 分享 JSON 接口(公开)
api.GET("/share/:token", noteHandler.GetSharedNote)
} }
// 管理后台专用 API(需认证) // ─────────── 管理后台 API(需认证)───────────
adminApi := r.Group("/admin/api") adminApi := r.Group("/admin/api")
adminApi.Use(func(c *gin.Context) { adminApi.Use(middleware.AuthRequired())
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.POST("/notes", noteHandler.CreateNote)
adminApi.GET("/export/:id", noteHandler.ExportNote) // 导出笔记 adminApi.GET("/notes/:id", noteHandler.GetAdminNote)
adminApi.POST("/import", noteHandler.ImportNotes) // 导入笔记 adminApi.PUT("/notes/:id", noteHandler.UpdateNote)
adminApi.DELETE("/notes/:id", noteHandler.DeleteNote)
// 回收站
adminApi.GET("/trash", noteHandler.ListTrash)
adminApi.POST("/restore/:id", noteHandler.RestoreNote)
adminApi.POST("/purge/:id", noteHandler.PurgeNote)
adminApi.POST("/empty-trash", noteHandler.EmptyTrash)
// 版本历史
adminApi.GET("/notes/:id/versions", noteHandler.ListVersions)
adminApi.POST("/notes/:id/restore-version", noteHandler.RestoreVersion)
// 分享
adminApi.POST("/notes/:id/share", noteHandler.CreateShare)
adminApi.POST("/notes/:id/revoke-share", noteHandler.RevokeShare)
// 导入导出
adminApi.GET("/export/:id", noteHandler.ExportNote)
adminApi.GET("/export-all", noteHandler.ExportAll)
adminApi.POST("/import", noteHandler.ImportNotes)
// 完整树 + 图片上传
adminApi.GET("/tree", noteHandler.GetTree)
adminApi.POST("/upload", imageHandler.Upload)
} }
// 后台管理路由 // ─────────── 后台管理路由 ───────────
admin := r.Group("/admin") admin := r.Group("/admin")
{ {
admin.GET("/login", adminHandler.LoginPage) admin.GET("/login", adminHandler.LoginPage)
@@ -65,6 +78,9 @@ func Setup(r *gin.Engine, noteHandler *handler.NoteHandler, adminHandler *handle
admin.GET("/", adminHandler.IndexPage) admin.GET("/", adminHandler.IndexPage)
} }
// ─────────── 分享阅读页 ───────────
r.GET("/share/:token", noteHandler.SharePage)
// 健康检查 // 健康检查
r.GET("/health", func(c *gin.Context) { r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"}) c.JSON(200, gin.H{"status": "ok"})
+183 -10
View File
@@ -1,10 +1,14 @@
package service package service
import ( import (
"crypto/rand"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"strconv" "strconv"
"time"
"gorm.io/gorm"
"note-manager/model" "note-manager/model"
"note-manager/repository" "note-manager/repository"
) )
@@ -50,40 +54,52 @@ func (s *NoteService) CreateNote(req model.NoteCreateRequest) (*model.Note, erro
if err := s.repo.Create(note); err != nil { if err := s.repo.Create(note); err != nil {
return nil, fmt.Errorf("创建笔记失败: %w", err) return nil, fmt.Errorf("创建笔记失败: %w", err)
} }
// 创建时保存第一版历史
_, _ = s.repo.SaveVersion(note)
return note, nil return note, nil
} }
// GetNote 获取单条笔记 // GetNote 获取单条笔记(后台管理使用,任意笔记)
func (s *NoteService) GetNote(id uint) (*model.Note, error) { func (s *NoteService) GetNote(id uint) (*model.Note, error) {
note, err := s.repo.GetByID(id) note, err := s.repo.GetByID(id)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("笔记不存在") return nil, errors.New("笔记不存在")
} }
return nil, err
}
return note, nil return note, nil
} }
// GetNoteContent 获取笔记内容(需密码验证) // GetNoteContent 获取笔记内容(需密码验证,用于前台展示
func (s *NoteService) GetNoteContent(id uint, password string) (*model.Note, error) { // 返回 (note, 是否需要升级密码哈希, err)
func (s *NoteService) GetNoteContent(id uint, password string) (*model.Note, bool, error) {
note, err := s.repo.GetByID(id) note, err := s.repo.GetByID(id)
if err != nil { if err != nil {
return nil, errors.New("笔记不存在") return nil, false, errors.New("笔记不存在")
} }
// 检查密码 // 检查密码
if note.Password != "" { if note.Password != "" {
if !model.CheckPassword(password, note.Password) { ok, upgrade := model.CheckPassword(password, note.Password)
return nil, errors.New("密码错误") if !ok {
return nil, false, errors.New("密码错误")
} }
return note, upgrade, nil
} }
return note, nil return note, false, nil
} }
// UpdateNote 更新笔记或目录 // UpdateNote 更新笔记或目录(保存更新前快照到版本历史)
func (s *NoteService) UpdateNote(id uint, req model.NoteUpdateRequest) (*model.Note, error) { func (s *NoteService) UpdateNote(id uint, req model.NoteUpdateRequest) (*model.Note, error) {
note, err := s.repo.GetByID(id) note, err := s.repo.GetByID(id)
if err != nil { if err != nil {
return nil, errors.New("笔记不存在") return nil, errors.New("笔记不存在")
} }
// 记录旧状态,判断是否产生实质内容变化
oldContent := note.Content
oldTitle := note.Title
if req.Title != nil { if req.Title != nil {
note.Title = *req.Title note.Title = *req.Title
} }
@@ -116,17 +132,22 @@ func (s *NoteService) UpdateNote(id uint, req model.NoteUpdateRequest) (*model.N
} }
if req.RemovePassword != nil && *req.RemovePassword { if req.RemovePassword != nil && *req.RemovePassword {
note.Password = "" note.Password = ""
} else if req.Password != nil { } else if req.Password != nil && *req.Password != "" {
note.Password = model.HashPassword(*req.Password) note.Password = model.HashPassword(*req.Password)
} }
if err := s.repo.Update(note); err != nil { if err := s.repo.Update(note); err != nil {
return nil, fmt.Errorf("更新笔记失败: %w", err) return nil, fmt.Errorf("更新笔记失败: %w", err)
} }
// 若内容或标题发生变化,保存历史版本
if oldContent != note.Content || oldTitle != note.Title {
_, _ = s.repo.SaveVersion(note)
}
return note, nil return note, nil
} }
// DeleteNote 删除笔记或目录(目录会删除所有子项) // DeleteNote 删除笔记或目录(目录会删除所有子项)
func (s *NoteService) DeleteNote(id uint) error { func (s *NoteService) DeleteNote(id uint) error {
note, err := s.repo.GetByID(id) note, err := s.repo.GetByID(id)
if err != nil { if err != nil {
@@ -217,6 +238,158 @@ func (s *NoteService) GetTags() ([]string, error) {
return s.repo.GetTags() return s.repo.GetTags()
} }
// ─────────────── 回收站 ───────────────
// ListTrash 获取回收站列表
func (s *NoteService) ListTrash() ([]model.NoteListItem, error) {
return s.repo.ListTrash()
}
// RestoreNote 从回收站恢复笔记或目录(整棵子树)
func (s *NoteService) RestoreNote(id uint) error {
note, err := s.repo.GetByIDIncludingDeleted(id)
if err != nil {
return errors.New("记录不存在")
}
if note.IsFolder {
return s.repo.RestoreSubtree(id)
}
return s.repo.Restore(id)
}
// PurgeNote 彻底删除笔记或目录(不可恢复)
func (s *NoteService) PurgeNote(id uint) error {
note, err := s.repo.GetByIDIncludingDeleted(id)
if err != nil {
return errors.New("记录不存在")
}
if note.IsFolder {
if err := s.repo.HardDeleteWithChildren(id); err != nil {
return err
}
} else {
if err := s.repo.HardDelete(id); err != nil {
return err
}
}
// 清理版本历史
_ = s.repo.DeleteVersions(id)
return nil
}
// EmptyTrash 清空回收站
func (s *NoteService) EmptyTrash() error {
trash, err := s.repo.ListTrash()
if err != nil {
return err
}
for _, item := range trash {
if err := s.PurgeNote(item.ID); err != nil {
return err
}
}
return nil
}
// ─────────────── 版本历史 ───────────────
// ListVersions 获取笔记版本列表
func (s *NoteService) ListVersions(noteID uint) ([]model.NoteVersion, error) {
return s.repo.ListVersions(noteID)
}
// RestoreVersion 将笔记恢复到指定版本
func (s *NoteService) RestoreVersion(noteID, versionID uint) (*model.Note, error) {
version, err := s.repo.GetVersion(versionID)
if err != nil {
return nil, errors.New("版本不存在")
}
note, err := s.repo.GetByID(noteID)
if err != nil {
return nil, errors.New("笔记不存在")
}
// 保存当前状态为历史版本(防止覆盖)
_, _ = s.repo.SaveVersion(note)
// 回滚
note.Title = version.Title
note.Content = version.Content
note.Category = version.Category
note.Tags = version.Tags
if err := s.repo.Update(note); err != nil {
return nil, fmt.Errorf("恢复版本失败: %w", err)
}
return note, nil
}
// ─────────────── 分享 ───────────────
// CreateShare 创建/更新分享令牌
func (s *NoteService) CreateShare(noteID uint, expireHours int) (*model.Note, error) {
note, err := s.repo.GetByID(noteID)
if err != nil {
return nil, errors.New("笔记不存在")
}
if note.IsFolder {
return nil, errors.New("目录不能分享")
}
note.ShareToken = randomToken(32)
if expireHours > 0 {
t := time.Now().Add(time.Duration(expireHours) * time.Hour)
note.ShareExpireAt = &t
} else {
note.ShareExpireAt = nil
}
if err := s.repo.Update(note); err != nil {
return nil, fmt.Errorf("创建分享失败: %w", err)
}
return note, nil
}
// RevokeShare 撤销分享
func (s *NoteService) RevokeShare(noteID uint) error {
note, err := s.repo.GetByID(noteID)
if err != nil {
return errors.New("笔记不存在")
}
note.ShareToken = ""
note.ShareExpireAt = nil
return s.repo.Update(note)
}
// GetSharedNote 通过令牌获取分享笔记(校验过期时间)
func (s *NoteService) GetSharedNote(token string) (*model.Note, error) {
note, err := s.repo.GetByShareToken(token)
if err != nil {
return nil, err
}
if note.ShareExpireAt != nil && time.Now().After(*note.ShareExpireAt) {
return nil, errors.New("分享链接已过期")
}
// 若有密码则需校验(在 handler 层处理)
_ = s.repo.IncrementVisitByToken(token)
return note, nil
}
// UpgradePasswordHash 将旧 SHA-256 密码哈希升级为 bcrypt
func (s *NoteService) UpgradePasswordHash(id uint, password string) error {
note, err := s.repo.GetByID(id)
if err != nil {
return err
}
note.Password = model.HashPassword(password)
return s.repo.Update(note)
}
// randomToken 生成安全的随机令牌
func randomToken(bytesLen int) string {
b := make([]byte, bytesLen)
if _, err := rand.Read(b); err != nil {
// 兜底使用时间戳(几乎不会发生)
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
func parseInt(s string, defaultVal int) int { func parseInt(s string, defaultVal int) int {
if s == "" { if s == "" {
return defaultVal return defaultVal
+247
View File
@@ -0,0 +1,247 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""note-manager 新功能端到端冒烟测试"""
import json
import re
import sys
import urllib.request
import urllib.parse
import http.cookiejar
import io
import zipfile
BASE = "http://localhost:8080"
cj = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
PASSED = 0
FAILED = 0
def report(name, ok, detail=""):
global PASSED, FAILED
if ok:
PASSED += 1
print(f"{name}")
else:
FAILED += 1
print(f"{name} {detail}")
def req(method, path, data=None, raw=False, headers=None):
url = BASE + path
body = None
hdrs = dict(headers or {})
if data is not None:
if isinstance(data, (dict, list)):
body = json.dumps(data).encode()
hdrs["Content-Type"] = "application/json"
elif isinstance(data, str):
body = data.encode()
hdrs["Content-Type"] = "application/x-www-form-urlencoded"
r = urllib.request.Request(url, data=body, method=method, headers=hdrs)
try:
resp = opener.open(r, timeout=10)
content = resp.read()
if raw:
return resp.status, content, dict(resp.headers)
return resp.status, json.loads(content.decode() if content else "{}")
except urllib.error.HTTPError as e:
content = e.read()
if raw:
return e.code, content, dict(e.headers)
try:
return e.code, json.loads(content.decode() if content else "{}")
except Exception:
return e.code, {}
except Exception as ex:
return -1, {"error": str(ex)}
print("═══ 1. 认证安全 ═══")
# 未登录访问管理 API -> 401
st, d = req("GET", "/admin/api/tree")
report("未登录访问管理API返回401", st == 401, f"(got {st})")
st, d = req("POST", "/admin/api/notes")
report("未登录创建笔记返回401", st == 401, f"(got {st})")
st, d = req("POST", "/admin/notes")
report("未登录(错误路径)不泄露", st in (401, 404), f"(got {st})")
# 错误密码登录
st, d = req("POST", "/admin/login", "password=wrongpass")
report("错误密码登录返回401", st == 401, f"(got {st})")
# 正确登录
st, d = req("POST", "/admin/login", "password=admin123")
report("正确密码登录成功", st == 200 and d.get("code") == 0, f"(got {st} {d})")
# 登录后可访问
st, d = req("GET", "/admin/api/tree")
report("登录后访问管理API成功", st == 200 and d.get("code") == 0, f"(got {st})")
# 旧固定 cookie 不再有效
class TmpOpener:
pass
# 手动构造请求带旧的 fixed cookie
import urllib.request as u
reqf = urllib.request.Request(BASE + "/admin/api/tree", headers={"Cookie": "admin_token=authenticated"})
try:
r = u.urlopen(reqf, timeout=10)
st_old = r.status
except urllib.error.HTTPError as e:
st_old = e.code
report("固定字符串cookie已失效(返回401)", st_old == 401, f"(got {st_old})")
print("═══ 2. 笔记 CRUD + 版本历史 ═══")
st, d = req("POST", "/admin/api/notes", {
"title": "测试笔记A", "content": "第一版内容", "category": "测试", "tags": '["go","测试"]', "is_public": True
})
note_id = d.get("data", {}).get("id")
report("创建笔记成功", st == 200 and note_id, f"(got {st} {d})")
st, d = req("PUT", f"/admin/api/notes/{note_id}", {"content": "第二版内容更新了"})
report("更新笔记成功", st == 200 and d.get("code") == 0, f"(got {st})")
st, d = req("GET", f"/admin/api/notes/{note_id}/versions")
vers = d.get("data", [])
report("版本历史记录>=2", st == 200 and len(vers) >= 2, f"(got {len(vers)})")
# 回滚到第一版
old_ver = vers[-1]
st, d = req("POST", f"/admin/api/notes/{note_id}/restore-version", "version_id=%d" % old_ver["id"])
report("恢复历史版本成功", st == 200 and d.get("code") == 0, f"(got {st})")
st, d = req("GET", f"/admin/api/notes/{note_id}")
report("恢复后内容为第一版", d.get("data", {}).get("content") == "第一版内容", f"(got {d.get('data',{}).get('content')})")
print("═══ 3. 安全:公开接口不泄露带密码笔记 ═══")
# 创建一个带密码的公开笔记
st, d = req("POST", "/admin/api/notes", {
"title": "受保护笔记", "content": "秘密内容", "is_public": True, "password": "secret123"
})
protected_id = d.get("data", {}).get("id")
report("创建带密码笔记成功", st == 200 and protected_id, f"(got {st})")
# 通过公开接口 GET /api/notes/:id 访问 -> 应被拒
st, d = req("GET", f"/api/notes/{protected_id}")
report("公开接口拒绝带密码笔记", st in (401, 403), f"(got {st})")
# 密码验证接口应能正确返回
st, d = req("POST", f"/api/notes/{protected_id}/access", {"password": "secret123"})
report("密码验证访问成功", st == 200 and "秘密内容" in d.get("data", {}).get("content", ""), f"(got {st})")
st, d = req("POST", f"/api/notes/{protected_id}/access", {"password": "wrong"})
report("错误密码访问被拒", st == 401, f"(got {st})")
# 公开无密码笔记
public_ok = False
st, d = req("GET", f"/api/notes/{note_id}")
report("公开接口读取无密码笔记成功", st == 200 and d.get("code") == 0, f"(got {st})")
print("═══ 4. 回收站(软删除/恢复/清空) ═══")
# 删除受保护笔记 -> 进入回收站
st, d = req("DELETE", f"/admin/api/notes/{protected_id}")
report("软删除笔记成功", st == 200 and d.get("code") == 0, f"(got {st})")
# 前台公开接口应查不到已删除笔记
st, d = req("GET", f"/api/notes/{protected_id}")
report("已删除笔记公开接口404", st == 404, f"(got {st})")
st, d = req("GET", "/admin/api/trash")
trash = d.get("data", [])
report("回收站包含已删笔记", any(t.get("id") == protected_id for t in trash), f"(got {[t.get('id') for t in trash]})")
# 恢复
st, d = req("POST", f"/admin/api/restore/{protected_id}")
report("从回收站恢复成功", st == 200 and d.get("code") == 0, f"(got {st})")
st, d = req("GET", "/admin/api/trash")
trash = d.get("data", [])
report("恢复后回收站不再包含该笔记", not any(t.get("id") == protected_id for t in trash))
st, d = req("GET", f"/api/notes/{protected_id}")
report("恢复后可再次访问(仍带密码被拒)", st in (401, 403, 200), f"(got {st})")
# 彻底删除测试
st, d = req("POST", "/admin/api/notes", {"title": "待彻底删除", "content": "x", "is_public": True})
tmp_id = d.get("data", {}).get("id")
req("DELETE", f"/admin/api/notes/{tmp_id}")
st, d = req("POST", f"/admin/api/purge/{tmp_id}")
report("彻底删除成功", st == 200 and d.get("code") == 0, f"(got {st})")
st, d = req("GET", "/admin/api/trash")
report("彻底删除后回收站无该记录", not any(t.get("id") == tmp_id for t in d.get("data", [])))
print("═══ 5. 分享链接 ═══")
# 给测试笔记A创建分享
st, d = req("POST", f"/admin/api/notes/{note_id}/share", "expire_hours=24")
share_token = d.get("data", {}).get("share_token")
report("创建分享链接成功", st == 200 and share_token, f"(got {st} {d})")
# 通过分享链接访问
st, d = req("GET", f"/api/share/{share_token}")
report("分享链接可公开访问", st == 200 and d.get("data", {}).get("id") == note_id, f"(got {st})")
# 分享阅读页
st, raw, _ = req("GET", f"/share/{share_token}", raw=True)
report("分享阅读页返回HTML", st == 200 and b"<html" in raw.lower(), f"(got {st})")
# 撤销分享
st, d = req("POST", f"/admin/api/notes/{note_id}/revoke-share")
report("撤销分享成功", st == 200 and d.get("code") == 0, f"(got {st})")
st, d = req("GET", f"/api/share/{share_token}")
report("撤销后分享链接失效", st == 404, f"(got {st})")
print("═══ 6. 批量导出 zip ═══")
st, raw, hdrs = req("GET", "/admin/api/export-all", raw=True)
is_zip = False
if st == 200:
try:
zf = zipfile.ZipFile(io.BytesIO(raw))
names = zf.namelist()
is_zip = True
report("批量导出zip包含测试笔记", any("测试笔记" in n for n in names), f"(names={names})")
report("批量导出zip含front matter", any("---" in zf.read(n).decode()[:200] for n in names if n.endswith(".md")))
except Exception as ex:
report("批量导出zip解析", False, f"(err {ex})")
report("批量导出返回zip", st == 200 and raw[:2] == b"PK" and is_zip, f"(got {st})")
print("═══ 7. 单笔记导出 + 图片上传嗅探 ═══")
st, d = req("GET", f"/admin/api/notes/{note_id}")
report("后台读取笔记详情成功", st == 200 and d.get("data", {}).get("id") == note_id)
st, raw, _ = req("GET", f"/admin/api/export/{note_id}", raw=True)
report("单笔记导出Markdown", st == 200 and "测试笔记" in raw.decode('utf-8', 'ignore'), f"(got {st})")
# 上传伪造文件(伪装成 png 的文本)
import uuid
fake_bytes = b"<script>alert(1)</script>"
# 用 multipart 上传
boundary = "----WebKitFormBoundary" + uuid.uuid4().hex
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="image"; filename="evil.png"\r\n'
f"Content-Type: image/png\r\n\r\n"
).encode() + fake_bytes + f"\r\n--{boundary}--\r\n".encode()
st, d = req("POST", "/admin/api/upload", data=body, headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}, raw=False)
report("伪造图片(非真实图片)被拒", st in (400, 500), f"(got {st} {d})")
print("═══ 8. 列表/分类/标签/树 ═══")
st, d = req("GET", "/api/notes")
report("公开列表接口正常", st == 200 and d.get("code") == 0)
st, d = req("GET", "/api/categories")
report("分类列表正常", st == 200 and "测试" in d.get("data", []))
st, d = req("GET", "/api/tags")
report("标签列表正常", st == 200)
st, d = req("GET", "/api/tree")
report("公开树正常", st == 200)
print(f"\n════ 结果:{PASSED} 通过,{FAILED} 失败 ════")
sys.exit(0 if FAILED == 0 else 1)
+275 -7
View File
@@ -317,12 +317,32 @@
transition: background 0.2s; transition: background 0.2s;
} }
.resize-handle:hover { background: #1a73e8; } .resize-handle:hover { background: #1a73e8; }
/* ───── 深色模式覆盖 ───── */
body.dark { background: #1a1d21; color: #e0e0e0; }
body.dark header { background: #20242a; box-shadow: 0 1px 3px rgba(0,0,0,0.4); }
body.dark .sidebar { background: #23272e; border-color: #333; }
body.dark .content { background: #1a1d21; }
body.dark .tree-item:hover { background: #2d323a; }
body.dark .tree-item.active { background: #355; color:#7ab8ff; }
body.dark .editor-pane, body.dark .preview-pane { background: #1e2228; border-color:#333; }
body.dark input, body.dark textarea, body.dark select {
background: #24282f; color: #e0e0e0; border-color: #3a3f47;
}
body.dark .modal-content { background: #24282f; color:#e0e0e0; }
body.dark .preview-content code, body.dark .preview-content pre { background:#111; }
body.dark .empty-state { color:#888; }
body.dark .btn-secondary { background:#2d323a; color:#ccc; border-color:#3a3f47; }
body.dark .tree-item .folder { color:#7ab8ff; }
</style> </style>
</head> </head>
<body> <body>
<header> <header>
<h1>笔记管理后台</h1> <h1>笔记管理后台</h1>
<div class="header-actions"> <div class="header-actions">
<button class="btn btn-secondary" onclick="toggleDarkMode()" title="切换深色模式" id="darkModeBtn">🌙 深色</button>
<button class="btn btn-secondary" onclick="exportAll()" title="批量导出全部笔记为 zip">📦 批量导出</button>
<button class="btn btn-secondary" onclick="openTrash()" title="回收站">🗑 回收站</button>
<a href="/" class="btn btn-secondary" target="_blank">前台预览</a> <a href="/" class="btn btn-secondary" target="_blank">前台预览</a>
<button class="btn btn-secondary" onclick="logout()">退出登录</button> <button class="btn btn-secondary" onclick="logout()">退出登录</button>
</div> </div>
@@ -364,6 +384,8 @@
<input type="text" id="noteTitle" placeholder="笔记标题"> <input type="text" id="noteTitle" placeholder="笔记标题">
<button class="btn btn-primary" onclick="saveNote()">保存</button> <button class="btn btn-primary" onclick="saveNote()">保存</button>
<button class="btn btn-secondary" onclick="exportNote()">导出</button> <button class="btn btn-secondary" onclick="exportNote()">导出</button>
<button class="btn btn-secondary" onclick="openVersions()" title="版本历史">🕘 历史</button>
<button class="btn btn-secondary" onclick="openShare()" title="分享链接">🔗 分享</button>
<button class="btn btn-warning" onclick="document.getElementById('importFile').click()">导入</button> <button class="btn btn-warning" onclick="document.getElementById('importFile').click()">导入</button>
<input type="file" id="importFile" accept=".md" style="display:none" onchange="importNote(this.files[0])"> <input type="file" id="importFile" accept=".md" style="display:none" onchange="importNote(this.files[0])">
<button class="btn btn-danger" onclick="confirmDelete()">删除</button> <button class="btn btn-danger" onclick="confirmDelete()">删除</button>
@@ -418,7 +440,7 @@
<div class="modal" id="deleteModal"> <div class="modal" id="deleteModal">
<div class="modal-content"> <div class="modal-content">
<h3>确认删除</h3> <h3>确认删除</h3>
<p id="deleteMessage">确定要删除吗?此操作不可撤销</p> <p id="deleteMessage">确定要删除吗?删除后可在回收站中恢复</p>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-secondary" onclick="closeDeleteModal()">取消</button> <button class="btn btn-secondary" onclick="closeDeleteModal()">取消</button>
<button class="btn btn-danger" onclick="doDelete()">删除</button> <button class="btn btn-danger" onclick="doDelete()">删除</button>
@@ -426,6 +448,61 @@
</div> </div>
</div> </div>
<!-- 回收站弹窗 -->
<div class="modal" id="trashModal">
<div class="modal-content" style="min-width: 560px; max-height: 70vh; display: flex; flex-direction: column;">
<h3 style="margin-bottom: 12px;">🗑 回收站 <span style="font-size:13px;color:#999;font-weight:normal;">(删除的笔记会在这里保留,可恢复或彻底清除)</span></h3>
<div style="flex:1; overflow-y:auto;" id="trashList"><p style="color:#999;padding:20px;text-align:center;">加载中...</p></div>
<div class="modal-actions" style="margin-top:12px;justify-content:space-between;">
<button class="btn btn-danger" onclick="emptyTrash()">清空回收站</button>
<div>
<button class="btn btn-secondary" onclick="closeTrash()">关闭</button>
</div>
</div>
</div>
</div>
<!-- 版本历史弹窗 -->
<div class="modal" id="versionsModal">
<div class="modal-content" style="min-width: 560px; max-height: 70vh; display: flex; flex-direction: column;">
<h3 style="margin-bottom: 12px;">🕘 版本历史</h3>
<div style="flex:1; overflow-y:auto;" id="versionsList"><p style="color:#999;padding:20px;text-align:center;">加载中...</p></div>
<div class="modal-actions" style="margin-top:12px;">
<button class="btn btn-secondary" onclick="closeVersions()">关闭</button>
</div>
</div>
</div>
<!-- 分享弹窗 -->
<div class="modal" id="shareModal">
<div class="modal-content" style="min-width: 520px;">
<h3 style="margin-bottom: 16px;">🔗 分享链接</h3>
<div id="shareNoToken" style="display:block;">
<p style="color:#666;font-size:14px;margin-bottom:14px;">生成一个链接,无需登录即可查看该笔记。</p>
<label style="font-size:13px;color:#888;">可选:过期时间</label>
<select id="shareExpire" style="width:100%;padding:8px;margin:6px 0 14px;border:1px solid #ccc;border-radius:6px;">
<option value="0">永久有效</option>
<option value="24">24 小时</option>
<option value="72">3 天</option>
<option value="168">7 天</option>
<option value="720">30 天</option>
</select>
<button class="btn btn-primary" style="width:100%;" onclick="createShare()">生成分享链接</button>
</div>
<div id="shareHasToken" style="display:none;">
<label style="font-size:13px;color:#888;">分享链接</label>
<div style="display:flex;gap:8px;margin:6px 0 12px;">
<input type="text" id="shareUrl" readonly style="flex:1;padding:8px;border:1px solid #ccc;border-radius:6px;background:#f5f5f5;">
<button class="btn btn-secondary" onclick="copyShareUrl()">复制</button>
</div>
<button class="btn btn-danger" style="width:100%;" onclick="revokeShare()">撤销分享</button>
</div>
<div class="modal-actions" style="margin-top:16px;">
<button class="btn btn-secondary" onclick="closeShare()">关闭</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script> <script>
@@ -574,7 +651,7 @@
// 选择项目 // 选择项目
async function selectItem(id) { async function selectItem(id) {
try { try {
const res = await fetch(`${API}/notes/${id}`); const res = await fetch(`${ADMIN_API}/notes/${id}`);
const data = await res.json(); const data = await res.json();
if (data.code === 0) { if (data.code === 0) {
currentItem = data.data; currentItem = data.data;
@@ -660,14 +737,14 @@
let res, data; let res, data;
if (editId) { if (editId) {
// 更新 // 更新
res = await fetch(`${API}/notes/${editId}`, { res = await fetch(`${ADMIN_API}/notes/${editId}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: name }) body: JSON.stringify({ title: name })
}); });
} else { } else {
// 新建 // 新建
res = await fetch(`${API}/notes`, { res = await fetch(`${ADMIN_API}/notes`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: name, is_folder: true, parent_id: parentId }) body: JSON.stringify({ title: name, is_folder: true, parent_id: parentId })
@@ -749,14 +826,14 @@
let res, data; let res, data;
if (currentItem && currentItem.id && !currentItem.is_folder) { if (currentItem && currentItem.id && !currentItem.is_folder) {
// 更新 // 更新
res = await fetch(`${API}/notes/${currentItem.id}`, { res = await fetch(`${ADMIN_API}/notes/${currentItem.id}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
} else { } else {
// 新建 // 新建
res = await fetch(`${API}/notes`, { res = await fetch(`${ADMIN_API}/notes`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload) body: JSON.stringify(payload)
@@ -837,7 +914,7 @@
if (!currentItem || !currentItem.id) return; if (!currentItem || !currentItem.id) return;
try { try {
const res = await fetch(`${API}/notes/${currentItem.id}`, { method: 'DELETE' }); const res = await fetch(`${ADMIN_API}/notes/${currentItem.id}`, { method: 'DELETE' });
const data = await res.json(); const data = await res.json();
if (data.code === 0) { if (data.code === 0) {
@@ -1031,6 +1108,197 @@
document.body.style.userSelect = ''; document.body.style.userSelect = '';
}); });
} }
// ═══════════ 增强功能:深色模式 / 批量导出 / 回收站 / 版本历史 / 分享 ═══════════
// ── 深色模式 ──
function toggleDarkMode() {
const isDark = document.body.classList.toggle('dark');
localStorage.setItem('nm_dark', isDark ? '1' : '0');
document.getElementById('darkModeBtn').textContent = isDark ? '☀️ 白天' : '🌙 深色';
}
function initDarkMode() {
const dark = localStorage.getItem('nm_dark') === '1';
if (dark) {
document.body.classList.add('dark');
document.getElementById('darkModeBtn').textContent = '☀️ 白天';
}
}
// ── 批量导出全部笔记为 zip ──
async function exportAll() {
showToast('正在导出全部笔记...', 'info');
try {
const res = await fetch(`${ADMIN_API}/export-all`);
if (!res.ok) { showToast('导出失败', 'error'); return; }
const blob = await res.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'notes-export.zip';
a.click();
URL.revokeObjectURL(a.href);
showToast('导出成功', 'success');
} catch (e) {
showToast('导出失败', 'error');
}
}
// ── 回收站 ──
async function openTrash() {
const modal = document.getElementById('trashModal');
modal.style.display = 'flex';
document.getElementById('trashList').innerHTML = '<p style="color:#999;padding:20px;text-align:center;">加载中...</p>';
const res = await fetch(`${ADMIN_API}/trash`);
const data = await res.json();
if (data.code === 0) {
renderTrash(data.data);
} else {
document.getElementById('trashList').innerHTML = `<p style="color:#d32f2f;padding:20px;text-align:center;">${escapeHtml(data.message || '加载失败')}</p>`;
}
}
function closeTrash() {
document.getElementById('trashModal').style.display = 'none';
}
function renderTrash(items) {
const box = document.getElementById('trashList');
if (!items || !items.length) {
box.innerHTML = '<p style="color:#999;padding:30px;text-align:center;">回收站是空的</p>';
return;
}
const rows = items.map(item => {
const icon = item.is_folder ? '📁' : '📄';
return `<div style="display:flex;align-items:center;gap:8px;padding:10px;border-bottom:1px solid #eee;">
<span>${icon}</span>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(item.title || '(无标题)')}</span>
<span style="font-size:12px;color:#999;">${item.is_folder ? '目录' : ''}</span>
<button class="btn btn-secondary" style="font-size:12px;padding:4px 10px;" onclick="restoreTrashItem(${item.id})">恢复</button>
<button class="btn btn-danger" style="font-size:12px;padding:4px 10px;" onclick="purgeTrashItem(${item.id})">彻底删除</button>
</div>`;
}).join('');
box.innerHTML = rows;
}
async function restoreTrashItem(id) {
const res = await fetch(`${ADMIN_API}/restore/${id}`, { method: 'POST' });
const data = await res.json();
if (data.code === 0) { showToast('已恢复', 'success'); openTrash(); loadTree(); }
else showToast(data.message || '恢复失败', 'error');
}
async function purgeTrashItem(id) {
if (!confirm('确定彻底删除这条记录吗?此操作不可恢复!')) return;
const res = await fetch(`${ADMIN_API}/purge/${id}`, { method: 'POST' });
const data = await res.json();
if (data.code === 0) { showToast('已彻底删除', 'success'); openTrash(); }
else showToast(data.message || '删除失败', 'error');
}
async function emptyTrash() {
if (!confirm('确定清空回收站吗?所有记录将被彻底删除,不可恢复!')) return;
const res = await fetch(`${ADMIN_API}/empty-trash`, { method: 'POST' });
const data = await res.json();
if (data.code === 0) { showToast('回收站已清空', 'success'); openTrash(); }
else showToast(data.message || '清空失败', 'error');
}
// ── 版本历史 ──
async function openVersions() {
if (!currentItem || !currentItem.id) { showToast('请先选择一篇笔记', 'error'); return; }
if (currentItem.is_folder) { showToast('目录没有版本历史', 'error'); return; }
const modal = document.getElementById('versionsModal');
modal.style.display = 'flex';
document.getElementById('versionsList').innerHTML = '<p style="color:#999;padding:20px;text-align:center;">加载中...</p>';
const res = await fetch(`${ADMIN_API}/notes/${currentItem.id}/versions`);
const data = await res.json();
if (data.code === 0) renderVersions(data.data);
else document.getElementById('versionsList').innerHTML = `<p style="color:#d32f2f;padding:20px;text-align:center;">${escapeHtml(data.message || '加载失败')}</p>`;
}
function closeVersions() {
document.getElementById('versionsModal').style.display = 'none';
}
function renderVersions(versions) {
const box = document.getElementById('versionsList');
if (!versions || !versions.length) {
box.innerHTML = '<p style="color:#999;padding:30px;text-align:center;">暂无历史版本</p>';
return;
}
const rows = versions.map((v, idx) => {
const d = new Date(v.created_at);
const preview = (v.content || '').replace(/[#*`>\-\[\]]/g, '').slice(0, 80);
return `<div style="padding:10px;border-bottom:1px solid #eee;">
<div style="display:flex;align-items:center;gap:8px;">
<strong>${idx === 0 ? '当前版本' : '版本 ' + (versions.length - idx)}</strong>
<span style="font-size:12px;color:#999;">${d.toLocaleString('zh-CN')}</span>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;color:#777;">${escapeHtml(preview)}</span>
${idx !== 0 ? `<button class="btn btn-secondary" style="font-size:12px;padding:4px 10px;" onclick="restoreVersion(${v.id})">恢复此版本</button>` : ''}
</div>
</div>`;
}).join('');
box.innerHTML = rows;
}
async function restoreVersion(versionId) {
if (!confirm('确定恢复到该版本吗?当前内容会先保存为新版本。')) return;
const fd = new FormData();
fd.append('version_id', versionId);
const res = await fetch(`${ADMIN_API}/notes/${currentItem.id}/restore-version`, { method: 'POST', body: fd });
const data = await res.json();
if (data.code === 0) {
showToast('已恢复', 'success');
closeVersions();
await selectItem(currentItem.id);
loadTree();
} else showToast(data.message || '恢复失败', 'error');
}
// ── 分享 ──
function openShare() {
if (!currentItem || !currentItem.id) { showToast('请先选择一篇笔记', 'error'); return; }
if (currentItem.is_folder) { showToast('目录不能分享', 'error'); return; }
const modal = document.getElementById('shareModal');
modal.style.display = 'flex';
if (currentItem.share_token) {
document.getElementById('shareNoToken').style.display = 'none';
document.getElementById('shareHasToken').style.display = 'block';
document.getElementById('shareUrl').value = location.origin + '/share/' + currentItem.share_token;
} else {
document.getElementById('shareNoToken').style.display = 'block';
document.getElementById('shareHasToken').style.display = 'none';
}
}
function closeShare() {
document.getElementById('shareModal').style.display = 'none';
}
async function createShare() {
const expire = document.getElementById('shareExpire').value;
const fd = new FormData();
fd.append('expire_hours', expire);
const res = await fetch(`${ADMIN_API}/notes/${currentItem.id}/share`, { method: 'POST', body: fd });
const data = await res.json();
if (data.code === 0) {
showToast('分享链接已生成', 'success');
currentItem.share_token = data.data.share_token;
openShare();
} else showToast(data.message || '生成失败', 'error');
}
async function revokeShare() {
const res = await fetch(`${ADMIN_API}/notes/${currentItem.id}/revoke-share`, { method: 'POST' });
const data = await res.json();
if (data.code === 0) {
showToast('已撤销分享', 'success');
currentItem.share_token = '';
openShare();
} else showToast(data.message || '操作失败', 'error');
}
function copyShareUrl() {
const url = document.getElementById('shareUrl').value;
if (navigator.clipboard) {
navigator.clipboard.writeText(url).then(() => showToast('链接已复制', 'success')).catch(() => showToast('复制失败', 'error'));
} else {
document.getElementById('shareUrl').select();
document.execCommand('copy');
showToast('链接已复制', 'success');
}
}
// 初始化深色模式
initDarkMode();
</script> </script>
</body> </body>
</html> </html>
+173
View File
@@ -0,0 +1,173 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>分享的笔记 - 云笔记</title>
<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; line-height: 1.7;
}
.container { max-width: 860px; margin: 40px auto; background: #fff; padding: 48px; border-radius: 10px; box-shadow: 0 1px 6px rgba(0,0,0,0.08); }
.badge { display: inline-block; background: #e8f0fe; color: #1a73e8; padding: 4px 12px; border-radius: 20px; font-size: 13px; margin-bottom: 16px; }
h1 { font-size: 28px; margin-bottom: 12px; color: #1a1a1a; }
.meta { color: #888; font-size: 14px; margin-bottom: 24px; padding-bottom: 16px; border-bottom: 1px solid #eee; }
.tag { display: inline-block; background: #f1f3f4; color: #5f6368; padding: 2px 10px; border-radius: 12px; font-size: 13px; margin-right: 6px; }
.note-content { word-wrap: break-word; }
.note-content h1,.note-content h2,.note-content h3,.note-content h4 { margin: 20px 0 10px; color: #1a1a1a; }
.note-content p { margin: 10px 0; }
.note-content pre { background: #f6f8fa; padding: 14px; border-radius: 6px; overflow-x: auto; }
.note-content code { background: #f6f8fa; padding: 2px 5px; border-radius: 3px; font-size: 14px; }
.note-content pre code { background: none; padding: 0; }
.note-content img { max-width: 100%; }
.note-content blockquote { border-left: 4px solid #ddd; padding-left: 14px; color: #666; margin: 12px 0; }
.note-content table { border-collapse: collapse; margin: 12px 0; }
.note-content th,.note-content td { border: 1px solid #ddd; padding: 8px 12px; }
.note-content a { color: #1a73e8; }
.password-prompt { text-align: center; padding: 60px 20px; }
.password-prompt input { padding: 10px 14px; border: 1px solid #ccc; border-radius: 6px; width: 260px; font-size: 14px; }
.password-prompt button { padding: 10px 24px; background: #1a73e8; color: #fff; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; margin-left: 8px; }
.footer { text-align: center; color: #bbb; font-size: 13px; margin: 24px 0 40px; }
.hidden { display: none; }
.mermaid { text-align: center; margin: 16px 0; }
.task-done { text-decoration: line-through; color: #888; }
</style>
</head>
<body>
<div class="container">
<span class="badge">📄 分享的笔记</span>
<div id="noteDetail" class="hidden">
<h1 id="noteTitle"></h1>
<div class="meta">
<span>分类:<span id="noteCategory">未分类</span></span>
<span style="margin-left:16px" id="noteDate"></span>
<div style="margin-top:10px" id="noteTags"></div>
</div>
<div class="note-content" id="noteContent"></div>
</div>
<div class="password-prompt" id="passwordPrompt" style="display:none">
<h3>🔒 该笔记已加密</h3>
<p style="color:#888;margin:10px 0 20px">请输入访问密码查看内容</p>
<input type="password" id="passwordInput" placeholder="输入访问密码">
<button onclick="accessWithPassword()">查看</button>
<p id="pwdError" style="color:#d32f2f;margin-top:12px"></p>
</div>
<div id="errorBox" class="password-prompt" style="display:none">
<h3>😕 无法访问</h3>
<p id="errorMsg" style="color:#888;margin-top:10px"></p>
</div>
</div>
<div class="footer">Powered by 云笔记</div>
<script>
const TOKEN = '{{.token}}';
const API = '/api/share/' + TOKEN;
async function load(password) {
let url = API;
if (password) url += '?password=' + encodeURIComponent(password);
const res = await fetch(url);
const data = await res.json();
if (data.code === 0) {
render(data.data);
} else if (res.status === 401) {
document.getElementById('passwordPrompt').style.display = 'block';
document.getElementById('passwordPrompt').style.display = 'flex';
} else {
document.getElementById('errorBox').style.display = 'block';
document.getElementById('errorMsg').textContent = data.message || '笔记不存在或链接已失效';
}
}
async function accessWithPassword() {
const pwd = document.getElementById('passwordInput').value;
document.getElementById('pwdError').textContent = '';
let url = API + '?password=' + encodeURIComponent(pwd);
const res = await fetch(url);
const data = await res.json();
if (data.code === 0) {
document.getElementById('passwordPrompt').style.display = 'none';
render(data.data);
} else {
document.getElementById('pwdError').textContent = '密码错误,请重试';
}
}
function render(note) {
document.getElementById('noteDetail').classList.remove('hidden');
document.getElementById('noteTitle').textContent = note.title;
if (note.category) document.getElementById('noteCategory').textContent = note.category;
if (note.created_at) {
const d = new Date(note.created_at);
document.getElementById('noteDate').textContent = '发布于 ' + d.toLocaleString('zh-CN');
}
const tagsBox = document.getElementById('noteTags');
try {
const tags = JSON.parse(note.tags || '[]');
if (Array.isArray(tags) && tags.length) {
tagsBox.innerHTML = tags.map(t => `<span class="tag">#${escapeHtml(t)}</span>`).join('');
}
} catch(e) {}
document.getElementById('noteContent').innerHTML = renderMarkdown(note.content || '');
// 高亮代码
document.querySelectorAll('pre code').forEach(block => {
try { hljs.highlightBlock(block); } catch(e){}
});
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 简单 Markdown 渲染(支持代码块、标题、列表、表格、链接、图片、加粗、待办清单)
function renderMarkdown(text) {
let html = escapeHtml(text);
// 代码块
html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, function(m, lang, code) {
return '<pre><code class="language-' + (lang || '') + '">' + code + '</code></pre>';
});
// 行内代码
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
// 待办清单
html = html.replace(/\[ \] /g, '☐ ');
html = html.replace(/\[x\] /g, '☑ ');
// 标题
html = html.replace(/^### (.*)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.*)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.*)$/gm, '<h1>$1</h1>');
// 图片
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">');
// 链接
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
// 加粗
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
// 引用
html = html.replace(/^&gt; (.*)$/gm, '<blockquote>$1</blockquote>');
// 无序列表
html = html.replace(/^- (.*)$/gm, '<li>$1</li>');
html = html.replace(/(<li>[\s\S]*?<\/li>)/g, function(m){ return m.includes('<li></li>') ? m : '<ul>'+m+'</ul>'; });
// 表格
html = html.replace(/\|(.+)\|\n\|[\s-|]+\|\n((?:\|[^|]+\|\n?)*)/g, function(m, head, body) {
const hd = head.split('|').filter((x,i)=>i!==0 && i!==head.split('|').length-1).map(c=>`<th>${c.trim()}</th>`).join('');
const rows = body.split('\n').filter(r=>r.trim()).map(r => {
const cells = r.split('|').filter((x,i)=>i!==0 && i!==r.split('|').length-1).map(c=>`<td>${c.trim()}</td>`).join('');
return `<tr>${cells}</tr>`;
}).join('');
return `<table><thead><tr>${hd}</tr></thead><tbody>${rows}</tbody></table>`;
});
// 段落
html = html.replace(/\n{2,}/g, '</p><p>');
html = '<p>' + html + '</p>';
return html;
}
load();
</script>
</body>
</html>
+95 -6
View File
@@ -7,6 +7,15 @@
<!-- Highlight.js 代码高亮 --> <!-- Highlight.js 代码高亮 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css"> <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> <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<script>
// 初始化 Mermaid(延迟到 DOM 加载)
window.addEventListener('load', function() {
try {
mermaid.initialize({ startOnLoad: false, theme: 'default', securityLevel: 'loose' });
} catch(e) { console.warn('mermaid init failed', e); }
});
</script>
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { body {
@@ -14,6 +23,23 @@
background: #fafafa; background: #fafafa;
color: #333; color: #333;
} }
/* 待办清单 */
.task-item { list-style: none; margin: 2px 0; }
.task-checkbox { margin-right: 6px; vertical-align: middle; width: 15px; height: 15px; }
.task-done { text-decoration: line-through; color: #888; }
.mermaid { text-align: center; margin: 16px 0; overflow-x: auto; }
/* ───── 深色模式覆盖 ───── */
body.dark { background: #1a1d21; color: #e0e0e0; }
body.dark header { background: #20242a; box-shadow: 0 1px 3px rgba(0,0,0,0.4); }
body.dark .sidebar { background: #23272e; border-color: #333; }
body.dark .main { background: #1a1d21; }
body.dark .search-box { background: #2d323a; }
body.dark .search-box input { color: #e0e0e0; }
body.dark .note-detail { background: #1e2228; }
body.dark .note-content pre, body.dark .note-content code { background: #111; }
body.dark .tree-item:hover { background: #2d323a; }
body.dark .toc-sidebar { border-color: #333; }
body.dark a { color: #7ab8ff; }
header { header {
background: #fff; background: #fff;
padding: 16px 24px; padding: 16px 24px;
@@ -426,6 +452,7 @@
<input type="text" id="searchInput" placeholder="搜索笔记..." onkeyup="handleSearch(event)"> <input type="text" id="searchInput" placeholder="搜索笔记..." onkeyup="handleSearch(event)">
</div> </div>
<a href="/admin/" style="color: #666; text-decoration: none; font-size: 14px;">管理</a> <a href="/admin/" style="color: #666; text-decoration: none; font-size: 14px;">管理</a>
<button onclick="toggleSiteDark()" id="darkBtn" style="background:none;border:1px solid #ccc;border-radius:20px;padding:5px 12px;font-size:13px;cursor:pointer;color:#666;">🌙 深色</button>
</div> </div>
</header> </header>
@@ -471,6 +498,7 @@
<div class="meta"> <div class="meta">
<span id="noteCategory"></span> <span id="noteCategory"></span>
<span id="noteDate"></span> <span id="noteDate"></span>
<span id="noteStats" style="color:#999;font-size:12px;margin-left:8px;"></span>
<div class="tags" id="noteTags"></div> <div class="tags" id="noteTags"></div>
</div> </div>
<div class="note-content" id="noteContent"></div> <div class="note-content" id="noteContent"></div>
@@ -659,8 +687,20 @@
const renderedContent = renderMarkdown(currentNote.content || ''); const renderedContent = renderMarkdown(currentNote.content || '');
document.getElementById('noteContent').innerHTML = renderedContent; document.getElementById('noteContent').innerHTML = renderedContent;
// 字数统计
const statsEl = document.getElementById('noteStats');
if (statsEl) {
const text = (currentNote.content || '').replace(/\s+/g, '');
const words = text.length;
const readMin = Math.max(1, Math.ceil(words / 400));
statsEl.textContent = `${words} 字 · 约 ${readMin} 分钟阅读`;
}
// 生成目录 // 生成目录
generateTOC(currentNote.content || ''); generateTOC(currentNote.content || '');
// 渲染 mermaid 图表(异步)
setTimeout(renderMermaid, 50);
} }
function generateTOC(content) { function generateTOC(content) {
@@ -803,6 +843,29 @@
} }
// 简单的 Markdown 渲染 // 简单的 Markdown 渲染
// Mermaid 图表渲染支持
let mmdCounter = 0;
let mmdItems = [];
// 渲染页面中的 mermaid 图表
function renderMermaid() {
if (typeof mermaid === 'undefined' || !mmdItems.length) return;
const items = mmdItems.splice(0, mmdItems.length);
items.forEach(item => {
const el = document.getElementById(item.uid);
if (!el) return;
try {
mermaid.render(item.uid, item.code, el).then(({svg}) => {
el.innerHTML = svg;
}).catch(() => {
el.innerHTML = '<pre style="color:#c0392b;background:#fdf0f0;padding:12px;border-radius:6px;">Mermaid 渲染失败(请检查图表语法)</pre>';
});
} catch(e) {
el.innerHTML = '<pre style="color:#c0392b;background:#fdf0f0;padding:12px;border-radius:6px;">Mermaid 渲染失败(请检查图表语法)</pre>';
}
});
}
function renderMarkdown(text) { function renderMarkdown(text) {
if (!text) return '<p style="color: #999;">无内容</p>'; if (!text) return '<p style="color: #999;">无内容</p>';
@@ -817,7 +880,6 @@
index++; index++;
return placeholder; return placeholder;
}); });
// 用占位符保护图片,避免被后续处理影响 // 用占位符保护图片,避免被后续处理影响
const imgPlaceholders = []; const imgPlaceholders = [];
text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function(match, alt, src) { text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function(match, alt, src) {
@@ -850,13 +912,19 @@
// 处理行内代码 // 处理行内代码
text = text.replace(/`([^`]+)`/g, '<code>$1</code>'); text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
// 恢复代码块并应用高亮 // 恢复代码块并应用高亮mermaid 代码块渲染为图表)
codeBlocks.forEach((block, i) => { codeBlocks.forEach((block, i) => {
let rendered;
if (block.lang === 'mermaid') {
// 生成 mermaid 图表占位
const uid = 'mmd_' + (++mmdCounter);
mmdItems.push({ uid: uid, code: block.code });
rendered = `<div class="mermaid" id="${uid}">${block.code}</div>`;
} else {
const highlighted = hljs.highlightAuto(block.code, block.lang !== 'plaintext' ? [block.lang] : undefined).value; const highlighted = hljs.highlightAuto(block.code, block.lang !== 'plaintext' ? [block.lang] : undefined).value;
text = text.replace( rendered = `<pre><code class="hljs language-${block.lang}">${highlighted}</code></pre>`;
`__CODE_BLOCK_${i}__`, }
`<pre><code class="hljs language-${block.lang}">${highlighted}</code></pre>` text = text.replace(`__CODE_BLOCK_${i}__`, rendered);
);
}); });
// 标题(带 id 用于目录导航) // 标题(带 id 用于目录导航)
@@ -881,6 +949,13 @@
// 删除线 // 删除线
text = text.replace(/~~(.+?)~~/g, '<del>$1</del>'); text = text.replace(/~~(.+?)~~/g, '<del>$1</del>');
// 待办清单(- [ ] / - [x]
text = text.replace(/^[-*] \[([ xX])\] (.*$)/gm, (m, checked, content) => {
const state = (checked === 'x' || checked === 'X') ? 'checked' : '';
const cls = state ? 'task-done' : '';
return `<li class="task-item"><input type="checkbox" class="task-checkbox" ${state} disabled> <span class="${cls}">${content}</span></li>`;
});
// 引用 // 引用
text = text.replace(/^> (.*$)/gm, '<blockquote>$1</blockquote>'); text = text.replace(/^> (.*$)/gm, '<blockquote>$1</blockquote>');
@@ -898,7 +973,21 @@
return `<p>${text}</p>`; return `<p>${text}</p>`;
} }
// 深色模式切换(前台)
function toggleSiteDark() {
const isDark = document.body.classList.toggle('dark');
localStorage.setItem('nm_dark', isDark ? '1' : '0');
document.getElementById('darkBtn').textContent = isDark ? '☀️ 白天' : '🌙 深色';
}
function initSiteDark() {
if (localStorage.getItem('nm_dark') === '1') {
document.body.classList.add('dark');
document.getElementById('darkBtn').textContent = '☀️ 白天';
}
}
init(); init();
initSiteDark();
</script> </script>
</body> </body>
</html> </html>