feat: v3 增强 - FTS5全文搜索+双向链接/知识图谱+自动保存草稿+标签管理+实时预览编辑器+PWA
- FTS5中文分词搜索(需-tags=sqlite_fts5) - [[wiki链接]]反向链接+SVG知识图谱 - 自动保存草稿(不触发版本历史) - 标签重命名/合并/删除+使用统计 - 后台编辑实时预览+格式工具栏 - PWA manifest+service worker+移动端适配 - 冒烟测试扩至66例全通过
This commit is contained in:
+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>
|
||||
|
||||
Reference in New Issue
Block a user