feat: 初始化云笔记项目
功能特性: - Markdown 编辑与实时预览 - 代码语法高亮 - 目录树形结构管理 - 图片粘贴上传 - Markdown 文件导入导出 - 笔记密码保护 - 前后端分离架构 技术栈: - Go + Gin + GORM + SQLite - 原生 HTML/CSS/JavaScript - Highlight.js
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"note-manager/config"
|
||||
"note-manager/service"
|
||||
)
|
||||
|
||||
// AdminHandler 后台管理处理器
|
||||
type AdminHandler struct {
|
||||
noteSvc *service.NoteService
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
// NewAdminHandler 创建后台管理处理器
|
||||
func NewAdminHandler(noteSvc *service.NoteService, cfg *config.Config) *AdminHandler {
|
||||
return &AdminHandler{noteSvc: noteSvc, config: cfg}
|
||||
}
|
||||
|
||||
// Login 登录页面
|
||||
func (h *AdminHandler) LoginPage(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "login.html", gin.H{
|
||||
"title": "后台管理登录",
|
||||
})
|
||||
}
|
||||
|
||||
// Login 验证登录
|
||||
func (h *AdminHandler) Login(c *gin.Context) {
|
||||
password := c.PostForm("password")
|
||||
if password == h.config.AdminPass {
|
||||
// 设置 cookie,有效期 7 天
|
||||
c.SetCookie("admin_token", "authenticated", 7*24*3600, "/", "", false, true)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"message": "登录成功",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"code": 401,
|
||||
"message": "密码错误",
|
||||
})
|
||||
}
|
||||
|
||||
// Logout 登出
|
||||
func (h *AdminHandler) Logout(c *gin.Context) {
|
||||
c.SetCookie("admin_token", "", -1, "/", "", false, true)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"message": "已退出登录",
|
||||
})
|
||||
}
|
||||
|
||||
// CheckAuth 检查是否已登录
|
||||
func (h *AdminHandler) CheckAuth(c *gin.Context) {
|
||||
token, err := c.Cookie("admin_token")
|
||||
if err == nil && token == "authenticated" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"message": "已登录",
|
||||
"data": gin.H{
|
||||
"authenticated": true,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"message": "未登录",
|
||||
"data": gin.H{
|
||||
"authenticated": false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// IndexPage 后台管理首页
|
||||
func (h *AdminHandler) IndexPage(c *gin.Context) {
|
||||
token, err := c.Cookie("admin_token")
|
||||
if err != nil || token != "authenticated" {
|
||||
c.Redirect(http.StatusFound, "/admin/login")
|
||||
return
|
||||
}
|
||||
c.HTML(http.StatusOK, "index.html", gin.H{
|
||||
"title": "笔记管理后台",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ImageHandler 图片上传处理器
|
||||
type ImageHandler struct {
|
||||
uploadDir string
|
||||
}
|
||||
|
||||
// NewImageHandler 创建图片处理器
|
||||
func NewImageHandler(uploadDir string) *ImageHandler {
|
||||
return &ImageHandler{uploadDir: uploadDir}
|
||||
}
|
||||
|
||||
// Init 初始化上传目录
|
||||
func (h *ImageHandler) Init() error {
|
||||
return os.MkdirAll(h.uploadDir, 0755)
|
||||
}
|
||||
|
||||
// Upload 上传图片
|
||||
func (h *ImageHandler) Upload(c *gin.Context) {
|
||||
file, err := c.FormFile("image")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "请选择图片文件"})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证文件类型
|
||||
ext := strings.ToLower(filepath.Ext(file.Filename))
|
||||
allowedExts := map[string]bool{
|
||||
".jpg": true,
|
||||
".jpeg": true,
|
||||
".png": true,
|
||||
".gif": true,
|
||||
".webp": true,
|
||||
".bmp": true,
|
||||
}
|
||||
|
||||
if !allowedExts[ext] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "不支持的图片格式,仅支持 jpg、png、gif、webp"})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证文件大小 (最大 5MB)
|
||||
if file.Size > 5*1024*1024 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "图片大小不能超过 5MB"})
|
||||
return
|
||||
}
|
||||
|
||||
// 生成唯一文件名
|
||||
filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomString(8), ext)
|
||||
filepath := filepath.Join(h.uploadDir, filename)
|
||||
|
||||
// 保存文件
|
||||
if err := c.SaveUploadedFile(file, filepath); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "保存图片失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 返回访问 URL
|
||||
url := "/uploads/" + filename
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"message": "上传成功",
|
||||
"data": gin.H{
|
||||
"url": url,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// randomString 生成随机字符串
|
||||
func randomString(length int) string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
result := make([]byte, length)
|
||||
for i := range result {
|
||||
result[i] = chars[time.Now().UnixNano()%int64(len(chars))]
|
||||
time.Sleep(time.Nanosecond)
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"note-manager/model"
|
||||
"note-manager/service"
|
||||
)
|
||||
|
||||
// NoteHandler 笔记请求处理器
|
||||
type NoteHandler struct {
|
||||
svc *service.NoteService
|
||||
}
|
||||
|
||||
// NewNoteHandler 创建处理器实例
|
||||
func NewNoteHandler(svc *service.NoteService) *NoteHandler {
|
||||
return &NoteHandler{svc: svc}
|
||||
}
|
||||
|
||||
// Response 通用响应结构
|
||||
type Response struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// PageResponse 分页响应结构
|
||||
type PageResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
func success(c *gin.Context, data interface{}) {
|
||||
c.JSON(http.StatusOK, Response{Code: 0, Message: "success", Data: data})
|
||||
}
|
||||
|
||||
func fail(c *gin.Context, status int, msg string) {
|
||||
c.JSON(status, Response{Code: -1, Message: msg})
|
||||
}
|
||||
|
||||
// requireAuth 需要管理员权限
|
||||
func requireAuth(c *gin.Context) bool {
|
||||
token, err := c.Cookie("admin_token")
|
||||
if err != nil || token != "authenticated" {
|
||||
c.JSON(http.StatusUnauthorized, Response{Code: 401, Message: "请先登录后台管理"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// CreateNote 创建笔记
|
||||
func (h *NoteHandler) CreateNote(c *gin.Context) {
|
||||
if !requireAuth(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var req model.NoteCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, "请求参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
note, err := h.svc.CreateNote(req)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
success(c, note)
|
||||
}
|
||||
|
||||
// GetNote 获取笔记详情
|
||||
func (h *NoteHandler) GetNote(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, "无效的笔记 ID")
|
||||
return
|
||||
}
|
||||
|
||||
note, err := h.svc.GetNote(uint(id))
|
||||
if err != nil {
|
||||
fail(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
success(c, note)
|
||||
}
|
||||
|
||||
// AccessNote 验证密码后获取笔记内容
|
||||
func (h *NoteHandler) AccessNote(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, "无效的笔记 ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
// 没有密码参数,尝试从 URL 参数获取
|
||||
req.Password = c.Query("password")
|
||||
}
|
||||
|
||||
note, err := h.svc.GetNoteContent(uint(id), req.Password)
|
||||
if err != nil {
|
||||
if err.Error() == "密码错误" {
|
||||
fail(c, http.StatusUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
fail(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
success(c, note)
|
||||
}
|
||||
|
||||
// UpdateNote 更新笔记
|
||||
func (h *NoteHandler) UpdateNote(c *gin.Context) {
|
||||
if !requireAuth(c) {
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, "无效的笔记 ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req model.NoteUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, "请求参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
note, err := h.svc.UpdateNote(uint(id), req)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
success(c, note)
|
||||
}
|
||||
|
||||
// DeleteNote 删除笔记
|
||||
func (h *NoteHandler) DeleteNote(c *gin.Context) {
|
||||
if !requireAuth(c) {
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, "无效的笔记 ID")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteNote(uint(id)); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
success(c, nil)
|
||||
}
|
||||
|
||||
// ListNotes 获取笔记列表
|
||||
func (h *NoteHandler) ListNotes(c *gin.Context) {
|
||||
var pinned *bool
|
||||
if v := c.Query("pinned"); v != "" {
|
||||
b := v == "true" || v == "1"
|
||||
pinned = &b
|
||||
}
|
||||
var favorite *bool
|
||||
if v := c.Query("favorite"); v != "" {
|
||||
b := v == "true" || v == "1"
|
||||
favorite = &b
|
||||
}
|
||||
|
||||
items, total, totalPages, err := h.svc.ListNotes(
|
||||
c.DefaultQuery("page", "1"),
|
||||
c.DefaultQuery("page_size", ""),
|
||||
c.Query("category"),
|
||||
c.Query("tag"),
|
||||
pinned,
|
||||
favorite,
|
||||
)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
page := parseIntDefault(c.DefaultQuery("page", "1"), 1)
|
||||
pageSize := parseIntDefault(c.DefaultQuery("page_size", "20"), 20)
|
||||
|
||||
c.JSON(http.StatusOK, PageResponse{
|
||||
Code: 0,
|
||||
Message: "success",
|
||||
Data: items,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
TotalPages: totalPages,
|
||||
})
|
||||
}
|
||||
|
||||
// SearchNotes 搜索笔记
|
||||
func (h *NoteHandler) SearchNotes(c *gin.Context) {
|
||||
keyword := c.Query("q")
|
||||
items, total, totalPages, err := h.svc.SearchNotes(
|
||||
keyword,
|
||||
c.DefaultQuery("page", "1"),
|
||||
c.DefaultQuery("page_size", ""),
|
||||
)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
page := parseIntDefault(c.DefaultQuery("page", "1"), 1)
|
||||
pageSize := parseIntDefault(c.DefaultQuery("page_size", "20"), 20)
|
||||
|
||||
c.JSON(http.StatusOK, PageResponse{
|
||||
Code: 0,
|
||||
Message: "success",
|
||||
Data: items,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
TotalPages: totalPages,
|
||||
})
|
||||
}
|
||||
|
||||
// GetCategories 获取分类列表
|
||||
func (h *NoteHandler) GetCategories(c *gin.Context) {
|
||||
categories, err := h.svc.GetCategories()
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
success(c, categories)
|
||||
}
|
||||
|
||||
// GetTags 获取所有标签
|
||||
func (h *NoteHandler) GetTags(c *gin.Context) {
|
||||
tags, err := h.svc.GetTags()
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
success(c, tags)
|
||||
}
|
||||
|
||||
// GetTree 获取树形结构(管理后台用)
|
||||
func (h *NoteHandler) GetTree(c *gin.Context) {
|
||||
tree, err := h.svc.GetAllTree()
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
success(c, tree)
|
||||
}
|
||||
|
||||
// GetPublicTree 获取公开树形结构(前台用)
|
||||
func (h *NoteHandler) GetPublicTree(c *gin.Context) {
|
||||
tree, err := h.svc.GetPublicTree()
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
success(c, tree)
|
||||
}
|
||||
|
||||
func parseIntDefault(s string, defaultVal int) int {
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return defaultVal
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ExportNote 导出笔记为 Markdown 文件
|
||||
func (h *NoteHandler) ExportNote(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, "无效的笔记 ID")
|
||||
return
|
||||
}
|
||||
|
||||
note, err := h.svc.GetNote(uint(id))
|
||||
if err != nil {
|
||||
fail(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 如果是目录,导出目录下所有笔记
|
||||
if note.IsFolder {
|
||||
fail(c, http.StatusBadRequest, "不支持导出目录,请选择具体笔记")
|
||||
return
|
||||
}
|
||||
|
||||
// 设置下载头
|
||||
filename := note.Title + ".md"
|
||||
c.Header("Content-Disposition", "attachment; filename*=UTF-8''"+urlEncode(filename))
|
||||
c.Header("Content-Type", "text/markdown; charset=utf-8")
|
||||
|
||||
// 添加 front matter
|
||||
frontMatter := "---\n"
|
||||
frontMatter += "title: " + note.Title + "\n"
|
||||
if note.Category != "" {
|
||||
frontMatter += "category: " + note.Category + "\n"
|
||||
}
|
||||
if note.Tags != "" {
|
||||
frontMatter += "tags: " + note.Tags + "\n"
|
||||
}
|
||||
frontMatter += "---\n\n"
|
||||
|
||||
c.String(http.StatusOK, frontMatter+note.Content)
|
||||
}
|
||||
|
||||
// ImportNotes 导入 Markdown 文件创建笔记
|
||||
func (h *NoteHandler) ImportNotes(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, "请选择文件")
|
||||
return
|
||||
}
|
||||
|
||||
// 验证文件类型
|
||||
if file.Header.Get("Content-Type") != "text/markdown" &&
|
||||
!strings.HasSuffix(file.Filename, ".md") {
|
||||
fail(c, http.StatusBadRequest, "仅支持 .md 文件")
|
||||
return
|
||||
}
|
||||
|
||||
// 读取文件内容
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, "读取文件失败")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
contentBytes, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, "读取文件失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 front matter
|
||||
title, body := parseFrontMatter(string(contentBytes))
|
||||
if title == "" {
|
||||
title = strings.TrimSuffix(file.Filename, ".md")
|
||||
}
|
||||
|
||||
// 创建笔记
|
||||
req := model.NoteCreateRequest{
|
||||
Title: title,
|
||||
Content: body,
|
||||
}
|
||||
|
||||
note, err := h.svc.CreateNote(req)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
success(c, note)
|
||||
}
|
||||
|
||||
// parseFrontMatter 解析 YAML front matter
|
||||
func parseFrontMatter(content string) (title string, body string) {
|
||||
if !strings.HasPrefix(content, "---") {
|
||||
return "", content
|
||||
}
|
||||
|
||||
parts := strings.SplitN(content, "---", 3)
|
||||
if len(parts) < 3 {
|
||||
return "", content
|
||||
}
|
||||
|
||||
frontMatter := parts[1]
|
||||
body = strings.TrimSpace(parts[2])
|
||||
|
||||
// 解析 title
|
||||
for _, line := range strings.Split(frontMatter, "\n") {
|
||||
if strings.HasPrefix(line, "title:") {
|
||||
title = strings.TrimSpace(strings.TrimPrefix(line, "title:"))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return title, body
|
||||
}
|
||||
|
||||
// urlEncode URL 编码(RFC 3986)
|
||||
func urlEncode(s string) string {
|
||||
return url.QueryEscape(s)
|
||||
}
|
||||
Reference in New Issue
Block a user