feat: v3 增强 - FTS5全文搜索+双向链接/知识图谱+自动保存草稿+标签管理+实时预览编辑器+PWA

- FTS5中文分词搜索(需-tags=sqlite_fts5)
- [[wiki链接]]反向链接+SVG知识图谱
- 自动保存草稿(不触发版本历史)
- 标签重命名/合并/删除+使用统计
- 后台编辑实时预览+格式工具栏
- PWA manifest+service worker+移动端适配
- 冒烟测试扩至66例全通过
This commit is contained in:
Your Name
2026-08-11 12:38:41 +08:00
parent cbfaac3d4f
commit b93c6f1e16
14 changed files with 1720 additions and 16 deletions
+60
View File
@@ -656,3 +656,63 @@ await fetch('/api/notes', {
- **待办清单**:前台渲染 `- [ ]` / `- [x]` 可勾选清单
- **字数统计**:前台笔记详情显示字数与预估阅读时长
- **回收站/版本历史/分享**:后台工具栏按钮 + 面板
## v3 新增功能(2026-08-11
### FTS5 全文搜索
`GET /api/notes/search?q=关键词&page=1` 现使用 **SQLite FTS5 全文索引**,支持中文短词(1~2 字)与英文。
- 采用 `unicode61` 分词器 + Go 层对中文做逐字分词(`segmentCJK`),使每个汉字独立成词元,支持短词搜索
- 索引在创建/更新/标签变更时增量维护,启动时全量重建
- 搜索结果按 `bm25` 相关度排序,排除已删除笔记与目录
- FTS 查询失败时自动回退到传统 LIKE 搜索
- 构建需启用 `-tags=sqlite_fts5`(否则报 `no such module: fts5`
### 双向链接 / 知识图谱
支持 `[[笔记标题]]` wiki 链接语法:
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/admin/api/notes/:id/backlinks` | 反向链接(谁链接到了该笔记) |
| GET | `/admin/api/graph` | 知识图谱数据 `{nodes:[{id,title}], edges:[{source,target}]}` |
- 后台编辑器支持点击 `[[链接]]` 跳转并高亮
- 后台工具栏「🕸 知识图谱」以 SVG 图形化展示节点与链接关系
### 自动保存草稿
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/admin/api/notes/:id/draft` | 保存草稿(body `{content}`),不触发版本历史 |
| DELETE | `/admin/api/notes/:id/draft` | 清除草稿(保存正文成功后调用) |
- 后台编辑器每 8 秒自动保存未落盘内容
- 打开笔记时若存在未保存草稿会提示恢复
### 标签管理
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/admin/api/tags/usage` | 标签使用统计 `[{name,count}]` |
| POST | `/admin/api/tags/rename` | 重命名 `{old_name,new_name}` |
| POST | `/admin/api/tags/merge` | 合并 `{from,to}`from 并入 to |
| DELETE | `/admin/api/tags` | 删除 `{name}` |
- 后台工具栏「🏷 标签管理」图形化操作
- 重命名/合并/删除会同步更新所有含该标签的笔记及其全文索引
### 富文本所见即所得(编辑器增强)
- 后台编辑器新增**实时预览**:编辑右侧即时渲染 Markdown
- 新增**格式工具栏**:加粗/斜体/标题/列表/链接/代码/图片/表格/任务清单
- 仍以 Markdown 为存储源(保证 FTS/反向链接/导出兼容),预览所见即所得
### 移动端 / PWA
- 新增 PWA 支持:`/manifest.json``/sw.js`、图标 `icon-192.png`/`icon-512.png`
- 前台页面响应式适配手机(侧栏折叠、字号/按钮优化)
- 可添加到主屏幕离线使用
> 注:FTS5 需用 `-tags=sqlite_fts5` 编译;`note_search` 虚拟表由 Go 代码维护(手动管理而非 SQL 触发器),保证中文分词正确。
Vendored
+2
View File
@@ -86,6 +86,7 @@ pipeline {
echo "时间: ${BUILD_TIME}"
# go-sqlite3 需要 CGO_ENABLED=1,用完整 golang 镜像确保 gcc 可用
# 必须带 -tags=sqlite_fts5 以启用 FTS5 全文搜索
docker run --rm \
-v "$PWD":/app \
-w /app \
@@ -94,6 +95,7 @@ pipeline {
-e GOARCH=amd64 \
golang:${GO_VERSION} \
go build \
-tags=sqlite_fts5 \
-ldflags="-s -w \
-X main.Version=${VERSION} \
-X main.BuildTime=${BUILD_TIME} \
+129
View File
@@ -661,3 +661,132 @@ func sanitizeFileName(name string) string {
}
return name
}
// ─────────────── 自动保存草稿 ───────────────
// SaveDraft 保存笔记草稿(自动保存)
func (h *NoteHandler) SaveDraft(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
var req struct {
Content string `json:"content"`
}
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求参数错误")
return
}
if err := h.svc.SaveDraft(uint(id), req.Content); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, nil)
}
// DiscardDraft 清除指定笔记的草稿
func (h *NoteHandler) DiscardDraft(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.ClearDraft(uint(id)); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, nil)
}
// ─────────────── 双向链接 / 知识图谱 ───────────────
// GetBacklinks 获取指定笔记的反向链接列表
func (h *NoteHandler) GetBacklinks(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "无效的笔记 ID")
return
}
links, err := h.svc.GetBacklinks(uint(id), "")
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, links)
}
// GetKnowledgeGraph 获取知识图谱数据(节点 + 边)
func (h *NoteHandler) GetKnowledgeGraph(c *gin.Context) {
graph, err := h.svc.GetKnowledgeGraph()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, graph)
}
// ─────────────── 标签管理 ───────────────
// GetTagUsage 获取标签及使用次数
func (h *NoteHandler) GetTagUsage(c *gin.Context) {
usage, err := h.svc.GetTagUsage()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
success(c, usage)
}
// RenameTag 重命名标签
func (h *NoteHandler) RenameTag(c *gin.Context) {
var req struct {
OldName string `json:"old_name" binding:"required"`
NewName string `json:"new_name" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求参数错误")
return
}
changed, err := h.svc.RenameTag(req.OldName, req.NewName)
if err != nil {
fail(c, http.StatusBadRequest, err.Error())
return
}
success(c, gin.H{"changed": changed})
}
// MergeTag 合并标签(from → to
func (h *NoteHandler) MergeTag(c *gin.Context) {
var req struct {
From string `json:"from" binding:"required"`
To string `json:"to" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求参数错误")
return
}
changed, err := h.svc.MergeTag(req.From, req.To)
if err != nil {
fail(c, http.StatusBadRequest, err.Error())
return
}
success(c, gin.H{"changed": changed})
}
// DeleteTag 删除标签(从所有笔记中移除)
func (h *NoteHandler) DeleteTag(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求参数错误")
return
}
changed, err := h.svc.DeleteTag(req.Name)
if err != nil {
fail(c, http.StatusBadRequest, err.Error())
return
}
success(c, gin.H{"changed": changed})
}
+7
View File
@@ -59,6 +59,7 @@ type Note struct {
ID uint `json:"id" gorm:"primaryKey"`
Title string `json:"title" gorm:"size:255;not null"`
Content string `json:"content" gorm:"type:text"`
DraftContent string `json:"draft_content,omitempty" gorm:"type:text"` // 未保存的草稿(自动保存用)
Category string `json:"category" gorm:"size:100;index"`
Tags string `json:"tags" gorm:"type:text"` // JSON 数组格式存储
Password string `json:"-" gorm:"size:255"` // 访问密码(哈希存储)
@@ -147,3 +148,9 @@ type NoteVersion struct {
func (NoteVersion) TableName() string {
return "note_versions"
}
// TagUsage 标签使用统计
type TagUsage struct {
Name string `json:"name"`
Count int `json:"count"`
}
+259 -4
View File
@@ -1,10 +1,12 @@
package repository
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
@@ -37,13 +39,156 @@ func NewNoteRepository(dbPath string) (*NoteRepository, error) {
return nil, fmt.Errorf("数据库迁移失败: %w", err)
}
return &NoteRepository{db: db}, nil
repo := &NoteRepository{db: db}
// 初始化 FTS5 全文搜索(含中文 trigram 分词)
if err := repo.initFTS5(); err != nil {
return nil, fmt.Errorf("初始化全文搜索失败: %w", err)
}
return repo, nil
}
// ─────────────── FTS5 全文搜索 ───────────────
// initFTS5 创建 FTS5 虚拟表并全量重建索引。
// 采用 unicode61 分词器 + 在 Go 层对中文做逐字分词(segmentCJK),
// 使中文搜索支持 1~2 个字符(trigram 只支持 3 字以上,不适合中文短词)。
func (r *NoteRepository) initFTS5() error {
// 独立 FTS5 表(不依赖外部内容表,由 Go 代码维护,便于中文分词)
createSQL := `
CREATE VIRTUAL TABLE IF NOT EXISTS note_search USING fts5(
title, content, category, tags,
tokenize='unicode61 remove_diacritics 2'
);`
if err := r.db.Exec(createSQL).Error; err != nil {
return err
}
// 启动时全量重建索引(低成本,保证索引与数据一致)
return r.rebuildAllFTS()
}
// segmentCJK 在中文/日文/韩文等连写字符之间插入空格,并在 CJK 与 ASCII 之间也插入空格,
// 使 unicode61 分词器能把每个汉字作为独立词元索引(支持短词),同时保留英文单词。
func segmentCJK(s string) string {
if s == "" {
return ""
}
isCJKChar := func(r rune) bool {
return (r >= 0x4E00 && r <= 0x9FFF) || // CJK 统一表意文字
(r >= 0x3040 && r <= 0x30FF) || // 日文平假名/片假名
(r >= 0xAC00 && r <= 0xD7AF) || // 韩文
(r >= 0x3400 && r <= 0x4DBF) // CJK 扩展 A
}
var b strings.Builder
first := true
var prevIsCJK bool
for _, r := range s {
cur := isCJKChar(r)
if !first && cur != prevIsCJK {
b.WriteRune(' ')
} else if !first && cur && prevIsCJK {
// 连续 CJK 字符之间也加空格,使每个字独立
b.WriteRune(' ')
}
b.WriteRune(r)
prevIsCJK = cur
first = false
}
return b.String()
}
// rebuildNoteFTS 重建某篇笔记的全文索引行(先删后插)
func (r *NoteRepository) rebuildNoteFTS(id uint) error {
var note model.Note
if err := r.db.Unscoped().First(&note, id).Error; err != nil {
// 笔记不存在则清除索引行
_ = r.db.Exec(`DELETE FROM note_search WHERE rowid = ?`, id).Error
return nil
}
sTitle := segmentCJK(note.Title)
sContent := segmentCJK(note.Content)
sCategory := segmentCJK(note.Category)
sTags := segmentCJK(note.Tags)
if err := r.db.Exec(`DELETE FROM note_search WHERE rowid = ?`, id).Error; err != nil {
return err
}
return r.db.Exec(`
INSERT INTO note_search(rowid, title, content, category, tags)
VALUES (?, ?, ?, ?, ?)`,
id, sTitle, sContent, sCategory, sTags).Error
}
// rebuildAllFTS 全量重建所有未删除笔记的索引(先清空再重建)
func (r *NoteRepository) rebuildAllFTS() error {
// 常规 FTS5 表用 DELETE 清空所有行
if err := r.db.Exec(`DELETE FROM note_search`).Error; err != nil {
return err
}
var ids []uint
if err := r.db.Model(&model.Note{}).Where("is_folder = ?", false).Pluck("id", &ids).Error; err != nil {
return err
}
for _, id := range ids {
if err := r.rebuildNoteFTS(id); err != nil {
return err
}
}
return nil
}
// FTS5Search 使用全文索引搜索(同时支持英文与中文短词)
// 返回匹配的笔记列表(排除已删除与目录)
func (r *NoteRepository) FTS5Search(keyword string, page, pageSize int) ([]model.NoteListItem, int64, error) {
// 查询词同样做中文逐字分词,与索引侧保持一致
keyword = strings.TrimSpace(segmentCJK(keyword))
if keyword == "" {
return nil, 0, nil
}
var total int64
// 先统计匹配总数(通过 FTS 表 join 到 notes 过滤目录与已删除)
countQuery := `
SELECT COUNT(*) FROM note_search s
JOIN notes n ON n.id = s.rowid
WHERE note_search MATCH ? AND n.is_folder = 0
AND (n.deleted_at IS NULL OR n.deleted_at = '')`
if err := r.db.Raw(countQuery, keyword).Scan(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
var items []model.NoteListItem
err := r.db.Raw(`
SELECT n.id, n.title, n.category, n.tags,
CASE WHEN n.password != '' THEN 1 ELSE 0 END AS has_password,
n.is_pinned, n.is_favorite, n.is_public, n.parent_id, n.is_folder,
n.sort_order, n.visit_count, n.created_at, n.updated_at
FROM note_search s
JOIN notes n ON n.id = s.rowid
WHERE note_search MATCH ? AND n.is_folder = 0
AND (n.deleted_at IS NULL OR n.deleted_at = '')
ORDER BY bm25(note_search), n.updated_at DESC
LIMIT ? OFFSET ?`,
keyword, pageSize, offset,
).Scan(&items).Error
if err != nil {
return nil, 0, err
}
return items, total, nil
}
// Create 创建笔记
func (r *NoteRepository) Create(note *model.Note) error {
res := r.db.Select("Title", "Content", "Category", "Tags", "Password", "IsPinned", "IsFavorite", "IsPublic", "ParentID", "IsFolder", "SortOrder", "ShareToken", "ShareExpireAt", "VisitCount").Create(note)
res := r.db.Select("Title", "Content", "DraftContent", "Category", "Tags", "Password", "IsPinned", "IsFavorite", "IsPublic", "ParentID", "IsFolder", "SortOrder", "ShareToken", "ShareExpireAt", "VisitCount").Create(note)
if res.Error != nil {
return res.Error
}
// 建立全文索引(目录不索引)
if !note.IsFolder {
return r.rebuildNoteFTS(note.ID)
}
return nil
}
// GetByID 根据 ID 获取笔记(排除已删除)
@@ -68,12 +213,35 @@ func (r *NoteRepository) GetByIDIncludingDeleted(id uint) (*model.Note, error) {
// Update 更新笔记
func (r *NoteRepository) Update(note *model.Note) error {
return r.db.Save(note).Error
if err := r.db.Save(note).Error; err != nil {
return err
}
// 更新全文索引(目录不索引)
if !note.IsFolder {
return r.rebuildNoteFTS(note.ID)
}
return nil
}
// 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
if err := r.db.Model(&model.Note{}).Where("id = ?", id).Updates(fields).Error; err != nil {
return err
}
// 若更新涉及可索引字段(标题/内容/分类/标签),同步重建索引
if _, ok := fields["Title"]; ok {
return r.rebuildNoteFTS(id)
}
if _, ok := fields["Content"]; ok {
return r.rebuildNoteFTS(id)
}
if _, ok := fields["Category"]; ok {
return r.rebuildNoteFTS(id)
}
if _, ok := fields["Tags"]; ok {
return r.rebuildNoteFTS(id)
}
return nil
}
// Delete 软删除笔记
@@ -331,6 +499,93 @@ func (r *NoteRepository) GetTags() ([]string, error) {
return result, nil
}
// ─────────────── 标签管理 ───────────────
// UpdateTagAll 将所有笔记中出现的指定标签重命名/合并/删除。
// oldTag 为要操作的旧标签;newTag 传入新名称实现重命名,传空字符串则删除该标签。
func (r *NoteRepository) UpdateTagAll(oldTag, newTag string) (int64, error) {
var notes []model.Note
if err := r.db.Where("tags LIKE ?", fmt.Sprintf("%%\"%s\"%%", oldTag)).
Where("is_folder = ?", false).
Find(&notes).Error; err != nil {
return 0, err
}
changed := int64(0)
for i := range notes {
var tagSlice []string
if json.Unmarshal([]byte(notes[i].Tags), &tagSlice) != nil {
continue
}
// 过滤掉旧标签;若 newTag 非空则加入新标签(避免重复)
seen := make(map[string]bool)
var newSlice []string
for _, t := range tagSlice {
if t == oldTag {
changed++
if newTag != "" && !seen[newTag] {
newSlice = append(newSlice, newTag)
seen[newTag] = true
}
continue
}
if !seen[t] {
newSlice = append(newSlice, t)
seen[t] = true
}
}
newJSON, _ := json.Marshal(newSlice)
if err := r.db.Model(&model.Note{}).Where("id = ?", notes[i].ID).
Update("tags", string(newJSON)).Error; err != nil {
return changed, err
}
// 标签变化,同步全文索引
_ = r.rebuildNoteFTS(notes[i].ID)
}
return changed, nil
}
// ─────────────── 双向链接 / 知识图谱 ───────────────
// GetAllNotesLight 获取所有未删除笔记的 id、标题 与 标签(用于解析 [[wiki链接]] 与标签统计)
func (r *NoteRepository) GetAllNotesLight() ([]model.NoteListItem, error) {
var items []model.NoteListItem
err := r.db.Model(&model.Note{}).
Where("is_folder = ?", false).
Select("id, title, tags").Find(&items).Error
return items, err
}
// GetAllLinks 获取所有未删除笔记的 id、标题、内容(用于扫描双向链接与构建图谱)
// 仅返回轻量字段以降低内存占用
func (r *NoteRepository) GetAllContentLight() ([]struct {
ID uint `gorm:"column:id"`
Title string `gorm:"column:title"`
Content string `gorm:"column:content"`
}, error) {
var items []struct {
ID uint `gorm:"column:id"`
Title string `gorm:"column:title"`
Content string `gorm:"column:content"`
}
err := r.db.Model(&model.Note{}).
Where("is_folder = ?", false).
Select("id, title, content").Find(&items).Error
return items, err
}
// GetByIDs 批量获取笔记(用于解析链接指向的笔记是否存在)
func (r *NoteRepository) GetByIDs(ids []uint) ([]model.NoteListItem, error) {
if len(ids) == 0 {
return nil, nil
}
var items []model.NoteListItem
err := r.db.Model(&model.Note{}).
Where("id IN ?", ids).
Select("id, title").Find(&items).Error
return items, err
}
// ─────────────── 版本历史 ───────────────
// SaveVersion 保存笔记新版本快照
+20
View File
@@ -66,6 +66,20 @@ func Setup(r *gin.Engine, noteHandler *handler.NoteHandler, adminHandler *handle
// 完整树 + 图片上传
adminApi.GET("/tree", noteHandler.GetTree)
adminApi.POST("/upload", imageHandler.Upload)
// 自动保存草稿
adminApi.POST("/notes/:id/draft", noteHandler.SaveDraft)
adminApi.DELETE("/notes/:id/draft", noteHandler.DiscardDraft)
// 双向链接 / 知识图谱
adminApi.GET("/notes/:id/backlinks", noteHandler.GetBacklinks)
adminApi.GET("/graph", noteHandler.GetKnowledgeGraph)
// 标签管理
adminApi.GET("/tags/usage", noteHandler.GetTagUsage)
adminApi.POST("/tags/rename", noteHandler.RenameTag)
adminApi.POST("/tags/merge", noteHandler.MergeTag)
adminApi.DELETE("/tags", noteHandler.DeleteTag)
}
// ─────────── 后台管理路由 ───────────
@@ -91,5 +105,11 @@ func Setup(r *gin.Engine, noteHandler *handler.NoteHandler, adminHandler *handle
c.File("./web/index.html")
})
// PWA 静态资源(manifest / service worker / 图标)
r.StaticFile("/manifest.json", "./web/manifest.json")
r.StaticFile("/sw.js", "./web/sw.js")
r.StaticFile("/icon-192.png", "./web/icon-192.png")
r.StaticFile("/icon-512.png", "./web/icon-512.png")
return r
}
+208 -2
View File
@@ -3,9 +3,12 @@ package service
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"time"
"gorm.io/gorm"
@@ -206,7 +209,7 @@ func (s *NoteService) GetByParentID(parentID uint) ([]model.NoteListItem, error)
return s.repo.GetByParentID(parentID)
}
// SearchNotes 搜索笔记
// SearchNotes 搜索笔记(优先使用 FTS5 全文索引,含中文分词)
func (s *NoteService) SearchNotes(keyword, pageStr, pageSizeStr string) ([]model.NoteListItem, int64, int, error) {
if keyword == "" {
return nil, 0, 0, errors.New("搜索关键词不能为空")
@@ -214,11 +217,24 @@ func (s *NoteService) SearchNotes(keyword, pageStr, pageSizeStr string) ([]model
page := parseInt(pageStr, 1)
pageSize := parseInt(pageSizeStr, s.pageSize)
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = s.pageSize
}
items, total, err := s.repo.Search(keyword, page, pageSize)
// FTS5 的 MATCH 语法:对用户输入做基本转义,避免语法错误
keyword = sanitizeFTS5(keyword)
items, total, err := s.repo.FTS5Search(keyword, page, pageSize)
if err != nil {
// FTS5 失败时回退到传统 LIKE 搜索
items, total, err = s.repo.Search(keyword, page, pageSize)
if err != nil {
return nil, 0, 0, fmt.Errorf("搜索笔记失败: %w", err)
}
}
totalPages := int(total) / pageSize
if int(total)%pageSize > 0 {
@@ -228,6 +244,21 @@ func (s *NoteService) SearchNotes(keyword, pageStr, pageSizeStr string) ([]model
return items, total, totalPages, nil
}
// sanitizeFTS5 对 FTS5 查询做安全转义,处理用户输入中的特殊字符(引号、操作符等)
// 简单方案:去掉可能破坏 MATCH 语法的字符,将内容包裹为短语。
func sanitizeFTS5(q string) string {
// 移除 FTS5 特殊语法字符
var b strings.Builder
skip := map[rune]bool{'"': true, '^': true, '*': true, '(': true, ')': true, '{': true, '}': true, '[': true, ']': true, ':': true, '+': true, '-': true, '~': true}
for _, r := range q {
if skip[r] {
continue
}
b.WriteRune(r)
}
return b.String()
}
// GetCategories 获取所有分类
func (s *NoteService) GetCategories() ([]string, error) {
return s.repo.GetCategories()
@@ -380,6 +411,181 @@ func (s *NoteService) UpgradePasswordHash(id uint, password string) error {
return s.repo.Update(note)
}
// ─────────────── 自动保存草稿 ───────────────
// SaveDraft 保存笔记草稿(仅更新草稿字段,不触发版本历史)
// 返回是否有未保存草稿被记录
func (s *NoteService) SaveDraft(id uint, content string) error {
note, err := s.repo.GetByID(id)
if err != nil {
return errors.New("笔记不存在")
}
if note.IsFolder {
return errors.New("目录不支持草稿")
}
note.DraftContent = content
// 直接更新草稿字段,保持 updated_at 不变(避免与正文保存混淆)
return s.repo.UpdateFields(id, map[string]interface{}{"draft_content": content})
}
// ClearDraft 清除笔记草稿(保存正文成功后调用)
func (s *NoteService) ClearDraft(id uint) error {
return s.repo.UpdateFields(id, map[string]interface{}{"draft_content": ""})
}
// ─────────────── 双向链接 / 知识图谱 ───────────────
// wikiLinkRe 匹配笔记正文中的 [[wiki链接]] 语法
var wikiLinkRe = regexp.MustCompile(`\[\[([^\[\]|]+)(?:\|[^\[\]]*)?\]\]`)
// GetBacklinks 获取指向指定笔记的所有笔记(反向链接)
func (s *NoteService) GetBacklinks(noteID uint, title string) ([]model.NoteListItem, error) {
if title == "" {
// 若未提供标题,先查一下
n, err := s.repo.GetByID(noteID)
if err != nil {
return nil, errors.New("笔记不存在")
}
title = n.Title
}
all, err := s.repo.GetAllNotesLight()
if err != nil {
return nil, err
}
// 找出所有包含 [[title]] 链接的笔记 ID
var result []model.NoteListItem
for _, n := range all {
if n.ID == noteID {
continue
}
full, err := s.repo.GetByID(n.ID)
if err != nil {
continue
}
if wikiLinkRe.MatchString(full.Content) && strings.Contains(full.Content, "[["+title+"]]") {
result = append(result, n)
}
}
return result, nil
}
// GraphNode 知识图谱节点
type GraphNode struct {
ID uint `json:"id"`
Title string `json:"title"`
}
// GraphEdge 知识图谱边
type GraphEdge struct {
Source uint `json:"source"`
Target uint `json:"target"`
}
// GetKnowledgeGraph 构建完整知识图谱(节点 + [[链接]] 边)
func (s *NoteService) GetKnowledgeGraph() (map[string]interface{}, error) {
all, err := s.repo.GetAllContentLight()
if err != nil {
return nil, err
}
// 标题 → ID 映射,用于把 [[标题]] 解析为具体笔记
titleToID := make(map[string]uint)
nodes := make([]GraphNode, 0, len(all))
for _, n := range all {
titleToID[n.Title] = n.ID
nodes = append(nodes, GraphNode{ID: n.ID, Title: n.Title})
}
// 构建边(去重)
type edgeKey struct{ src, dst uint }
seen := make(map[edgeKey]bool)
var edges []GraphEdge
for _, n := range all {
// 提取当前笔记中的所有 [[链接]]
matches := wikiLinkRe.FindAllStringSubmatch(n.Content, -1)
for _, m := range matches {
// m[1] 是链接目标(标题或路径,取第一个 [[..]] 内容作为标题)
link := strings.TrimSpace(m[1])
if targetID, ok := titleToID[link]; ok && targetID != n.ID {
key := edgeKey{n.ID, targetID}
if !seen[key] {
seen[key] = true
edges = append(edges, GraphEdge{Source: n.ID, Target: targetID})
}
}
}
}
return map[string]interface{}{
"nodes": nodes,
"edges": edges,
}, nil
}
// ─────────────── 标签管理 ───────────────
// RenameTag 重命名标签(所有含该标签的笔记同步更新)
func (s *NoteService) RenameTag(oldTag, newTag string) (int64, error) {
if oldTag == "" || newTag == "" {
return 0, errors.New("标签名不能为空")
}
if oldTag == newTag {
return 0, nil
}
return s.repo.UpdateTagAll(oldTag, newTag)
}
// MergeTag 将 from 标签合并到 to 标签(from 消失)
func (s *NoteService) MergeTag(from, to string) (int64, error) {
if from == "" || to == "" {
return 0, errors.New("标签名不能为空")
}
if from == to {
return 0, nil
}
return s.repo.UpdateTagAll(from, to)
}
// DeleteTag 删除指定标签(从所有笔记中移除)
func (s *NoteService) DeleteTag(tag string) (int64, error) {
if tag == "" {
return 0, errors.New("标签名不能为空")
}
return s.repo.UpdateTagAll(tag, "")
}
// GetTagUsage 获取每个标签及其使用次数
func (s *NoteService) GetTagUsage() ([]model.TagUsage, error) {
var result []model.TagUsage
counts := make(map[string]int)
all, err := s.repo.GetAllNotesLight()
if err != nil {
return nil, err
}
for _, n := range all {
var tags []string
if json.Unmarshal([]byte(n.Tags), &tags) == nil {
for _, t := range tags {
counts[t]++
}
}
}
for tag, count := range counts {
result = append(result, model.TagUsage{Name: tag, Count: count})
}
// 按使用次数降序
for i := range result {
for j := i + 1; j < len(result); j++ {
if result[j].Count > result[i].Count {
result[i], result[j] = result[j], result[i]
}
}
}
return result, nil
}
// randomToken 生成安全的随机令牌
func randomToken(bytesLen int) string {
b := make([]byte, bytesLen)
+100 -2
View File
@@ -243,9 +243,107 @@ report("标签列表正常", st == 200)
st, d = req("GET", "/api/tree")
report("公开树正常", st == 200)
print("═══ 9. FTS5 全文搜索 ═══")
# 创建一篇含中文与英文的笔记用于搜索测试
st, d = req("POST", "/admin/api/notes", {
"title": "搜索引擎优化笔记", "content": "这是一篇关于数据库索引与搜索引擎的笔记 涉及中文分词 ASP.NET",
"category": "技术", "tags": '["全文","优化"]', "is_public": True
})
fts_id = d.get("data", {}).get("id")
report("创建搜索测试笔记", st == 200 and fts_id, f"(got {st})")
# 中文短词(1~2字)搜索
q_enc = urllib.parse.urlencode({"q": "数据库"})
st, d = req("GET", f"/api/notes/search?{q_enc}")
hits = [x.get("title") for x in (d.get("data") or [])]
report("中文短词搜索命中", st == 200 and "搜索引擎优化笔记" in hits, f"(got {hits})")
# 英文搜索
q_enc = urllib.parse.urlencode({"q": "ASP.NET"})
st, d = req("GET", f"/api/notes/search?{q_enc}")
hits = [x.get("title") for x in (d.get("data") or [])]
report("英文搜索命中", st == 200 and "搜索引擎优化笔记" in hits, f"(got {hits})")
# 无结果关键词
q_enc = urllib.parse.urlencode({"q": "不存在的关键词XYZ"})
st, d = req("GET", f"/api/notes/search?{q_enc}")
report("无匹配关键词返回空", st == 200 and d.get("total", 0) == 0, f"(got {d.get('total')})")
print("═══ 10. 双向链接 / 知识图谱 ═══")
# 创建两篇互相/单向链接的笔记
st, da = req("POST", "/admin/api/notes", {
"title": "链接源A", "content": f"参考 [[搜索引擎优化笔记]] 和 [[链接目标B]]",
"is_public": True
})
linkA_id = da.get("data", {}).get("id")
st, db_ = req("POST", "/admin/api/notes", {
"title": "链接目标B", "content": "内容B", "is_public": True
})
linkB_id = db_.get("data", {}).get("id")
report("创建链接笔记成功", st == 200 and linkA_id and linkB_id, f"(got {st})")
# 反向链接:谁链接到了搜索引擎优化笔记(应为 链接源A)
st, d = req("GET", f"/admin/api/notes/{fts_id}/backlinks")
backlinks = [x.get("title") for x in (d.get("data") or [])]
report("反向链接查询", st == 200 and "链接源A" in backlinks, f"(got {backlinks})")
# 知识图谱:应包含节点,且存在 edges
st, d = req("GET", "/admin/api/graph")
g = d.get("data") or {}
gnodes = g.get("nodes") or []
gedges = g.get("edges") or []
report("知识图谱返回节点", st == 200 and any(n.get("title") == "链接源A" for n in gnodes), f"(got {st})")
report("知识图谱含链接边", st == 200 and len(gedges) >= 1, f"(got {len(gedges)})")
print("═══ 11. 自动保存草稿 ═══")
st, d = req("POST", f"/admin/api/notes/{fts_id}/draft", {"content": "这是未保存的草稿"})
report("保存草稿成功", st == 200 and d.get("code") == 0, f"(got {st})")
st, d = req("GET", f"/admin/api/notes/{fts_id}")
report("读取到草稿字段", d.get("data", {}).get("draft_content") == "这是未保存的草稿", f"(got {d.get('data',{}).get('draft_content')})")
st, d = req("DELETE", f"/admin/api/notes/{fts_id}/draft")
report("清除草稿成功", st == 200 and d.get("code") == 0, f"(got {st})")
st, d = req("GET", f"/admin/api/notes/{fts_id}")
report("清除后草稿为空", d.get("data", {}).get("draft_content", "") == "", f"(got {d.get('data',{}).get('draft_content')})")
print("═══ 12. 标签管理 ═══")
# 标签使用统计应包含 test 链接笔记的标签
st, d = req("GET", "/admin/api/tags/usage")
usage = {u.get("name"): u.get("count") for u in (d.get("data") or [])}
report("标签使用统计接口", st == 200 and isinstance(d.get("data"), list), f"(got {st})")
# 重命名标签:给链接源A加个标签再重命名
st, d = req("PUT", f"/admin/api/notes/{linkA_id}", {"tags": '["临时标签","保留"]'})
report("为链接笔记设置标签", st == 200 and d.get("code") == 0, f"(got {st})")
st, d = req("POST", "/admin/api/tags/rename", {"old_name": "临时标签", "new_name": "已重命名"})
report("重命名标签", st == 200 and d.get("data", {}).get("changed", 0) >= 1, f"(got {d})")
st, d = req("GET", f"/admin/api/notes/{linkA_id}")
tag_str = d.get("data", {}).get("tags", "")
report("重命名后笔记标签同步", "已重命名" in tag_str and "临时标签" not in tag_str, f"(got {tag_str})")
# 合并标签
st, d = req("POST", "/admin/api/tags/merge", {"from": "保留", "to": "已重命名"})
report("合并标签", st == 200 and d.get("data", {}).get("changed", 0) >= 1, f"(got {d})")
st, d = req("GET", f"/admin/api/notes/{linkA_id}")
tag_str = d.get("data", {}).get("tags", "")
report("合并后标签去重", "已重命名" in tag_str and "保留" not in tag_str, f"(got {tag_str})")
# 删除标签
st, d = req("DELETE", "/admin/api/tags", {"name": "已重命名"})
report("删除标签", st == 200, f"(got {st})")
# ── 清理测试数据(彻底删除本脚本创建的所有笔记及其版本)──
print("\n═══ 9. 清理测试数据 ═══")
test_ids = [x for x in (note_id, protected_id, tmp_id) if x]
print("\n═══ 13. 清理测试数据 ═══")
test_ids = [x for x in (note_id, protected_id, tmp_id, fts_id, linkA_id, linkB_id) if x]
for tid in test_ids:
if tid:
# 先尝试恢复到非删除态再彻底删除(若在回收站)
+733 -2
View File
@@ -4,6 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>笔记管理后台</title>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#1a73e8">
<link rel="apple-touch-icon" href="/icon-192.png">
<!-- Highlight.js 代码高亮 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
@@ -385,12 +388,223 @@
/* 回收站/版本/分享面板内分割线 */
body.dark #trashList div[style*="border-bottom"], body.dark #versionsList div[style*="border-bottom"] { border-bottom-color: #2e3440 !important; }
/* ───── 新增功能样式:搜索 / 工具栏 / 自动保存 / 反向链接 / 图表 / 标签 ───── */
.search-box {
display: flex;
align-items: center;
gap: 4px;
background: #f1f3f4;
border-radius: 20px;
padding: 4px 8px 4px 14px;
}
.search-box input {
border: none;
background: transparent;
outline: none;
font-size: 13px;
width: 170px;
color: #333;
}
.search-box input::placeholder { color: #9aa0aa; }
.search-box .btn-icon { padding: 4px; min-width: 24px; font-size: 13px; }
/* 搜索结果显示在侧边栏,替换树形目录 */
#searchResults {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.search-result-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
color: #333;
gap: 8px;
}
.search-result-item:hover { background: #f1f3f4; }
.search-result-item .s-cat {
font-size: 12px;
color: #999;
background: #f1f3f4;
padding: 1px 8px;
border-radius: 10px;
flex-shrink: 0;
max-width: 110px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-empty { padding: 20px; text-align: center; color: #999; font-size: 13px; }
/* 编辑器工具栏与自动保存提示 */
.editor-content {
display: flex;
flex-direction: column;
padding: 0;
}
.editor-content textarea {
flex: 1;
padding: 16px;
border: none;
resize: none;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 14px;
line-height: 1.7;
outline: none;
width: 100%;
}
.md-toolbar {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 8px 12px;
border-bottom: 1px solid #e0e0e0;
align-items: center;
background: #fafafa;
}
.tool-btn {
padding: 4px 9px;
border: 1px solid #ddd;
border-radius: 4px;
background: #fff;
cursor: pointer;
font-size: 12px;
color: #333;
line-height: 1.4;
}
.tool-btn:hover { background: #e8eaed; }
.autosave-indicator {
font-size: 12px;
color: #6aa1f7;
margin-left: 8px;
opacity: 0.9;
}
/* 反向链接面板(侧边栏底部) */
.backlinks-box {
border-top: 1px solid #e0e0e0;
flex-shrink: 0;
max-height: 200px;
overflow-y: auto;
}
.backlinks-header {
padding: 8px 14px;
font-size: 13px;
font-weight: 600;
color: #666;
background: #fafafa;
border-bottom: 1px solid #e0e0e0;
}
.backlinks-list {
padding: 6px 8px;
}
.backlink-item {
display: block;
width: 100%;
text-align: left;
padding: 6px 10px;
border: none;
background: transparent;
cursor: pointer;
font-size: 13px;
color: #1a73e8;
border-radius: 6px;
}
.backlink-item:hover { background: #f1f3f4; text-decoration: underline; }
.backlink-empty { padding: 10px; font-size: 12px; color: #999; }
/* 预览中的 wiki 链接 */
.preview-content .wiki-link {
color: #1a73e8;
border-bottom: 1px dashed #1a73e8;
cursor: pointer;
text-decoration: none;
}
.preview-content .wiki-link:hover { background: #e8f0fe; }
/* 图表节点与连线 */
.graph-node {
cursor: pointer;
user-select: none;
}
.graph-node circle { fill: #1a73e8; stroke: #fff; stroke-width: 2px; }
.graph-node text { fill: #333; font-size: 11px; text-anchor: middle; }
.graph-edge { stroke: #cdd6e4; stroke-width: 1.5; }
.graph-hint { font-size: 12px; color: #999; margin-top: 8px; }
/* 标签管理 */
.tag-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 6px;
border-bottom: 1px solid #eee;
}
.tag-row .tag-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
color: #333;
}
.tag-row .tag-count {
font-size: 12px;
color: #999;
background: #f1f3f4;
padding: 1px 8px;
border-radius: 10px;
flex-shrink: 0;
}
.tag-row button {
font-size: 12px;
padding: 4px 9px;
flex-shrink: 0;
}
.tags-empty { padding: 24px; text-align: center; color: #999; }
body.dark .search-box { background: #20242e; }
body.dark .search-box input { color: #e6e6e6; }
body.dark .search-box input::placeholder { color: #6b7280; }
body.dark .search-result-item { color: #c9ced8; }
body.dark .search-result-item:hover { background: #1e232d; }
body.dark .search-result-item .s-cat { background: #20242e; color: #9aa0aa; }
body.dark .search-empty { color: #9aa0aa; }
body.dark .md-toolbar { background: #12151b; border-bottom-color: #232733; }
body.dark .tool-btn { background: #20242e; border-color: #2e3440; color: #c9ced8; }
body.dark .tool-btn:hover { background: #2a3040; color: #e6e6e6; }
body.dark .backlinks-box { border-top-color: #232733; }
body.dark .backlinks-header { background: #1a1e26; color: #9aa0aa; border-bottom-color: #232733; }
body.dark .backlink-item { color: #7aa2f7; }
body.dark .backlink-item:hover { background: #1e232d; }
body.dark .backlink-empty { color: #9aa0aa; }
body.dark .preview-content .wiki-link { color: #7aa2f7; border-bottom-color: #7aa2f7; }
body.dark .preview-content .wiki-link:hover { background: #1b2a44; }
body.dark .graph-node circle { fill: #6aa1f7; stroke: #0f1115; }
body.dark .graph-node text { fill: #e6e6e6; }
body.dark .graph-edge { stroke: #2e3440; }
body.dark .graph-hint { color: #9aa0aa; }
body.dark .tag-row { border-bottom-color: #2e3440; }
body.dark .tag-row .tag-name { color: #e6e6e6; }
body.dark .tag-row .tag-count { background: #20242e; color: #9aa0aa; }
body.dark .tags-empty { color: #9aa0aa; }
</style>
</head>
<body>
<header>
<h1>笔记管理后台</h1>
<div class="header-actions">
<div class="search-box">
<input type="text" id="searchInput" placeholder="搜索笔记..." autocomplete="off">
<button class="btn btn-icon" id="clearSearchBtn" onclick="clearSearch()" title="清除搜索" style="display:none;"></button>
</div>
<button class="btn btn-secondary" onclick="openGraph()" title="知识图谱">🕸 知识图谱</button>
<button class="btn btn-secondary" onclick="openTags()" title="标签管理">🏷 标签管理</button>
<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>
@@ -416,6 +630,11 @@
<div class="tree-container" id="treeContainer">
<div class="loading">加载中...</div>
</div>
<div id="searchResults" style="display:none;"></div>
<div class="backlinks-box" id="backlinksBox" style="display:none;">
<div class="backlinks-header">反向链接</div>
<div class="backlinks-list" id="backlinksList">加载中...</div>
</div>
</div>
<!-- 右侧内容区 -->
@@ -455,6 +674,20 @@
</div>
</div>
<div class="editor-content">
<div class="md-toolbar" id="mdToolbar">
<button type="button" class="tool-btn" data-fmt="bold" title="加粗">B</button>
<button type="button" class="tool-btn" data-fmt="italic" title="斜体"><em>I</em></button>
<button type="button" class="tool-btn" data-fmt="h1" title="一级标题">H1</button>
<button type="button" class="tool-btn" data-fmt="h2" title="二级标题">H2</button>
<button type="button" class="tool-btn" data-fmt="h3" title="三级标题">H3</button>
<button type="button" class="tool-btn" data-fmt="list" title="无序列表">• 列表</button>
<button type="button" class="tool-btn" data-fmt="link" title="链接">🔗 链接</button>
<button type="button" class="tool-btn" data-fmt="code" title="行内代码">&lt;/&gt;</button>
<button type="button" class="tool-btn" data-fmt="image" title="上传图片">🖼 图片</button>
<button type="button" class="tool-btn" data-fmt="table" title="表格">▦ 表格</button>
<button type="button" class="tool-btn" data-fmt="task" title="任务列表">☑ 任务</button>
<span class="autosave-indicator" id="autosaveIndicator" style="display:none;">已自动保存草稿</span>
</div>
<textarea id="noteContent" placeholder="使用 Markdown 编写内容..."></textarea>
</div>
</div>
@@ -556,12 +789,39 @@
<div class="toast" id="toast"></div>
<!-- 知识图谱弹窗 -->
<div class="modal" id="graphModal">
<div class="modal-content" style="min-width: 680px; max-width: 90vw; max-height: 80vh; display: flex; flex-direction: column;">
<h3 style="margin-bottom: 12px;">🕸 知识图谱 <span style="font-size:13px;color:#999;font-weight:normal;margin-left:8px;">点击节点可打开笔记</span></h3>
<div id="graphContainer" style="flex:1; overflow:auto;"><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="closeGraph()">关闭</button>
</div>
</div>
</div>
<!-- 标签管理弹窗 -->
<div class="modal" id="tagsModal">
<div class="modal-content" style="min-width: 520px; max-height: 76vh; display: flex; flex-direction: column;">
<h3 style="margin-bottom: 12px;">🏷 标签管理</h3>
<div style="flex:1; overflow-y:auto;" id="tagsList"><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="closeTags()">关闭</button>
</div>
</div>
</div>
<script>
const API = '/api';
const ADMIN_API = '/admin/api';
let treeData = [];
let currentItem = null;
let isFolderMode = false;
let editId = null; // 当前打开的笔记 id
let lastSavedContent = ''; // 上次保存/加载的内容,用于自动保存判断
let searchTimer = null; // 搜索防抖定时器
let searchMode = false; // 是否处于搜索模式(隐藏树形)
let autosaveInlineOpen = false; // 草稿已加载标志
// 初始化
async function init() {
@@ -713,6 +973,9 @@
if (isFolderMode) {
// 目录模式
editId = null;
hideBacklinks();
hideAutosaveIndicator();
document.getElementById('editorPane').style.display = 'none';
document.getElementById('resizeHandle').style.display = 'none';
document.getElementById('previewPane').style.display = 'none';
@@ -735,13 +998,28 @@
editorView.style.display = 'flex';
editorView.style.flexDirection = 'row';
editId = currentItem.id;
document.getElementById('noteTitle').value = currentItem.title || '';
document.getElementById('noteCategory').value = currentItem.category || '';
document.getElementById('noteTags').value = parseTags(currentItem.tags).join(', ');
document.getElementById('noteContent').value = currentItem.content || '';
document.getElementById('notePublic').checked = currentItem.is_public !== false;
document.getElementById('notePassword').value = ''; // 不显示密码
lastSavedContent = currentItem.content || '';
autosaveInlineOpen = false;
hideAutosaveIndicator();
updatePreview();
loadBacklinks(currentItem.id);
// 如果有比正式内容更新的草稿,提示加载
if (currentItem.draft_content && currentItem.draft_content !== lastSavedContent) {
if (confirm('检测到未保存的草稿内容,是否加载草稿?')) {
currentItem.content = currentItem.draft_content;
document.getElementById('noteContent').value = currentItem.draft_content;
lastSavedContent = currentItem.draft_content;
autosaveInlineOpen = true;
updatePreview();
}
}
}
renderTree();
@@ -822,6 +1100,11 @@
function showNewNoteModal(parentId) {
if (!parentId && parentId !== 0) return;
currentItem = { parent_id: parentId || 0, is_folder: false, id: null };
editId = null;
lastSavedContent = '';
autosaveInlineOpen = false;
hideBacklinks();
hideAutosaveIndicator();
document.getElementById('emptyState').style.display = 'none';
document.getElementById('editorView').style.display = 'flex';
document.getElementById('editorView').style.flexDirection = 'row';
@@ -894,6 +1177,13 @@
if (data.code === 0) {
showToast(currentItem && currentItem.id ? '笔记已更新' : '笔记已创建', 'success');
// 保存成功后清除已保存的草稿
if (currentItem && currentItem.id && !currentItem.is_folder) {
clearDraft(currentItem.id);
}
lastSavedContent = content;
autosaveInlineOpen = false;
hideAutosaveIndicator();
await loadTree();
if (data.data && data.data.id) {
selectItem(data.data.id);
@@ -972,6 +1262,11 @@
showToast('已删除', 'success');
closeDeleteModal();
currentItem = null;
editId = null;
lastSavedContent = '';
autosaveInlineOpen = false;
hideAutosaveIndicator();
hideBacklinks();
document.getElementById('emptyState').style.display = 'flex';
document.getElementById('editorView').style.display = 'none';
await loadTree();
@@ -986,16 +1281,151 @@
// 更新预览
function updatePreview() {
const content = document.getElementById('noteContent').value;
document.getElementById('previewContent').innerHTML = renderMarkdown(content);
const preview = document.getElementById('previewContent');
preview.innerHTML = renderMarkdown(content);
// 将预览中的 [[笔记标题]] wiki 链接转为可点击
makeWikiLinksClickable(preview);
}
// 监听输入更新预览
// 将 [[标题]] 渲染为可点击链接(在 renderMarkdown 输出后处理)
function makeWikiLinksClickable(previewEl) {
if (!previewEl) return;
const walker = document.createTreeWalker(previewEl, NodeFilter.SHOW_TEXT, {
acceptNode: function(node) {
return /\[\[[^\]]+\]\]/.test(node.textContent) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
}
});
const nodesToProcess = [];
let n;
while ((n = walker.nextNode())) nodesToProcess.push(n);
nodesToProcess.forEach(function(textNode) {
const parts = textNode.textContent.split(/(\[\[[^\]]+\]\])/g);
const frag = document.createDocumentFragment();
parts.forEach(function(part) {
if (/^\[\[[^\]]+\]\]$/.test(part)) {
const title = part.slice(2, -2);
const a = document.createElement('a');
a.className = 'wiki-link';
a.textContent = title;
a.title = '打开笔记:' + title;
a.addEventListener('click', function(ev) {
ev.preventDefault();
openWikiLink(title);
});
frag.appendChild(a);
} else if (part) {
frag.appendChild(document.createTextNode(part));
}
});
textNode.parentNode.replaceChild(frag, textNode);
});
}
// 根据标题打开笔记(用于 wiki 链接与搜索/反向链接跳转)
function openNoteByTitle(title) {
const t = String(title || '').trim();
if (!t) return;
if (searchMode) { clearSearch(); } // 退出搜索模式回到树形
// 在树形数据中查找标题完全匹配的笔记
const match = (treeData || []).find(item => !item.is_folder && item.title === t);
if (match) {
selectItem(match.id);
return;
}
// 树形中未找到时,通过搜索接口全量查找
fetch(`${API}/notes/search?q=${encodeURIComponent(t)}&page=1`).then(r => r.json()).then(data => {
if (data && data.code === 0 && Array.isArray(data.data)) {
const hit = data.data.find(it => it.title === t);
if (hit) {
selectItem(hit.id);
} else {
showToast('未找到笔记:' + t, 'error');
}
}
}).catch(() => showToast('查找笔记失败', 'error'));
}
// 打开 [[wiki]] 链接(开放给全局,因为预览中内联 onclick 不在)
window.openWiki = function(title) { openNoteByTitle(title); };
function openWikiLink(title) { openNoteByTitle(title); }
// ── Markdown 编辑工具栏 ──
function applyFormat(fmt) {
const ta = document.getElementById('noteContent');
if (!ta) return;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const selected = ta.value.substring(start, end) || '文本';
let insert = '';
let selStart = start, selEnd = end;
switch (fmt) {
case 'bold': insert = '**' + selected + '**'; break;
case 'italic': insert = '*' + selected + '*'; break;
case 'h1': insert = '# ' + selected; break;
case 'h2': insert = '## ' + selected; break;
case 'h3': insert = '### ' + selected; break;
case 'list': insert = '\n- ' + selected; selStart += 1; break;
case 'link':
insert = '[' + selected + '](https://)';
selEnd = start + selected.length + 3; // 选中 url 占位
break;
case 'code': insert = '`' + selected + '`'; break;
case 'image':
insert = '![' + selected + '](图片链接)';
selStart = start + 3;
selEnd = selStart + selected.length;
break;
case 'table':
insert = '\n| 列1 | 列2 |\n| --- | --- |\n| 内容 | 内容 |';
break;
case 'task':
insert = '\n- [ ] ' + selected;
selStart += 1;
break;
default: return;
}
ta.value = ta.value.substring(0, start) + insert + ta.value.substring(end);
ta.selectionStart = selStart;
ta.selectionEnd = selEnd;
ta.focus();
updatePreview();
// 标记有未保存修改
showDraftNeedsSave();
}
// 监听输入更新预览 + 工具栏点击
document.addEventListener('DOMContentLoaded', () => {
const contentArea = document.getElementById('noteContent');
if (contentArea) {
contentArea.addEventListener('input', updatePreview);
}
const toolbar = document.getElementById('mdToolbar');
if (toolbar) {
toolbar.addEventListener('click', (e) => {
const btn = e.target.closest('.tool-btn');
if (btn) applyFormat(btn.getAttribute('data-fmt'));
});
}
const searchInput = document.getElementById('searchInput');
if (searchInput) {
searchInput.addEventListener('input', (e) => {
const q = e.target.value.trim();
clearTimeout(searchTimer);
if (!q) { clearSearch(); return; }
searchTimer = setTimeout(() => doSearch(q), 400);
});
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const q = e.target.value.trim();
if (q) doSearch(q);
}
});
}
init();
startAutosave(); // 启动自动保存定时器
});
// 退出登录
@@ -1348,6 +1778,307 @@
}
}
// ═══════════ 新增功能:搜索 / 自动保存草稿 / 反向链接 / 知识图谱 / 标签管理 ═══════════
// ── 1) 全文搜索 ──
async function doSearch(q) {
q = String(q || '').trim();
if (!q) { clearSearch(); return; }
// 进入搜索模式,隐藏树形目录,显示结果列表
const treeContainer = document.getElementById('treeContainer');
const resultsBox = document.getElementById('searchResults');
treeContainer.style.display = 'none';
document.getElementById('clearSearchBtn').style.display = 'inline-block';
searchMode = true;
resultsBox.style.display = 'block';
resultsBox.innerHTML = '<div class="search-empty">搜索中...</div>';
try {
const res = await fetch(`${API}/notes/search?q=${encodeURIComponent(q)}&page=1`);
const data = await res.json();
if (data.code === 0) {
renderSearchResults(data.data || [], q);
} else {
resultsBox.innerHTML = `<div class="search-empty">${escapeHtml(data.message || '搜索失败')}</div>`;
}
} catch (err) {
resultsBox.innerHTML = '<div class="search-empty">搜索失败</div>';
}
}
function renderSearchResults(notes, q) {
const resultsBox = document.getElementById('searchResults');
if (!notes.length) {
resultsBox.innerHTML = `<div class="search-empty">未找到与 “${escapeHtml(q)}” 相关的笔记</div>`;
return;
}
resultsBox.innerHTML = notes.map(note => {
const cat = note.category ? `<span class="s-cat">${escapeHtml(note.category)}</span>` : '';
return `<div class="search-result-item" onclick="openSearchResult(${note.id})">
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(note.title || '(无标题)')}</span>
${cat}
</div>`;
}).join('');
}
async function openSearchResult(id) {
await selectItem(id);
}
function clearSearch() {
searchMode = false;
const input = document.getElementById('searchInput');
if (input) input.value = '';
document.getElementById('clearSearchBtn').style.display = 'none';
const treeContainer = document.getElementById('treeContainer');
const resultsBox = document.getElementById('searchResults');
if (treeContainer) treeContainer.style.display = 'block';
if (resultsBox) { resultsBox.style.display = 'none'; resultsBox.innerHTML = ''; }
}
// ── 2) 自动保存草稿 ──
function startAutosave() {
setInterval(autosaveTick, 8000);
}
function autosaveTick() {
const ta = document.getElementById('noteContent');
if (!ta) return;
if (!editId || !currentItem || currentItem.is_folder) return;
const current = ta.value;
if (current === lastSavedContent) return; // 无改动不保存
// 保存草稿
fetch(`${ADMIN_API}/notes/${editId}/draft`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: current })
}).then(r => r.json()).then(data => {
if (data && data.code === 0) {
lastSavedContent = current;
autosaveInlineOpen = true;
showAutosaveIndicator();
}
}).catch(() => {});
}
function showAutosaveIndicator() {
const el = document.getElementById('autosaveIndicator');
if (el) el.style.display = 'inline';
}
function hideAutosaveIndicator() {
const el = document.getElementById('autosaveIndicator');
if (el) el.style.display = 'none';
}
function showDraftNeedsSave() {
// 编辑后提示草稿待保存(由定时器执行)
const el = document.getElementById('autosaveIndicator');
if (el) el.style.display = 'none'; // 保存中隐藏,保存完成后再显示
}
async function clearDraft(id) {
try {
await fetch(`${ADMIN_API}/notes/${id}/draft`, { method: 'DELETE' });
} catch (e) {}
}
// ── 3) 反向链接 ──
async function loadBacklinks(id) {
const box = document.getElementById('backlinksBox');
const list = document.getElementById('backlinksList');
if (!box || !list) return;
box.style.display = 'none';
list.innerHTML = '加载中...';
try {
const res = await fetch(`${ADMIN_API}/notes/${id}/backlinks`);
const data = await res.json();
if (data.code === 0) {
const items = data.data || [];
if (items.length) {
box.style.display = 'block';
list.innerHTML = items.map(n =>
`<button class="backlink-item" onclick="openSearchResult(${n.id})">🔗 ${escapeHtml(n.title || '(无标题)')}</button>`
).join('');
} else {
box.style.display = 'none';
}
} else {
box.style.display = 'none';
}
} catch (err) {
box.style.display = 'none';
}
}
function hideBacklinks() {
const box = document.getElementById('backlinksBox');
if (box) box.style.display = 'none';
}
// ── 4) 知识图谱 ──
async function openGraph() {
const modal = document.getElementById('graphModal');
modal.style.display = 'flex';
const container = document.getElementById('graphContainer');
container.innerHTML = '<p style="color:#999;padding:20px;text-align:center;">加载中...</p>';
try {
const res = await fetch(`${ADMIN_API}/graph`);
const data = await res.json();
if (data.code === 0) {
renderGraph(data.data, container);
} else {
container.innerHTML = `<p style="color:#d32f2f;padding:20px;text-align:center;">${escapeHtml(data.message || '加载失败')}</p>`;
}
} catch (err) {
container.innerHTML = '<p style="color:#d32f2f;padding:20px;text-align:center;">图谱加载失败</p>';
}
}
function closeGraph() {
document.getElementById('graphModal').style.display = 'none';
}
function renderGraph(graph, container) {
const nodes = (graph && graph.nodes) || [];
const edges = (graph && graph.edges) || [];
if (!nodes.length) {
container.innerHTML = '<p style="color:#999;padding:20px;text-align:center;">暂无节点数据</p>';
return;
}
const W = 620, H = 480, cx = W / 2, cy = H / 2, R = Math.min(W, H) / 2 - 70;
const pos = {};
nodes.forEach((n, i) => {
const angle = (2 * Math.PI * i) / nodes.length - Math.PI / 2;
pos[n.id] = { x: cx + R * Math.cos(angle), y: cy + R * Math.sin(angle) };
});
let svg = `<svg width="${W}" height="${H}" style="background:transparent;">`;
edges.forEach(e => {
if (!pos[e.source] || !pos[e.target]) return;
svg += `<line class="graph-edge" x1="${pos[e.source].x.toFixed(1)}" y1="${pos[e.source].y.toFixed(1)}"
x2="${pos[e.target].x.toFixed(1)}" y2="${pos[e.target].y.toFixed(1)}"/>`;
});
nodes.forEach(n => {
const p = pos[n.id];
const title = String(n.title || '').slice(0, 14) + (String(n.title || '').length > 14 ? '…' : '');
svg += `<g class="graph-node" onclick="openGraphNode(${n.id})">
<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="16"/>
<text x="${p.x.toFixed(1)}" y="${(p.y + 32).toFixed(1)}">${escapeHtml(title)}</text>
</g>`;
});
svg += '</svg>';
container.innerHTML = svg + '<div class="graph-hint">💡 点击节点打开对应笔记</div>';
}
function openGraphNode(id) {
closeGraph();
// 打开对应的笔记并刷新目录
selectItem(id);
}
// ── 5) 标签管理 ──
async function openTags() {
const modal = document.getElementById('tagsModal');
modal.style.display = 'flex';
const list = document.getElementById('tagsList');
list.innerHTML = '<p style="color:#999;padding:20px;text-align:center;">加载中...</p>';
await refreshTagsList(list);
}
function closeTags() {
document.getElementById('tagsModal').style.display = 'none';
}
let _tagNamesCache = []; // 标签管理列表的标签名缓存(避免内联 onclick 转义问题)
async function refreshTagsList(list) {
if (!list) list = document.getElementById('tagsList');
try {
const res = await fetch(`${ADMIN_API}/tags/usage`);
const data = await res.json();
if (data.code === 0) {
const tags = data.data || [];
_tagNamesCache = tags.map(t => t.name);
if (!tags.length) {
list.innerHTML = '<div class="tags-empty">暂无标签</div>';
return;
}
list.innerHTML = tags.map((t, idx) => `
<div class="tag-row">
<span class="tag-name" title="${escapeHtml(t.name)}"># ${escapeHtml(t.name)}</span>
<span class="tag-count">${t.count} 篇</span>
<button class="btn btn-secondary" onclick="renameTag(${idx})">重命名</button>
<button class="btn btn-secondary" onclick="mergeTag(${idx})">合并</button>
<button class="btn btn-danger" onclick="deleteTag(${idx})">删除</button>
</div>`).join('');
} else {
list.innerHTML = `<p style="color:#d32f2f;padding:20px;text-align:center;">${escapeHtml(data.message || '加载失败')}</p>`;
}
} catch (err) {
list.innerHTML = '<p style="color:#d32f2f;padding:20px;text-align:center;">加载失败</p>';
}
}
async function renameTag(idx) {
const oldName = _tagNamesCache[idx];
if (oldName === undefined) return;
const newName = prompt('请输入新的标签名称:', oldName);
if (!newName || newName.trim() === oldName) return;
try {
const res = await fetch(`${ADMIN_API}/tags/rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ old_name: oldName, new_name: newName.trim() })
});
const data = await res.json();
showToast(data.changed ? '重命名成功' : '重命名失败', data.changed ? 'success' : 'error');
} catch (err) {
showToast('重命名失败', 'error');
}
await refreshTagsList();
await loadTree();
if (editId) selectItem(editId);
}
async function mergeTag(idx) {
const fromName = _tagNamesCache[idx];
if (fromName === undefined) return;
const toName = prompt(`将标签 “${fromName}” 合并到:`);
if (!toName || toName.trim() === fromName) return;
try {
const res = await fetch(`${ADMIN_API}/tags/merge`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from: fromName, to: toName.trim() })
});
const data = await res.json();
showToast(data.changed ? '合并成功' : '合并失败', data.changed ? 'success' : 'error');
} catch (err) {
showToast('合并失败', 'error');
}
await refreshTagsList();
await loadTree();
if (editId) selectItem(editId);
}
async function deleteTag(idx) {
const name = _tagNamesCache[idx];
if (name === undefined) return;
if (!confirm(`确定删除标签 “${name}” 吗?`)) return;
try {
const res = await fetch(`${ADMIN_API}/tags`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name })
});
const data = await res.json();
showToast(data.changed ? '删除成功' : '删除失败', data.changed ? 'success' : 'error');
} catch (err) {
showToast('删除失败', 'error');
}
await refreshTagsList();
await loadTree();
if (editId) selectItem(editId);
}
// 初始化深色模式
initDarkMode();
</script>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1011 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

+108 -3
View File
@@ -4,6 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>云笔记</title>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#1a73e8">
<link rel="apple-touch-icon" href="/icon-192.png">
<!-- Highlight.js 代码高亮 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
@@ -508,16 +511,95 @@
/* 响应式 */
@media (max-width: 768px) {
.sidebar { width: 100%; display: none; }
.sidebar.show { display: block; }
.content { padding: 20px; }
header {
padding: 10px 12px;
}
header h1 { font-size: 16px; }
#menuBtn {
display: block;
flex-shrink: 0;
}
.header-right {
gap: 8px;
}
.search-box {
flex: 1;
padding: 7px 12px;
}
.search-box input {
width: 100%;
font-size: 14px;
}
.search-box svg { width: 18px; height: 18px; }
#darkBtn {
font-size: 0;
padding: 0;
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
#darkBtn::after {
content: '🌙';
font-size: 18px;
}
body.dark #darkBtn::after {
content: '☀️';
}
#darkBtn.btn-light::after { content: '☀️'; }
.main {
flex-direction: column;
height: auto;
min-height: calc(100vh - 57px);
}
/* 边栏在手机端默认隐藏,点开汉堡菜单后显示 */
.sidebar {
width: 100%;
max-height: 50vh;
display: none;
border-right: none;
border-bottom: 1px solid #e0e0e0;
}
.sidebar.show { display: flex; }
.content {
height: auto;
min-height: 60vh;
padding: 0;
flex-direction: column;
}
.toc-sidebar {
display: none !important;
}
.note-area { padding: 16px; }
.note-detail h1 { font-size: 24px; }
.note-content { font-size: 15px; }
.note-content pre { overflow-x: auto; }
.empty-state { padding: 60px 20px; }
.note-item { padding: 12px; }
.filter-item { padding: 12px; font-size: 14px; }
.tree-node { padding: 12px 10px; }
.tree-label { font-size: 14px; }
header a { font-size: 13px; }
footer { font-size: 11px; padding: 16px 12px; }
}
@media (max-width: 480px) {
header h1 { display: none; }
.header-right .search-box { max-width: none; }
.wrap-text { word-break: break-word; }
}
</style>
</head>
<body>
<header>
<div style="display:flex;align-items:center;gap:12px;">
<button id="menuBtn" aria-label="切换目录" style="display:none;background:none;border:1px solid #ccc;border-radius:8px;width:40px;height:40px;cursor:pointer;color:#666;font-size:20px;line-height:1;"></button>
<h1>云笔记</h1>
</div>
<div class="header-right">
<div class="search-box">
<svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
@@ -1058,8 +1140,31 @@
}
}
// 移动端:汉堡菜单切换左侧目录
function toggleSidebar() {
const sidebar = document.querySelector('.sidebar');
sidebar.classList.toggle('show');
}
// PWA:注册 Service Worker(简单、幂等;失败不阻塞页面)
function registerServiceWorker() {
if (!('serviceWorker' in navigator)) return;
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js').catch(function(err) {
console.warn('Service Worker 注册失败:', err);
});
});
}
// 菜单按钮绑定
document.addEventListener('DOMContentLoaded', function() {
const menuBtn = document.getElementById('menuBtn');
if (menuBtn) menuBtn.addEventListener('click', toggleSidebar);
});
init();
initSiteDark();
registerServiceWorker();
</script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
{
"name": "云笔记",
"short_name": "笔记",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1a73e8",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+70
View File
@@ -0,0 +1,70 @@
/* 云笔记 Service Worker — 简单而正确的 PWA 缓存
* 预缓存首页与基础资源;网络优先、失败时回退到缓存(Network-first with cache fallback)。
* 作用域:'/'(由 manifest scope 与 register 的路径共同决定)。
*/
'use strict';
const CACHE_NAME = 'yunjibiji-v1';
const PRECACHE_URLS = [
'/',
'/manifest.json',
'/icon-192.png',
'/icon-512.png',
];
// 安装:预缓存核心资源
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(PRECACHE_URLS))
.then(() => self.skipWaiting())
);
});
// 激活:清理旧版本缓存
self.addEventListener('activate', (event) => {
const cacheWhitelist = [CACHE_NAME];
event.waitUntil(
caches.keys().then((cacheNames) =>
Promise.all(
cacheNames.map((cacheName) => {
if (!cacheWhitelist.includes(cacheName)) {
return caches.delete(cacheName);
}
})
)
).then(() => self.clients.claim())
);
});
// 抓取:网络优先,失败时回退到缓存
self.addEventListener('fetch', (event) => {
const request = event.request;
// 仅处理 GET 请求
if (request.method !== 'GET') return;
// 不缓存跨域请求(外部 CDN、图片等),直接放行
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
// API 请求不做缓存(保证数据实时)
if (url.pathname.startsWith('/api/')) return;
// 管理后台不做缓存
if (url.pathname.startsWith('/admin/')) return;
event.respondWith(
fetch(request)
.then((response) => {
// 仅缓存有效响应,避免缓存错误页
if (response && response.status === 200 && response.type === 'basic') {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
}
return response;
})
.catch(() =>
caches.match(request).then((cached) => {
return cached || caches.match('/');
})
)
);
});