feat: v3 增强 - FTS5全文搜索+双向链接/知识图谱+自动保存草稿+标签管理+实时预览编辑器+PWA
- FTS5中文分词搜索(需-tags=sqlite_fts5) - [[wiki链接]]反向链接+SVG知识图谱 - 自动保存草稿(不触发版本历史) - 标签重命名/合并/删除+使用统计 - 后台编辑实时预览+格式工具栏 - PWA manifest+service worker+移动端适配 - 冒烟测试扩至66例全通过
This commit is contained in:
@@ -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
@@ -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} \
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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(¬e, 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(¬es).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 保存笔记新版本快照
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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="行内代码"></></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 = '';
|
||||
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>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1011 B |
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
+108
-3
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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('/');
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user