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

- FTS5中文分词搜索(需-tags=sqlite_fts5)
- [[wiki链接]]反向链接+SVG知识图谱
- 自动保存草稿(不触发版本历史)
- 标签重命名/合并/删除+使用统计
- 后台编辑实时预览+格式工具栏
- PWA manifest+service worker+移动端适配
- 冒烟测试扩至66例全通过
This commit is contained in:
Your Name
2026-08-11 12:38:41 +08:00
parent cbfaac3d4f
commit b93c6f1e16
14 changed files with 1720 additions and 16 deletions
+733 -2
View File
@@ -4,6 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>笔记管理后台</title>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#1a73e8">
<link rel="apple-touch-icon" href="/icon-192.png">
<!-- Highlight.js 代码高亮 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
@@ -385,12 +388,223 @@
/* 回收站/版本/分享面板内分割线 */
body.dark #trashList div[style*="border-bottom"], body.dark #versionsList div[style*="border-bottom"] { border-bottom-color: #2e3440 !important; }
/* ───── 新增功能样式:搜索 / 工具栏 / 自动保存 / 反向链接 / 图表 / 标签 ───── */
.search-box {
display: flex;
align-items: center;
gap: 4px;
background: #f1f3f4;
border-radius: 20px;
padding: 4px 8px 4px 14px;
}
.search-box input {
border: none;
background: transparent;
outline: none;
font-size: 13px;
width: 170px;
color: #333;
}
.search-box input::placeholder { color: #9aa0aa; }
.search-box .btn-icon { padding: 4px; min-width: 24px; font-size: 13px; }
/* 搜索结果显示在侧边栏,替换树形目录 */
#searchResults {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.search-result-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
color: #333;
gap: 8px;
}
.search-result-item:hover { background: #f1f3f4; }
.search-result-item .s-cat {
font-size: 12px;
color: #999;
background: #f1f3f4;
padding: 1px 8px;
border-radius: 10px;
flex-shrink: 0;
max-width: 110px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-empty { padding: 20px; text-align: center; color: #999; font-size: 13px; }
/* 编辑器工具栏与自动保存提示 */
.editor-content {
display: flex;
flex-direction: column;
padding: 0;
}
.editor-content textarea {
flex: 1;
padding: 16px;
border: none;
resize: none;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 14px;
line-height: 1.7;
outline: none;
width: 100%;
}
.md-toolbar {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 8px 12px;
border-bottom: 1px solid #e0e0e0;
align-items: center;
background: #fafafa;
}
.tool-btn {
padding: 4px 9px;
border: 1px solid #ddd;
border-radius: 4px;
background: #fff;
cursor: pointer;
font-size: 12px;
color: #333;
line-height: 1.4;
}
.tool-btn:hover { background: #e8eaed; }
.autosave-indicator {
font-size: 12px;
color: #6aa1f7;
margin-left: 8px;
opacity: 0.9;
}
/* 反向链接面板(侧边栏底部) */
.backlinks-box {
border-top: 1px solid #e0e0e0;
flex-shrink: 0;
max-height: 200px;
overflow-y: auto;
}
.backlinks-header {
padding: 8px 14px;
font-size: 13px;
font-weight: 600;
color: #666;
background: #fafafa;
border-bottom: 1px solid #e0e0e0;
}
.backlinks-list {
padding: 6px 8px;
}
.backlink-item {
display: block;
width: 100%;
text-align: left;
padding: 6px 10px;
border: none;
background: transparent;
cursor: pointer;
font-size: 13px;
color: #1a73e8;
border-radius: 6px;
}
.backlink-item:hover { background: #f1f3f4; text-decoration: underline; }
.backlink-empty { padding: 10px; font-size: 12px; color: #999; }
/* 预览中的 wiki 链接 */
.preview-content .wiki-link {
color: #1a73e8;
border-bottom: 1px dashed #1a73e8;
cursor: pointer;
text-decoration: none;
}
.preview-content .wiki-link:hover { background: #e8f0fe; }
/* 图表节点与连线 */
.graph-node {
cursor: pointer;
user-select: none;
}
.graph-node circle { fill: #1a73e8; stroke: #fff; stroke-width: 2px; }
.graph-node text { fill: #333; font-size: 11px; text-anchor: middle; }
.graph-edge { stroke: #cdd6e4; stroke-width: 1.5; }
.graph-hint { font-size: 12px; color: #999; margin-top: 8px; }
/* 标签管理 */
.tag-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 6px;
border-bottom: 1px solid #eee;
}
.tag-row .tag-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
color: #333;
}
.tag-row .tag-count {
font-size: 12px;
color: #999;
background: #f1f3f4;
padding: 1px 8px;
border-radius: 10px;
flex-shrink: 0;
}
.tag-row button {
font-size: 12px;
padding: 4px 9px;
flex-shrink: 0;
}
.tags-empty { padding: 24px; text-align: center; color: #999; }
body.dark .search-box { background: #20242e; }
body.dark .search-box input { color: #e6e6e6; }
body.dark .search-box input::placeholder { color: #6b7280; }
body.dark .search-result-item { color: #c9ced8; }
body.dark .search-result-item:hover { background: #1e232d; }
body.dark .search-result-item .s-cat { background: #20242e; color: #9aa0aa; }
body.dark .search-empty { color: #9aa0aa; }
body.dark .md-toolbar { background: #12151b; border-bottom-color: #232733; }
body.dark .tool-btn { background: #20242e; border-color: #2e3440; color: #c9ced8; }
body.dark .tool-btn:hover { background: #2a3040; color: #e6e6e6; }
body.dark .backlinks-box { border-top-color: #232733; }
body.dark .backlinks-header { background: #1a1e26; color: #9aa0aa; border-bottom-color: #232733; }
body.dark .backlink-item { color: #7aa2f7; }
body.dark .backlink-item:hover { background: #1e232d; }
body.dark .backlink-empty { color: #9aa0aa; }
body.dark .preview-content .wiki-link { color: #7aa2f7; border-bottom-color: #7aa2f7; }
body.dark .preview-content .wiki-link:hover { background: #1b2a44; }
body.dark .graph-node circle { fill: #6aa1f7; stroke: #0f1115; }
body.dark .graph-node text { fill: #e6e6e6; }
body.dark .graph-edge { stroke: #2e3440; }
body.dark .graph-hint { color: #9aa0aa; }
body.dark .tag-row { border-bottom-color: #2e3440; }
body.dark .tag-row .tag-name { color: #e6e6e6; }
body.dark .tag-row .tag-count { background: #20242e; color: #9aa0aa; }
body.dark .tags-empty { color: #9aa0aa; }
</style>
</head>
<body>
<header>
<h1>笔记管理后台</h1>
<div class="header-actions">
<div class="search-box">
<input type="text" id="searchInput" placeholder="搜索笔记..." autocomplete="off">
<button class="btn btn-icon" id="clearSearchBtn" onclick="clearSearch()" title="清除搜索" style="display:none;"></button>
</div>
<button class="btn btn-secondary" onclick="openGraph()" title="知识图谱">🕸 知识图谱</button>
<button class="btn btn-secondary" onclick="openTags()" title="标签管理">🏷 标签管理</button>
<button class="btn btn-secondary" onclick="toggleDarkMode()" title="切换深色模式" id="darkModeBtn">🌙 深色</button>
<button class="btn btn-secondary" onclick="exportAll()" title="批量导出全部笔记为 zip">📦 批量导出</button>
<button class="btn btn-secondary" onclick="openTrash()" title="回收站">🗑 回收站</button>
@@ -416,6 +630,11 @@
<div class="tree-container" id="treeContainer">
<div class="loading">加载中...</div>
</div>
<div id="searchResults" style="display:none;"></div>
<div class="backlinks-box" id="backlinksBox" style="display:none;">
<div class="backlinks-header">反向链接</div>
<div class="backlinks-list" id="backlinksList">加载中...</div>
</div>
</div>
<!-- 右侧内容区 -->
@@ -455,6 +674,20 @@
</div>
</div>
<div class="editor-content">
<div class="md-toolbar" id="mdToolbar">
<button type="button" class="tool-btn" data-fmt="bold" title="加粗">B</button>
<button type="button" class="tool-btn" data-fmt="italic" title="斜体"><em>I</em></button>
<button type="button" class="tool-btn" data-fmt="h1" title="一级标题">H1</button>
<button type="button" class="tool-btn" data-fmt="h2" title="二级标题">H2</button>
<button type="button" class="tool-btn" data-fmt="h3" title="三级标题">H3</button>
<button type="button" class="tool-btn" data-fmt="list" title="无序列表">• 列表</button>
<button type="button" class="tool-btn" data-fmt="link" title="链接">🔗 链接</button>
<button type="button" class="tool-btn" data-fmt="code" title="行内代码">&lt;/&gt;</button>
<button type="button" class="tool-btn" data-fmt="image" title="上传图片">🖼 图片</button>
<button type="button" class="tool-btn" data-fmt="table" title="表格">▦ 表格</button>
<button type="button" class="tool-btn" data-fmt="task" title="任务列表">☑ 任务</button>
<span class="autosave-indicator" id="autosaveIndicator" style="display:none;">已自动保存草稿</span>
</div>
<textarea id="noteContent" placeholder="使用 Markdown 编写内容..."></textarea>
</div>
</div>
@@ -556,12 +789,39 @@
<div class="toast" id="toast"></div>
<!-- 知识图谱弹窗 -->
<div class="modal" id="graphModal">
<div class="modal-content" style="min-width: 680px; max-width: 90vw; max-height: 80vh; display: flex; flex-direction: column;">
<h3 style="margin-bottom: 12px;">🕸 知识图谱 <span style="font-size:13px;color:#999;font-weight:normal;margin-left:8px;">点击节点可打开笔记</span></h3>
<div id="graphContainer" style="flex:1; overflow:auto;"><p style="color:#999;padding:20px;text-align:center;">加载中...</p></div>
<div class="modal-actions" style="margin-top:12px;">
<button class="btn btn-secondary" onclick="closeGraph()">关闭</button>
</div>
</div>
</div>
<!-- 标签管理弹窗 -->
<div class="modal" id="tagsModal">
<div class="modal-content" style="min-width: 520px; max-height: 76vh; display: flex; flex-direction: column;">
<h3 style="margin-bottom: 12px;">🏷 标签管理</h3>
<div style="flex:1; overflow-y:auto;" id="tagsList"><p style="color:#999;padding:20px;text-align:center;">加载中...</p></div>
<div class="modal-actions" style="margin-top:12px;">
<button class="btn btn-secondary" onclick="closeTags()">关闭</button>
</div>
</div>
</div>
<script>
const API = '/api';
const ADMIN_API = '/admin/api';
let treeData = [];
let currentItem = null;
let isFolderMode = false;
let editId = null; // 当前打开的笔记 id
let lastSavedContent = ''; // 上次保存/加载的内容,用于自动保存判断
let searchTimer = null; // 搜索防抖定时器
let searchMode = false; // 是否处于搜索模式(隐藏树形)
let autosaveInlineOpen = false; // 草稿已加载标志
// 初始化
async function init() {
@@ -713,6 +973,9 @@
if (isFolderMode) {
// 目录模式
editId = null;
hideBacklinks();
hideAutosaveIndicator();
document.getElementById('editorPane').style.display = 'none';
document.getElementById('resizeHandle').style.display = 'none';
document.getElementById('previewPane').style.display = 'none';
@@ -735,13 +998,28 @@
editorView.style.display = 'flex';
editorView.style.flexDirection = 'row';
editId = currentItem.id;
document.getElementById('noteTitle').value = currentItem.title || '';
document.getElementById('noteCategory').value = currentItem.category || '';
document.getElementById('noteTags').value = parseTags(currentItem.tags).join(', ');
document.getElementById('noteContent').value = currentItem.content || '';
document.getElementById('notePublic').checked = currentItem.is_public !== false;
document.getElementById('notePassword').value = ''; // 不显示密码
lastSavedContent = currentItem.content || '';
autosaveInlineOpen = false;
hideAutosaveIndicator();
updatePreview();
loadBacklinks(currentItem.id);
// 如果有比正式内容更新的草稿,提示加载
if (currentItem.draft_content && currentItem.draft_content !== lastSavedContent) {
if (confirm('检测到未保存的草稿内容,是否加载草稿?')) {
currentItem.content = currentItem.draft_content;
document.getElementById('noteContent').value = currentItem.draft_content;
lastSavedContent = currentItem.draft_content;
autosaveInlineOpen = true;
updatePreview();
}
}
}
renderTree();
@@ -822,6 +1100,11 @@
function showNewNoteModal(parentId) {
if (!parentId && parentId !== 0) return;
currentItem = { parent_id: parentId || 0, is_folder: false, id: null };
editId = null;
lastSavedContent = '';
autosaveInlineOpen = false;
hideBacklinks();
hideAutosaveIndicator();
document.getElementById('emptyState').style.display = 'none';
document.getElementById('editorView').style.display = 'flex';
document.getElementById('editorView').style.flexDirection = 'row';
@@ -894,6 +1177,13 @@
if (data.code === 0) {
showToast(currentItem && currentItem.id ? '笔记已更新' : '笔记已创建', 'success');
// 保存成功后清除已保存的草稿
if (currentItem && currentItem.id && !currentItem.is_folder) {
clearDraft(currentItem.id);
}
lastSavedContent = content;
autosaveInlineOpen = false;
hideAutosaveIndicator();
await loadTree();
if (data.data && data.data.id) {
selectItem(data.data.id);
@@ -972,6 +1262,11 @@
showToast('已删除', 'success');
closeDeleteModal();
currentItem = null;
editId = null;
lastSavedContent = '';
autosaveInlineOpen = false;
hideAutosaveIndicator();
hideBacklinks();
document.getElementById('emptyState').style.display = 'flex';
document.getElementById('editorView').style.display = 'none';
await loadTree();
@@ -986,16 +1281,151 @@
// 更新预览
function updatePreview() {
const content = document.getElementById('noteContent').value;
document.getElementById('previewContent').innerHTML = renderMarkdown(content);
const preview = document.getElementById('previewContent');
preview.innerHTML = renderMarkdown(content);
// 将预览中的 [[笔记标题]] wiki 链接转为可点击
makeWikiLinksClickable(preview);
}
// 监听输入更新预览
// 将 [[标题]] 渲染为可点击链接(在 renderMarkdown 输出后处理)
function makeWikiLinksClickable(previewEl) {
if (!previewEl) return;
const walker = document.createTreeWalker(previewEl, NodeFilter.SHOW_TEXT, {
acceptNode: function(node) {
return /\[\[[^\]]+\]\]/.test(node.textContent) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
}
});
const nodesToProcess = [];
let n;
while ((n = walker.nextNode())) nodesToProcess.push(n);
nodesToProcess.forEach(function(textNode) {
const parts = textNode.textContent.split(/(\[\[[^\]]+\]\])/g);
const frag = document.createDocumentFragment();
parts.forEach(function(part) {
if (/^\[\[[^\]]+\]\]$/.test(part)) {
const title = part.slice(2, -2);
const a = document.createElement('a');
a.className = 'wiki-link';
a.textContent = title;
a.title = '打开笔记:' + title;
a.addEventListener('click', function(ev) {
ev.preventDefault();
openWikiLink(title);
});
frag.appendChild(a);
} else if (part) {
frag.appendChild(document.createTextNode(part));
}
});
textNode.parentNode.replaceChild(frag, textNode);
});
}
// 根据标题打开笔记(用于 wiki 链接与搜索/反向链接跳转)
function openNoteByTitle(title) {
const t = String(title || '').trim();
if (!t) return;
if (searchMode) { clearSearch(); } // 退出搜索模式回到树形
// 在树形数据中查找标题完全匹配的笔记
const match = (treeData || []).find(item => !item.is_folder && item.title === t);
if (match) {
selectItem(match.id);
return;
}
// 树形中未找到时,通过搜索接口全量查找
fetch(`${API}/notes/search?q=${encodeURIComponent(t)}&page=1`).then(r => r.json()).then(data => {
if (data && data.code === 0 && Array.isArray(data.data)) {
const hit = data.data.find(it => it.title === t);
if (hit) {
selectItem(hit.id);
} else {
showToast('未找到笔记:' + t, 'error');
}
}
}).catch(() => showToast('查找笔记失败', 'error'));
}
// 打开 [[wiki]] 链接(开放给全局,因为预览中内联 onclick 不在)
window.openWiki = function(title) { openNoteByTitle(title); };
function openWikiLink(title) { openNoteByTitle(title); }
// ── Markdown 编辑工具栏 ──
function applyFormat(fmt) {
const ta = document.getElementById('noteContent');
if (!ta) return;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const selected = ta.value.substring(start, end) || '文本';
let insert = '';
let selStart = start, selEnd = end;
switch (fmt) {
case 'bold': insert = '**' + selected + '**'; break;
case 'italic': insert = '*' + selected + '*'; break;
case 'h1': insert = '# ' + selected; break;
case 'h2': insert = '## ' + selected; break;
case 'h3': insert = '### ' + selected; break;
case 'list': insert = '\n- ' + selected; selStart += 1; break;
case 'link':
insert = '[' + selected + '](https://)';
selEnd = start + selected.length + 3; // 选中 url 占位
break;
case 'code': insert = '`' + selected + '`'; break;
case 'image':
insert = '![' + selected + '](图片链接)';
selStart = start + 3;
selEnd = selStart + selected.length;
break;
case 'table':
insert = '\n| 列1 | 列2 |\n| --- | --- |\n| 内容 | 内容 |';
break;
case 'task':
insert = '\n- [ ] ' + selected;
selStart += 1;
break;
default: return;
}
ta.value = ta.value.substring(0, start) + insert + ta.value.substring(end);
ta.selectionStart = selStart;
ta.selectionEnd = selEnd;
ta.focus();
updatePreview();
// 标记有未保存修改
showDraftNeedsSave();
}
// 监听输入更新预览 + 工具栏点击
document.addEventListener('DOMContentLoaded', () => {
const contentArea = document.getElementById('noteContent');
if (contentArea) {
contentArea.addEventListener('input', updatePreview);
}
const toolbar = document.getElementById('mdToolbar');
if (toolbar) {
toolbar.addEventListener('click', (e) => {
const btn = e.target.closest('.tool-btn');
if (btn) applyFormat(btn.getAttribute('data-fmt'));
});
}
const searchInput = document.getElementById('searchInput');
if (searchInput) {
searchInput.addEventListener('input', (e) => {
const q = e.target.value.trim();
clearTimeout(searchTimer);
if (!q) { clearSearch(); return; }
searchTimer = setTimeout(() => doSearch(q), 400);
});
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const q = e.target.value.trim();
if (q) doSearch(q);
}
});
}
init();
startAutosave(); // 启动自动保存定时器
});
// 退出登录
@@ -1348,6 +1778,307 @@
}
}
// ═══════════ 新增功能:搜索 / 自动保存草稿 / 反向链接 / 知识图谱 / 标签管理 ═══════════
// ── 1) 全文搜索 ──
async function doSearch(q) {
q = String(q || '').trim();
if (!q) { clearSearch(); return; }
// 进入搜索模式,隐藏树形目录,显示结果列表
const treeContainer = document.getElementById('treeContainer');
const resultsBox = document.getElementById('searchResults');
treeContainer.style.display = 'none';
document.getElementById('clearSearchBtn').style.display = 'inline-block';
searchMode = true;
resultsBox.style.display = 'block';
resultsBox.innerHTML = '<div class="search-empty">搜索中...</div>';
try {
const res = await fetch(`${API}/notes/search?q=${encodeURIComponent(q)}&page=1`);
const data = await res.json();
if (data.code === 0) {
renderSearchResults(data.data || [], q);
} else {
resultsBox.innerHTML = `<div class="search-empty">${escapeHtml(data.message || '搜索失败')}</div>`;
}
} catch (err) {
resultsBox.innerHTML = '<div class="search-empty">搜索失败</div>';
}
}
function renderSearchResults(notes, q) {
const resultsBox = document.getElementById('searchResults');
if (!notes.length) {
resultsBox.innerHTML = `<div class="search-empty">未找到与 “${escapeHtml(q)}” 相关的笔记</div>`;
return;
}
resultsBox.innerHTML = notes.map(note => {
const cat = note.category ? `<span class="s-cat">${escapeHtml(note.category)}</span>` : '';
return `<div class="search-result-item" onclick="openSearchResult(${note.id})">
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(note.title || '(无标题)')}</span>
${cat}
</div>`;
}).join('');
}
async function openSearchResult(id) {
await selectItem(id);
}
function clearSearch() {
searchMode = false;
const input = document.getElementById('searchInput');
if (input) input.value = '';
document.getElementById('clearSearchBtn').style.display = 'none';
const treeContainer = document.getElementById('treeContainer');
const resultsBox = document.getElementById('searchResults');
if (treeContainer) treeContainer.style.display = 'block';
if (resultsBox) { resultsBox.style.display = 'none'; resultsBox.innerHTML = ''; }
}
// ── 2) 自动保存草稿 ──
function startAutosave() {
setInterval(autosaveTick, 8000);
}
function autosaveTick() {
const ta = document.getElementById('noteContent');
if (!ta) return;
if (!editId || !currentItem || currentItem.is_folder) return;
const current = ta.value;
if (current === lastSavedContent) return; // 无改动不保存
// 保存草稿
fetch(`${ADMIN_API}/notes/${editId}/draft`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: current })
}).then(r => r.json()).then(data => {
if (data && data.code === 0) {
lastSavedContent = current;
autosaveInlineOpen = true;
showAutosaveIndicator();
}
}).catch(() => {});
}
function showAutosaveIndicator() {
const el = document.getElementById('autosaveIndicator');
if (el) el.style.display = 'inline';
}
function hideAutosaveIndicator() {
const el = document.getElementById('autosaveIndicator');
if (el) el.style.display = 'none';
}
function showDraftNeedsSave() {
// 编辑后提示草稿待保存(由定时器执行)
const el = document.getElementById('autosaveIndicator');
if (el) el.style.display = 'none'; // 保存中隐藏,保存完成后再显示
}
async function clearDraft(id) {
try {
await fetch(`${ADMIN_API}/notes/${id}/draft`, { method: 'DELETE' });
} catch (e) {}
}
// ── 3) 反向链接 ──
async function loadBacklinks(id) {
const box = document.getElementById('backlinksBox');
const list = document.getElementById('backlinksList');
if (!box || !list) return;
box.style.display = 'none';
list.innerHTML = '加载中...';
try {
const res = await fetch(`${ADMIN_API}/notes/${id}/backlinks`);
const data = await res.json();
if (data.code === 0) {
const items = data.data || [];
if (items.length) {
box.style.display = 'block';
list.innerHTML = items.map(n =>
`<button class="backlink-item" onclick="openSearchResult(${n.id})">🔗 ${escapeHtml(n.title || '(无标题)')}</button>`
).join('');
} else {
box.style.display = 'none';
}
} else {
box.style.display = 'none';
}
} catch (err) {
box.style.display = 'none';
}
}
function hideBacklinks() {
const box = document.getElementById('backlinksBox');
if (box) box.style.display = 'none';
}
// ── 4) 知识图谱 ──
async function openGraph() {
const modal = document.getElementById('graphModal');
modal.style.display = 'flex';
const container = document.getElementById('graphContainer');
container.innerHTML = '<p style="color:#999;padding:20px;text-align:center;">加载中...</p>';
try {
const res = await fetch(`${ADMIN_API}/graph`);
const data = await res.json();
if (data.code === 0) {
renderGraph(data.data, container);
} else {
container.innerHTML = `<p style="color:#d32f2f;padding:20px;text-align:center;">${escapeHtml(data.message || '加载失败')}</p>`;
}
} catch (err) {
container.innerHTML = '<p style="color:#d32f2f;padding:20px;text-align:center;">图谱加载失败</p>';
}
}
function closeGraph() {
document.getElementById('graphModal').style.display = 'none';
}
function renderGraph(graph, container) {
const nodes = (graph && graph.nodes) || [];
const edges = (graph && graph.edges) || [];
if (!nodes.length) {
container.innerHTML = '<p style="color:#999;padding:20px;text-align:center;">暂无节点数据</p>';
return;
}
const W = 620, H = 480, cx = W / 2, cy = H / 2, R = Math.min(W, H) / 2 - 70;
const pos = {};
nodes.forEach((n, i) => {
const angle = (2 * Math.PI * i) / nodes.length - Math.PI / 2;
pos[n.id] = { x: cx + R * Math.cos(angle), y: cy + R * Math.sin(angle) };
});
let svg = `<svg width="${W}" height="${H}" style="background:transparent;">`;
edges.forEach(e => {
if (!pos[e.source] || !pos[e.target]) return;
svg += `<line class="graph-edge" x1="${pos[e.source].x.toFixed(1)}" y1="${pos[e.source].y.toFixed(1)}"
x2="${pos[e.target].x.toFixed(1)}" y2="${pos[e.target].y.toFixed(1)}"/>`;
});
nodes.forEach(n => {
const p = pos[n.id];
const title = String(n.title || '').slice(0, 14) + (String(n.title || '').length > 14 ? '…' : '');
svg += `<g class="graph-node" onclick="openGraphNode(${n.id})">
<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="16"/>
<text x="${p.x.toFixed(1)}" y="${(p.y + 32).toFixed(1)}">${escapeHtml(title)}</text>
</g>`;
});
svg += '</svg>';
container.innerHTML = svg + '<div class="graph-hint">💡 点击节点打开对应笔记</div>';
}
function openGraphNode(id) {
closeGraph();
// 打开对应的笔记并刷新目录
selectItem(id);
}
// ── 5) 标签管理 ──
async function openTags() {
const modal = document.getElementById('tagsModal');
modal.style.display = 'flex';
const list = document.getElementById('tagsList');
list.innerHTML = '<p style="color:#999;padding:20px;text-align:center;">加载中...</p>';
await refreshTagsList(list);
}
function closeTags() {
document.getElementById('tagsModal').style.display = 'none';
}
let _tagNamesCache = []; // 标签管理列表的标签名缓存(避免内联 onclick 转义问题)
async function refreshTagsList(list) {
if (!list) list = document.getElementById('tagsList');
try {
const res = await fetch(`${ADMIN_API}/tags/usage`);
const data = await res.json();
if (data.code === 0) {
const tags = data.data || [];
_tagNamesCache = tags.map(t => t.name);
if (!tags.length) {
list.innerHTML = '<div class="tags-empty">暂无标签</div>';
return;
}
list.innerHTML = tags.map((t, idx) => `
<div class="tag-row">
<span class="tag-name" title="${escapeHtml(t.name)}"># ${escapeHtml(t.name)}</span>
<span class="tag-count">${t.count} 篇</span>
<button class="btn btn-secondary" onclick="renameTag(${idx})">重命名</button>
<button class="btn btn-secondary" onclick="mergeTag(${idx})">合并</button>
<button class="btn btn-danger" onclick="deleteTag(${idx})">删除</button>
</div>`).join('');
} else {
list.innerHTML = `<p style="color:#d32f2f;padding:20px;text-align:center;">${escapeHtml(data.message || '加载失败')}</p>`;
}
} catch (err) {
list.innerHTML = '<p style="color:#d32f2f;padding:20px;text-align:center;">加载失败</p>';
}
}
async function renameTag(idx) {
const oldName = _tagNamesCache[idx];
if (oldName === undefined) return;
const newName = prompt('请输入新的标签名称:', oldName);
if (!newName || newName.trim() === oldName) return;
try {
const res = await fetch(`${ADMIN_API}/tags/rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ old_name: oldName, new_name: newName.trim() })
});
const data = await res.json();
showToast(data.changed ? '重命名成功' : '重命名失败', data.changed ? 'success' : 'error');
} catch (err) {
showToast('重命名失败', 'error');
}
await refreshTagsList();
await loadTree();
if (editId) selectItem(editId);
}
async function mergeTag(idx) {
const fromName = _tagNamesCache[idx];
if (fromName === undefined) return;
const toName = prompt(`将标签 “${fromName}” 合并到:`);
if (!toName || toName.trim() === fromName) return;
try {
const res = await fetch(`${ADMIN_API}/tags/merge`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from: fromName, to: toName.trim() })
});
const data = await res.json();
showToast(data.changed ? '合并成功' : '合并失败', data.changed ? 'success' : 'error');
} catch (err) {
showToast('合并失败', 'error');
}
await refreshTagsList();
await loadTree();
if (editId) selectItem(editId);
}
async function deleteTag(idx) {
const name = _tagNamesCache[idx];
if (name === undefined) return;
if (!confirm(`确定删除标签 “${name}” 吗?`)) return;
try {
const res = await fetch(`${ADMIN_API}/tags`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name })
});
const data = await res.json();
showToast(data.changed ? '删除成功' : '删除失败', data.changed ? 'success' : 'error');
} catch (err) {
showToast('删除失败', 'error');
}
await refreshTagsList();
await loadTree();
if (editId) selectItem(editId);
}
// 初始化深色模式
initDarkMode();
</script>