6a2ffb5d4b
- 有子节点的目录前显示折叠箭头, 点击折叠/展开子目录 - 折叠状态持久化到 localStorage, 刷新后保留 - 与前台目录树折叠行为一致
2391 lines
107 KiB
HTML
2391 lines
107 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<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>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: #f5f5f5;
|
|
height: 100vh;
|
|
overflow: hidden;
|
|
}
|
|
header {
|
|
background: #fff;
|
|
padding: 12px 20px;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
height: 56px;
|
|
}
|
|
header h1 { font-size: 18px; color: #333; }
|
|
.header-actions { display: flex; gap: 12px; align-items: center; }
|
|
.btn {
|
|
padding: 8px 16px;
|
|
border: none;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
transition: all 0.2s;
|
|
}
|
|
.btn-primary { background: #1a73e8; color: #fff; }
|
|
.btn-primary:hover { background: #1557b0; }
|
|
.btn-secondary { background: #f1f3f4; color: #333; }
|
|
.btn-secondary:hover { background: #e8eaed; }
|
|
.btn-danger { background: #dc3545; color: #fff; }
|
|
.btn-danger:hover { background: #c82333; }
|
|
.btn-icon {
|
|
padding: 8px;
|
|
background: transparent;
|
|
color: #666;
|
|
min-width: 36px;
|
|
}
|
|
.btn-icon:hover { background: #f1f3f4; color: #333; }
|
|
|
|
.main {
|
|
display: flex;
|
|
height: calc(100vh - 56px);
|
|
}
|
|
|
|
/* 左侧边栏 - 树形目录 */
|
|
.sidebar {
|
|
width: 280px;
|
|
background: #fff;
|
|
border-right: 1px solid #e0e0e0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex-shrink: 0;
|
|
}
|
|
.sidebar-header {
|
|
padding: 12px 16px;
|
|
border-bottom: 1px solid #e0e0e0;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
.sidebar-header h2 { font-size: 14px; color: #666; }
|
|
.tree-container {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 8px;
|
|
}
|
|
.tree-item {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 8px 12px;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
color: #333;
|
|
user-select: none;
|
|
}
|
|
.tree-item:hover { background: #f1f3f4; }
|
|
.tree-item.active { background: #e8f0fe; color: #1a73e8; }
|
|
.tree-item.folder { font-weight: 500; }
|
|
.tree-item .icon {
|
|
width: 20px;
|
|
height: 20px;
|
|
margin-right: 8px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.tree-item .title {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.tree-item .actions {
|
|
display: none;
|
|
gap: 4px;
|
|
}
|
|
.tree-item:hover .actions { display: flex; }
|
|
.tree-item .actions button {
|
|
padding: 2px 6px;
|
|
font-size: 12px;
|
|
background: transparent;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
}
|
|
.tree-item .actions button:hover { background: #e8eaed; }
|
|
.tree-children { padding-left: 20px; }
|
|
.tree-item .toggle {
|
|
width: 18px;
|
|
height: 18px;
|
|
margin-right: 2px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
border: none;
|
|
background: transparent;
|
|
padding: 0;
|
|
color: #888;
|
|
flex-shrink: 0;
|
|
transition: transform 0.15s ease;
|
|
}
|
|
.tree-item .toggle svg { width: 14px; height: 14px; fill: currentColor; }
|
|
.tree-item .toggle.collapsed { transform: rotate(-90deg); }
|
|
.tree-item .toggle:hover { color: #1a73e8; }
|
|
.tree-children { display: block; }
|
|
.tree-children.hidden { display: none; }
|
|
|
|
|
|
/* 右侧内容区 - 左右分栏 */
|
|
.content {
|
|
flex: 1;
|
|
display: flex;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* 编辑区 */
|
|
.editor-pane {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
border-right: 1px solid #e0e0e0;
|
|
background: #fff;
|
|
}
|
|
.editor-header {
|
|
padding: 12px 16px;
|
|
border-bottom: 1px solid #e0e0e0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
.editor-header input {
|
|
flex: 1;
|
|
padding: 10px 14px;
|
|
border: 1px solid #e0e0e0;
|
|
border-radius: 6px;
|
|
font-size: 16px;
|
|
font-weight: 500;
|
|
}
|
|
.editor-header input:focus {
|
|
outline: none;
|
|
border-color: #1a73e8;
|
|
}
|
|
.editor-meta {
|
|
padding: 12px 16px;
|
|
border-bottom: 1px solid #e0e0e0;
|
|
display: flex;
|
|
gap: 12px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.editor-meta input {
|
|
padding: 8px 12px;
|
|
border: 1px solid #e0e0e0;
|
|
border-radius: 6px;
|
|
font-size: 13px;
|
|
}
|
|
.editor-meta input:focus {
|
|
outline: none;
|
|
border-color: #1a73e8;
|
|
}
|
|
.editor-content {
|
|
flex: 1;
|
|
padding: 16px;
|
|
}
|
|
.editor-content textarea {
|
|
width: 100%;
|
|
height: 100%;
|
|
border: none;
|
|
resize: none;
|
|
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
|
|
font-size: 14px;
|
|
line-height: 1.7;
|
|
padding: 0;
|
|
outline: none;
|
|
}
|
|
|
|
/* 预览区 */
|
|
.preview-pane {
|
|
flex: 1;
|
|
background: #fff;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.preview-header {
|
|
padding: 12px 16px;
|
|
border-bottom: 1px solid #e0e0e0;
|
|
font-size: 14px;
|
|
color: #666;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.preview-header svg { width: 18px; height: 18px; }
|
|
.preview-content {
|
|
flex: 1;
|
|
padding: 24px;
|
|
overflow-y: auto;
|
|
}
|
|
.preview-content h1, .preview-content h2, .preview-content h3 {
|
|
margin: 20px 0 10px 0;
|
|
color: #202124;
|
|
}
|
|
.preview-content h1 { font-size: 28px; }
|
|
.preview-content h2 { font-size: 22px; }
|
|
.preview-content h3 { font-size: 18px; }
|
|
.preview-content p { margin: 10px 0; line-height: 1.8; }
|
|
.preview-content code {
|
|
background: #f1f3f4;
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
font-family: monospace;
|
|
font-size: 13px;
|
|
}
|
|
.preview-content pre {
|
|
background: #f8f9fa;
|
|
padding: 16px;
|
|
border-radius: 8px;
|
|
overflow-x: auto;
|
|
margin: 12px 0;
|
|
}
|
|
.preview-content pre code { background: none; padding: 0; }
|
|
.preview-content blockquote {
|
|
border-left: 4px solid #1a73e8;
|
|
padding-left: 16px;
|
|
margin: 16px 0;
|
|
color: #555;
|
|
}
|
|
.preview-content ul, .preview-content ol {
|
|
margin: 10px 0;
|
|
padding-left: 24px;
|
|
}
|
|
.preview-content li { margin: 6px 0; }
|
|
|
|
/* 空状态 */
|
|
.empty-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
color: #999;
|
|
}
|
|
.empty-state svg {
|
|
width: 64px;
|
|
height: 64px;
|
|
fill: #ddd;
|
|
margin-bottom: 16px;
|
|
}
|
|
.empty-state h3 { font-size: 18px; margin-bottom: 8px; color: #666; }
|
|
.empty-state p { font-size: 14px; }
|
|
|
|
/* 弹窗 */
|
|
.modal {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0; left: 0;
|
|
width: 100%; height: 100%;
|
|
background: rgba(0,0,0,0.5);
|
|
justify-content: center;
|
|
align-items: center;
|
|
z-index: 1000;
|
|
}
|
|
.modal.active { display: flex; }
|
|
.modal-content {
|
|
background: #fff;
|
|
padding: 24px;
|
|
border-radius: 12px;
|
|
max-width: 400px;
|
|
width: 90%;
|
|
}
|
|
.modal-content h3 { margin-bottom: 16px; font-size: 16px; }
|
|
.modal-content input {
|
|
width: 100%;
|
|
padding: 10px 14px;
|
|
border: 1px solid #e0e0e0;
|
|
border-radius: 6px;
|
|
margin-bottom: 16px;
|
|
font-size: 14px;
|
|
}
|
|
.modal-actions {
|
|
display: flex;
|
|
gap: 12px;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
/* Toast */
|
|
.toast {
|
|
position: fixed;
|
|
bottom: 20px; right: 20px;
|
|
padding: 12px 20px;
|
|
background: #333;
|
|
color: #fff;
|
|
border-radius: 8px;
|
|
display: none;
|
|
z-index: 1001;
|
|
}
|
|
.toast.show { display: block; }
|
|
.toast.success { background: #28a745; }
|
|
.toast.error { background: #dc3545; }
|
|
|
|
/* Resize handle */
|
|
.resize-handle {
|
|
width: 4px;
|
|
cursor: col-resize;
|
|
background: transparent;
|
|
transition: background 0.2s;
|
|
}
|
|
.resize-handle:hover { background: #1a73e8; }
|
|
|
|
/* ───── 深色模式(专业协调配色)───── */
|
|
body.dark {
|
|
background: #0f1115;
|
|
color: #e6e6e6;
|
|
}
|
|
body.dark header {
|
|
background: #15181e;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.5);
|
|
border-bottom: 1px solid #232733;
|
|
}
|
|
body.dark header h1 { color: #e6e6e6; }
|
|
|
|
body.dark .btn-secondary { background: #20242e; color: #c9ced8; border: 1px solid #2e3440; }
|
|
body.dark .btn-secondary:hover { background: #2a3040; color: #e6e6e6; }
|
|
body.dark .btn-icon { color: #9aa0aa; }
|
|
body.dark .btn-icon:hover { background: #20242e; color: #e6e6e6; }
|
|
|
|
body.dark .main { background: #0f1115; }
|
|
body.dark .sidebar {
|
|
background: #15181e;
|
|
border-right: 1px solid #232733;
|
|
}
|
|
body.dark .sidebar-header { border-bottom: 1px solid #232733; }
|
|
body.dark .sidebar-header h2 { color: #9aa0aa; }
|
|
|
|
body.dark .tree-item { color: #c9ced8; }
|
|
body.dark .tree-item:hover { background: #1e232d; }
|
|
body.dark .tree-item.active { background: #1b2a44; color: #7aa2f7; }
|
|
body.dark .tree-item .actions button {
|
|
background: #1e232d; border-color: #2e3440; color: #9aa0aa;
|
|
}
|
|
body.dark .tree-item .actions button:hover { background: #2a3040; color: #e6e6e6; }
|
|
|
|
body.dark .content { background: #0f1115; }
|
|
body.dark .editor-pane {
|
|
background: #12151b;
|
|
border-right: 1px solid #232733;
|
|
}
|
|
body.dark .editor-header, body.dark .editor-meta {
|
|
border-bottom: 1px solid #232733;
|
|
}
|
|
body.dark input, body.dark textarea, body.dark select {
|
|
background: #1a1e26; color: #e6e6e6; border-color: #2e3440;
|
|
}
|
|
body.dark input::placeholder, body.dark textarea::placeholder { color: #6b7280; }
|
|
body.dark input:focus, body.dark textarea:focus { border-color: #6aa1f7; }
|
|
body.dark .editor-content textarea { background: #12151b; color: #e6e6e6; }
|
|
body.dark .editor-meta label { color: #9aa0aa; }
|
|
|
|
body.dark .preview-pane { background: #12151b; }
|
|
body.dark .preview-header { border-bottom: 1px solid #232733; color: #9aa0aa; }
|
|
body.dark .preview-content h1, body.dark .preview-content h2, body.dark .preview-content h3 { color: #f0f2f5; }
|
|
body.dark .preview-content p { color: #c9ced8; }
|
|
body.dark .preview-content code { background: #1e232d; color: #e0a46a; }
|
|
body.dark .preview-content pre { background: #0b0d12; border: 1px solid #232733; }
|
|
body.dark .preview-content pre code { background: none; color: #d4d7de; }
|
|
body.dark .preview-content blockquote { border-left-color: #6aa1f7; color: #9aa0aa; }
|
|
|
|
body.dark .empty-state { color: #9aa0aa; }
|
|
body.dark .empty-state svg { fill: #2a2f3a; }
|
|
body.dark .empty-state h3 { color: #c9ced8; }
|
|
|
|
body.dark .modal-content { background: #1a1e26; color: #e6e6e6; }
|
|
body.dark .modal-content h3 { color: #f0f2f5; }
|
|
|
|
/* 回收站/版本/分享面板内分割线 */
|
|
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; }
|
|
|
|
/* 侧栏筛选(收藏/分类) */
|
|
.side-filter {
|
|
border-bottom: 1px solid #e0e0e0;
|
|
flex-shrink: 0;
|
|
max-height: 45%;
|
|
overflow-y: auto;
|
|
background: #fff;
|
|
}
|
|
.side-filter-header {
|
|
padding: 8px 14px;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: #666;
|
|
}
|
|
.filter-chip-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
padding: 4px 12px 10px;
|
|
}
|
|
.filter-chip {
|
|
padding: 4px 10px;
|
|
border: 1px solid #e0e0e0;
|
|
border-radius: 14px;
|
|
background: #fff;
|
|
color: #555;
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
}
|
|
.filter-chip:hover { background: #f1f3f4; }
|
|
.filter-chip.active { background: #1a73e8; color: #fff; border-color: #1a73e8; }
|
|
.filter-chip.star { color: #e5a00d; }
|
|
.filter-chip.star.active { background: #e5a00d; color: #fff; border-color: #e5a00d; }
|
|
|
|
/* 预览中的 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 .side-filter { background: #15181e; border-bottom-color: #232733; }
|
|
body.dark .side-filter-header { color: #9aa0aa; }
|
|
body.dark .filter-chip { background: #1a1e26; border-color: #2a3040; color: #c9ced8; }
|
|
body.dark .filter-chip:hover { background: #2a3040; }
|
|
body.dark .filter-chip.active { background: #1a73e8; color: #fff; border-color: #1a73e8; }
|
|
body.dark .filter-chip.star.active { background: #e5a00d; color: #fff; border-color: #e5a00d; }
|
|
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; }
|
|
/* 平台设置:注册开关 */
|
|
.switch { position: relative; display: inline-block; width: 48px; height: 26px; }
|
|
.switch input { opacity: 0; width: 0; height: 0; }
|
|
.slider {
|
|
position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;
|
|
background: #ccc; transition: .3s; border-radius: 26px;
|
|
}
|
|
.slider:before {
|
|
position: absolute; content: ""; height: 20px; width: 20px; left: 3px; bottom: 3px;
|
|
background: #fff; transition: .3s; border-radius: 50%;
|
|
}
|
|
.switch input:checked + .slider { background: #1976d2; }
|
|
.switch input:checked + .slider:before { transform: translateX(22px); }
|
|
body.dark .slider { background: #2a3040; }
|
|
body.dark .setting-row { border-bottom-color: #232733; }
|
|
</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>
|
|
<button class="btn btn-secondary" id="settingsBtn" onclick="openSettings()" title="平台设置(仅管理员)" style="display:none;">⚙ 设置</button>
|
|
<a href="/" class="btn btn-secondary" target="_blank">前台预览</a>
|
|
<button class="btn btn-secondary" onclick="logout()">退出登录</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="main">
|
|
<!-- 左侧边栏 - 树形目录 -->
|
|
<div class="sidebar">
|
|
<div class="sidebar-header">
|
|
<h2>笔记目录</h2>
|
|
<div>
|
|
<button class="btn btn-icon" onclick="showNewFolderModal(0)" title="新建目录">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M20 6h-8l-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm-1 8h-3v3h-2v-3h-3v-2h3V9h2v3h3v2z"/></svg>
|
|
</button>
|
|
<button class="btn btn-icon" onclick="showNewNoteModal(0)" title="新建笔记">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="side-filter" id="sideFilter" style="display:none;"></div>
|
|
<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>
|
|
|
|
<!-- 右侧内容区 -->
|
|
<div class="content" id="contentArea">
|
|
<!-- 空状态 -->
|
|
<div class="empty-state" id="emptyState">
|
|
<svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg>
|
|
<h3>选择一个笔记或目录</h3>
|
|
<p>从左侧列表选择,或点击 + 新建</p>
|
|
</div>
|
|
|
|
<!-- 编辑器和预览(隐藏直到选中笔记) -->
|
|
<div id="editorView" style="display: none; flex: 1; display: none; flex-direction: column;">
|
|
<!-- 编辑区 -->
|
|
<div class="editor-pane" id="editorPane">
|
|
<div class="editor-header">
|
|
<input type="text" id="noteTitle" placeholder="笔记标题">
|
|
<button class="btn btn-primary" onclick="saveNote()">保存</button>
|
|
<button class="btn btn-secondary" onclick="exportNote()">导出</button>
|
|
<button class="btn btn-secondary" onclick="openVersions()" title="版本历史">🕘 历史</button>
|
|
<button class="btn btn-secondary" onclick="openShare()" title="分享链接">🔗 分享</button>
|
|
<button class="btn btn-warning" onclick="document.getElementById('importFile').click()">导入</button>
|
|
<input type="file" id="importFile" accept=".md" style="display:none" onchange="importNote(this.files[0])">
|
|
<button class="btn btn-danger" onclick="confirmDelete()">删除</button>
|
|
</div>
|
|
<div class="editor-meta">
|
|
<input type="text" id="noteCategory" placeholder="分类(可选)" style="flex: 1;">
|
|
<input type="text" id="noteTags" placeholder="标签,多个用逗号分隔" style="flex: 1;">
|
|
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 13px; color: #666; white-space: nowrap;">
|
|
<input type="checkbox" id="notePublic" checked style="width: 16px; height: 16px;">
|
|
<svg viewBox="0 0 24 24" style="width: 14px; height: 14px; fill: #666;"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg>
|
|
公开
|
|
</label>
|
|
<!-- 收藏切换按钮 -->
|
|
<button type="button" id="favBtn" class="btn btn-icon" onclick="toggleFavorite()" title="收藏/取消收藏"
|
|
style="display:none; align-items:center; gap:6px; font-size:13px; color:#666; white-space:nowrap; padding:4px 10px;">
|
|
<svg id="favStar" viewBox="0 0 24 24" style="width:16px; height:16px; fill:#ccc;"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>
|
|
<span id="favText">收藏</span>
|
|
</button>
|
|
<div style="display: flex; align-items: center; gap: 6px; margin-left: 12px;">
|
|
<svg viewBox="0 0 24 24" style="width: 14px; height: 14px; fill: #666;"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/></svg>
|
|
<input type="password" id="notePassword" placeholder="访问密码(可选)" style="width: 120px; padding: 6px 10px; border: 1px solid #e0e0e0; border-radius: 4px; font-size: 12px;">
|
|
</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>
|
|
<div class="resize-handle" id="resizeHandle"></div>
|
|
<!-- 预览区 -->
|
|
<div class="preview-pane" id="previewPane">
|
|
<div class="preview-header">
|
|
<svg viewBox="0 0 24 24" fill="#666"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>
|
|
预览
|
|
</div>
|
|
<div class="preview-content" id="previewContent">
|
|
<p style="color: #999;">开始编辑以查看预览...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 新建/编辑目录弹窗 -->
|
|
<div class="modal" id="folderModal">
|
|
<div class="modal-content">
|
|
<h3 id="folderModalTitle">新建目录</h3>
|
|
<input type="hidden" id="folderParentId">
|
|
<input type="hidden" id="folderEditId">
|
|
<input type="text" id="folderName" placeholder="目录名称">
|
|
<div class="modal-actions">
|
|
<button class="btn btn-secondary" onclick="closeFolderModal()">取消</button>
|
|
<button class="btn btn-primary" onclick="saveFolder()">保存</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 删除确认弹窗 -->
|
|
<div class="modal" id="deleteModal">
|
|
<div class="modal-content">
|
|
<h3>确认删除</h3>
|
|
<p id="deleteMessage">确定要删除吗?删除后可在回收站中恢复。</p>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-secondary" onclick="closeDeleteModal()">取消</button>
|
|
<button class="btn btn-danger" onclick="doDelete()">删除</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 回收站弹窗 -->
|
|
<div class="modal" id="trashModal">
|
|
<div class="modal-content" style="min-width: 560px; max-height: 70vh; display: flex; flex-direction: column;">
|
|
<h3 style="margin-bottom: 12px;">🗑 回收站 <span style="font-size:13px;color:#999;font-weight:normal;">(删除的笔记会在这里保留,可恢复或彻底清除)</span></h3>
|
|
<div style="flex:1; overflow-y:auto;" id="trashList"><p style="color:#999;padding:20px;text-align:center;">加载中...</p></div>
|
|
<div class="modal-actions" style="margin-top:12px;justify-content:space-between;">
|
|
<button class="btn btn-danger" onclick="emptyTrash()">清空回收站</button>
|
|
<div>
|
|
<button class="btn btn-secondary" onclick="closeTrash()">关闭</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 版本历史弹窗 -->
|
|
<div class="modal" id="versionsModal">
|
|
<div class="modal-content" style="min-width: 560px; max-height: 70vh; display: flex; flex-direction: column;">
|
|
<h3 style="margin-bottom: 12px;">🕘 版本历史</h3>
|
|
<div style="flex:1; overflow-y:auto;" id="versionsList"><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="closeVersions()">关闭</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 平台设置弹窗(仅管理员) -->
|
|
<div class="modal" id="settingsModal">
|
|
<div class="modal-content" style="min-width: 460px;">
|
|
<h3 style="margin-bottom: 16px;">⚙ 平台设置</h3>
|
|
<div class="setting-row" style="display:flex;justify-content:space-between;align-items:center;padding:12px 0;border-bottom:1px solid #eee;">
|
|
<div>
|
|
<div style="font-weight:600;">开放注册</div>
|
|
<div style="font-size:12px;color:#999;">关闭后新用户无法注册,仅已有账号可登录</div>
|
|
</div>
|
|
<label class="switch">
|
|
<input type="checkbox" id="regSwitch" onchange="toggleRegistration(this)">
|
|
<span class="slider"></span>
|
|
</label>
|
|
</div>
|
|
<div class="modal-actions" style="margin-top:16px;">
|
|
<button class="btn btn-secondary" onclick="closeSettings()">关闭</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<!-- 分享弹窗 -->
|
|
<div class="modal" id="shareModal">
|
|
<div class="modal-content" style="min-width: 520px;">
|
|
<h3 style="margin-bottom: 16px;">🔗 分享链接</h3>
|
|
<div id="shareNoToken" style="display:block;">
|
|
<p style="color:#666;font-size:14px;margin-bottom:14px;">生成一个链接,无需登录即可查看该笔记。</p>
|
|
<label style="font-size:13px;color:#888;">可选:过期时间</label>
|
|
<select id="shareExpire" style="width:100%;padding:8px;margin:6px 0 14px;border:1px solid #ccc;border-radius:6px;">
|
|
<option value="0">永久有效</option>
|
|
<option value="24">24 小时</option>
|
|
<option value="72">3 天</option>
|
|
<option value="168">7 天</option>
|
|
<option value="720">30 天</option>
|
|
</select>
|
|
<button class="btn btn-primary" style="width:100%;" onclick="createShare()">生成分享链接</button>
|
|
</div>
|
|
<div id="shareHasToken" style="display:none;">
|
|
<label style="font-size:13px;color:#888;">分享链接</label>
|
|
<div style="display:flex;gap:8px;margin:6px 0 12px;">
|
|
<input type="text" id="shareUrl" readonly style="flex:1;padding:8px;border:1px solid #ccc;border-radius:6px;background:#f5f5f5;">
|
|
<button class="btn btn-secondary" onclick="copyShareUrl()">复制</button>
|
|
</div>
|
|
<button class="btn btn-danger" style="width:100%;" onclick="revokeShare()">撤销分享</button>
|
|
</div>
|
|
<div class="modal-actions" style="margin-top:16px;">
|
|
<button class="btn btn-secondary" onclick="closeShare()">关闭</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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 = [];
|
|
// 折叠的目录 id 集合(持久化到 localStorage,按用户隔离)
|
|
let collapsedFolders = new Set();
|
|
try {
|
|
const saved = JSON.parse(localStorage.getItem('noteManagerCollapsedFolders') || '[]');
|
|
collapsedFolders = new Set(Array.isArray(saved) ? saved : []);
|
|
} catch (e) { collapsedFolders = new Set(); }
|
|
function saveCollapsedFolders() {
|
|
try { localStorage.setItem('noteManagerCollapsedFolders', JSON.stringify([...collapsedFolders])); } catch (e) {}
|
|
}
|
|
let currentItem = null;
|
|
let isFolderMode = false;
|
|
let editId = null; // 当前打开的笔记 id
|
|
let lastSavedContent = ''; // 上次保存/加载的内容,用于自动保存判断
|
|
let searchTimer = null; // 搜索防抖定时器
|
|
let searchMode = false; // 是否处于搜索模式(隐藏树形)
|
|
let autosaveInlineOpen = false; // 草稿已加载标志
|
|
let filterMode = { type: 'all', value: '' }; // 侧栏筛选:all / fav / category:xxx
|
|
let categoryList = []; // 分类列表(用于筛选 chips)
|
|
|
|
// 初始化
|
|
async function init() {
|
|
await checkAdmin();
|
|
await loadTree();
|
|
setupResize();
|
|
setupImagePaste();
|
|
}
|
|
|
|
// 检查当前用户是否为管理员(决定是否显示「设置」按钮)
|
|
async function checkAdmin() {
|
|
try {
|
|
const res = await fetch('/api/auth/me');
|
|
const data = await res.json();
|
|
const me = data.data;
|
|
if (me && me.role === 'admin') {
|
|
const btn = document.getElementById('settingsBtn');
|
|
if (btn) btn.style.display = '';
|
|
}
|
|
} catch (e) { /* 非管理员不显示 */ }
|
|
}
|
|
|
|
// ── 平台设置(注册开关,管理员) ──
|
|
async function openSettings() {
|
|
document.getElementById('settingsModal').style.display = 'flex';
|
|
try {
|
|
const res = await fetch(`${ADMIN_API}/settings/registration`);
|
|
const data = await res.json();
|
|
if (data.code === 0) {
|
|
document.getElementById('regSwitch').checked = !!data.data.registration_enabled;
|
|
}
|
|
} catch (e) {
|
|
showToast('读取设置失败', 'error');
|
|
}
|
|
}
|
|
function closeSettings() {
|
|
document.getElementById('settingsModal').style.display = 'none';
|
|
}
|
|
async function toggleRegistration(el) {
|
|
const enabled = el.checked;
|
|
try {
|
|
const res = await fetch(`${ADMIN_API}/settings/registration`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ enabled: enabled })
|
|
});
|
|
const data = await res.json();
|
|
if (data.code === 0) {
|
|
showToast(enabled ? '已开放注册' : '已关闭注册', 'success');
|
|
} else {
|
|
el.checked = !enabled;
|
|
showToast(data.message || '操作失败', 'error');
|
|
}
|
|
} catch (e) {
|
|
el.checked = !enabled;
|
|
showToast('操作失败', 'error');
|
|
}
|
|
}
|
|
|
|
// 设置粘贴图片监听
|
|
function setupImagePaste() {
|
|
const textarea = document.getElementById('noteContent');
|
|
if (!textarea) return;
|
|
|
|
textarea.addEventListener('paste', async (e) => {
|
|
// 检查剪贴板中是否有图片
|
|
const items = e.clipboardData?.items;
|
|
if (!items) return;
|
|
|
|
for (let item of items) {
|
|
if (item.type.indexOf('image') === 0) {
|
|
e.preventDefault();
|
|
|
|
const file = item.getAsFile();
|
|
if (!file) continue;
|
|
|
|
// 显示上传提示
|
|
const originalValue = textarea.value;
|
|
const cursorPos = textarea.selectionStart;
|
|
const beforeCursor = originalValue.substring(0, cursorPos);
|
|
const afterCursor = originalValue.substring(cursorPos);
|
|
|
|
textarea.value = beforeCursor + '\n\n' + afterCursor;
|
|
textarea.selectionStart = textarea.selectionEnd = cursorPos + 14;
|
|
textarea.focus();
|
|
|
|
try {
|
|
const url = await uploadImage(file);
|
|
// 替换上传提示为实际图片
|
|
textarea.value = textarea.value.replace('', ``);
|
|
// 手动触发预览更新
|
|
updatePreview();
|
|
} catch (err) {
|
|
// 替换为错误信息
|
|
textarea.value = textarea.value.replace('', '');
|
|
showToast('图片上传失败', 'error');
|
|
// 手动触发预览更新
|
|
updatePreview();
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 上传图片
|
|
async function uploadImage(file) {
|
|
const formData = new FormData();
|
|
formData.append('image', file);
|
|
|
|
const res = await fetch(`${ADMIN_API}/upload`, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
body: formData
|
|
});
|
|
|
|
const data = await res.json();
|
|
if (data.code !== 0) {
|
|
throw new Error(data.message || '上传失败');
|
|
}
|
|
|
|
return data.data.url;
|
|
}
|
|
|
|
// 加载树形数据
|
|
async function loadTree() {
|
|
try {
|
|
const res = await fetch(`/admin/api/tree`);
|
|
const data = await res.json();
|
|
if (data.code === 0) {
|
|
treeData = data.data;
|
|
renderTree();
|
|
renderSideFilter();
|
|
}
|
|
} catch (err) {
|
|
showToast('加载目录失败', 'error');
|
|
}
|
|
}
|
|
|
|
// 渲染树形结构
|
|
function renderTree() {
|
|
const container = document.getElementById('treeContainer');
|
|
if (treeData.length === 0) {
|
|
container.innerHTML = '<div style="padding: 20px; text-align: center; color: #999;">暂无内容,点击 + 新建</div>';
|
|
return;
|
|
}
|
|
|
|
// 若处于筛选模式(收藏/分类),渲染扁平结果列表
|
|
if (filterMode.type !== 'all') {
|
|
const list = getFilteredList();
|
|
if (list.length === 0) {
|
|
container.innerHTML = '<div style="padding: 20px; text-align: center; color: #999;">' +
|
|
(filterMode.type === 'fav' ? '暂无收藏笔记' : '该分类暂无笔记') + '</div>';
|
|
} else {
|
|
container.innerHTML = list.map(item => {
|
|
const isActive = currentItem && currentItem.id === item.id;
|
|
return `
|
|
<div class="tree-item ${isActive ? 'active' : ''}" onclick="selectItem(${item.id})">
|
|
<span class="icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="#666"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg></span>
|
|
<span class="title">${escapeHtml(item.title)}</span>
|
|
${item.is_favorite ? '<span style="color:#e5a00d;font-size:11px;">★</span>' : ''}
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 构建树形结构
|
|
const rootItems = treeData.filter(item => item.parent_id === 0);
|
|
container.innerHTML = renderTreeItems(rootItems);
|
|
}
|
|
|
|
// 根据当前筛选模式返回扁平笔记列表
|
|
function getFilteredList() {
|
|
if (filterMode.type === 'fav') {
|
|
return treeData.filter(i => !i.is_folder && i.is_favorite);
|
|
}
|
|
if (filterMode.type === 'category' && filterMode.value) {
|
|
return treeData.filter(i => !i.is_folder && i.category === filterMode.value);
|
|
}
|
|
return treeData.filter(i => !i.is_folder);
|
|
}
|
|
|
|
// 渲染侧栏筛选条(收藏 + 分类)
|
|
function renderSideFilter() {
|
|
const box = document.getElementById('sideFilter');
|
|
if (!box) return;
|
|
// 汇总分类
|
|
const catSet = new Set();
|
|
treeData.forEach(i => { if (!i.is_folder && i.category) catSet.add(i.category); });
|
|
categoryList = Array.from(catSet);
|
|
|
|
let html = '<div class="side-filter-header">快速筛选</div><div class="filter-chip-row">';
|
|
html += `<span class="filter-chip ${filterMode.type === 'all' ? 'active' : ''}" onclick="setFilter('all','')">全部</span>`;
|
|
html += `<span class="filter-chip star ${filterMode.type === 'fav' ? 'active' : ''}" onclick="setFilter('fav','')">★ 收藏</span>`;
|
|
categoryList.forEach(cat => {
|
|
const hot = filterMode.type === 'category' && filterMode.value === cat;
|
|
html += `<span class="filter-chip ${hot ? 'active' : ''}" onclick="setFilter('category','${escapeHtml(cat)}')">${escapeHtml(cat)}</span>`;
|
|
});
|
|
// 清理没有笔记的分类(含已删除引用)
|
|
if (categoryList.length === 0) {
|
|
html += '<span style="font-size:12px;color:#999;">暂无分类</span>';
|
|
}
|
|
html += '</div>';
|
|
|
|
box.innerHTML = html;
|
|
box.style.display = 'block';
|
|
}
|
|
|
|
// 设置筛选模式并重绘
|
|
function setFilter(type, value) {
|
|
filterMode = { type, value };
|
|
// 筛选时退出目录模式(避免编辑目录)
|
|
if (type !== 'all') {
|
|
searchMode = false;
|
|
}
|
|
renderSideFilter();
|
|
renderTree();
|
|
}
|
|
|
|
function renderTreeItems(items) {
|
|
return items.map(item => {
|
|
const children = treeData.filter(c => c.parent_id === item.id);
|
|
const hasChildren = children.length > 0;
|
|
const isActive = currentItem && currentItem.id === item.id;
|
|
|
|
if (item.is_folder) {
|
|
// 目录折叠状态:默认展开;在 collapsedFolders 中则折叠
|
|
const isCollapsed = collapsedFolders.has(item.id);
|
|
return `
|
|
<div class="tree-item folder ${isActive ? 'active' : ''}" onclick="selectItem(${item.id})">
|
|
${hasChildren ? `
|
|
<button class="toggle ${isCollapsed ? 'collapsed' : ''}" title="${isCollapsed ? '展开' : '折叠'}" onclick="event.stopPropagation(); toggleFolder(${item.id})">
|
|
<svg viewBox="0 0 24 24"><path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6z"/></svg>
|
|
</button>` : `
|
|
<span class="toggle" style="visibility:hidden;"><svg viewBox="0 0 24 24"><path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6z"/></svg></span>`}
|
|
<span class="icon">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="${isActive ? '#1a73e8' : '#fbbc04'}">
|
|
<path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/>
|
|
</svg>
|
|
</span>
|
|
<span class="title">${escapeHtml(item.title)}</span>
|
|
<div class="actions">
|
|
<button onclick="event.stopPropagation(); showNewFolderModal(${item.id})">+目录</button>
|
|
<button onclick="event.stopPropagation(); showNewNoteModal(${item.id})">+笔记</button>
|
|
</div>
|
|
</div>
|
|
${hasChildren ? `<div class="tree-children ${isCollapsed ? 'hidden' : ''}">${renderTreeItems(children)}</div>` : ''}
|
|
`;
|
|
} else {
|
|
return `
|
|
<div class="tree-item ${isActive ? 'active' : ''}" onclick="selectItem(${item.id})">
|
|
<span class="toggle" style="visibility:hidden;"><svg viewBox="0 0 24 24"><path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6z"/></svg></span>
|
|
<span class="icon">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="#666">
|
|
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/>
|
|
</svg>
|
|
</span>
|
|
<span class="title">${escapeHtml(item.title)}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
}).join('');
|
|
}
|
|
|
|
// 折叠/展开目录
|
|
function toggleFolder(id) {
|
|
if (collapsedFolders.has(id)) {
|
|
collapsedFolders.delete(id);
|
|
} else {
|
|
collapsedFolders.add(id);
|
|
}
|
|
saveCollapsedFolders();
|
|
renderTree();
|
|
}
|
|
|
|
// 选择项目
|
|
async function selectItem(id) {
|
|
try {
|
|
const res = await fetch(`${ADMIN_API}/notes/${id}`);
|
|
const data = await res.json();
|
|
if (data.code === 0) {
|
|
currentItem = data.data;
|
|
isFolderMode = currentItem.is_folder;
|
|
|
|
document.getElementById('emptyState').style.display = 'none';
|
|
const editorView = document.getElementById('editorView');
|
|
|
|
if (isFolderMode) {
|
|
// 目录模式
|
|
editId = null;
|
|
hideBacklinks();
|
|
hideAutosaveIndicator();
|
|
document.getElementById('editorPane').style.display = 'none';
|
|
document.getElementById('resizeHandle').style.display = 'none';
|
|
document.getElementById('previewPane').style.display = 'none';
|
|
editorView.style.display = 'flex';
|
|
editorView.innerHTML = `
|
|
<div style="padding: 40px; text-align: center;">
|
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="#fbbc04" style="margin-bottom: 16px;">
|
|
<path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/>
|
|
</svg>
|
|
<h3 style="margin-bottom: 8px;">${escapeHtml(currentItem.title)}</h3>
|
|
<p style="color: #666; margin-bottom: 20px;">目录:包含 ${treeData.filter(t => t.parent_id === id).length} 个项目</p>
|
|
<button class="btn btn-secondary" onclick="showEditFolderModal()">编辑目录</button>
|
|
</div>
|
|
`;
|
|
} else {
|
|
// 笔记模式
|
|
document.getElementById('editorPane').style.display = 'flex';
|
|
document.getElementById('resizeHandle').style.display = 'block';
|
|
document.getElementById('previewPane').style.display = 'flex';
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
|
|
updateFavUI();
|
|
renderTree();
|
|
}
|
|
} catch (err) {
|
|
showToast('加载失败', 'error');
|
|
}
|
|
}
|
|
|
|
// 显示新建目录弹窗
|
|
function showNewFolderModal(parentId) {
|
|
document.getElementById('folderModalTitle').textContent = '新建目录';
|
|
document.getElementById('folderParentId').value = parentId;
|
|
document.getElementById('folderEditId').value = '';
|
|
document.getElementById('folderName').value = '';
|
|
document.getElementById('folderModal').classList.add('active');
|
|
}
|
|
|
|
// 显示编辑目录弹窗
|
|
function showEditFolderModal() {
|
|
if (!currentItem) return;
|
|
document.getElementById('folderModalTitle').textContent = '编辑目录';
|
|
document.getElementById('folderParentId').value = currentItem.parent_id;
|
|
document.getElementById('folderEditId').value = currentItem.id;
|
|
document.getElementById('folderName').value = currentItem.title;
|
|
document.getElementById('folderModal').classList.add('active');
|
|
}
|
|
|
|
function closeFolderModal() {
|
|
document.getElementById('folderModal').classList.remove('active');
|
|
}
|
|
|
|
async function saveFolder() {
|
|
const name = document.getElementById('folderName').value.trim();
|
|
const parentId = parseInt(document.getElementById('folderParentId').value) || 0;
|
|
const editId = document.getElementById('folderEditId').value;
|
|
|
|
if (!name) {
|
|
showToast('请输入目录名称', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
let res, data;
|
|
if (editId) {
|
|
// 更新
|
|
res = await fetch(`${ADMIN_API}/notes/${editId}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title: name })
|
|
});
|
|
} else {
|
|
// 新建
|
|
res = await fetch(`${ADMIN_API}/notes`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title: name, is_folder: true, parent_id: parentId })
|
|
});
|
|
}
|
|
data = await res.json();
|
|
|
|
if (data.code === 0) {
|
|
showToast(editId ? '目录已更新' : '目录已创建', 'success');
|
|
closeFolderModal();
|
|
await loadTree();
|
|
if (editId && currentItem && currentItem.id == editId) {
|
|
selectItem(parseInt(editId));
|
|
}
|
|
} else {
|
|
showToast(data.message || '保存失败', 'error');
|
|
}
|
|
} catch (err) {
|
|
showToast('保存失败', 'error');
|
|
}
|
|
}
|
|
|
|
// 显示新建笔记弹窗
|
|
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';
|
|
document.getElementById('editorPane').style.display = 'flex';
|
|
document.getElementById('resizeHandle').style.display = 'block';
|
|
document.getElementById('previewPane').style.display = 'flex';
|
|
document.getElementById('noteTitle').value = '';
|
|
document.getElementById('noteCategory').value = '';
|
|
document.getElementById('noteTags').value = '';
|
|
document.getElementById('noteContent').value = '';
|
|
updatePreview();
|
|
updateFavUI();
|
|
}
|
|
|
|
// 保存笔记
|
|
// 收藏切换按钮(点击直接调接口即时生效)
|
|
async function toggleFavorite() {
|
|
const btn = document.getElementById('favBtn');
|
|
if (!currentItem || !currentItem.id || currentItem.is_folder) {
|
|
showToast('请先打开一篇笔记', 'error');
|
|
return;
|
|
}
|
|
const newVal = btn.dataset.fav !== '1';
|
|
try {
|
|
const res = await fetch(`${ADMIN_API}/notes/${currentItem.id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ is_favorite: newVal })
|
|
});
|
|
const data = await res.json();
|
|
if (data.code === 0) {
|
|
currentItem.is_favorite = newVal;
|
|
btn.dataset.fav = newVal ? '1' : '0';
|
|
document.getElementById('favText').textContent = newVal ? '已收藏' : '收藏';
|
|
document.getElementById('favStar').style.fill = newVal ? '#e5a00d' : '#ccc';
|
|
showToast(newVal ? '已收藏' : '已取消收藏', 'success');
|
|
// 若当前正处于收藏筛选,刷新列表
|
|
if (filterMode.type === 'fav' || filterMode.type === 'all') {
|
|
await loadTree();
|
|
}
|
|
} else {
|
|
showToast(data.message || '操作失败', 'error');
|
|
}
|
|
} catch (err) {
|
|
showToast('操作失败', 'error');
|
|
}
|
|
}
|
|
|
|
// 根据 currentItem 刷新收藏按钮状态
|
|
function updateFavUI() {
|
|
const btn = document.getElementById('favBtn');
|
|
if (!btn) return;
|
|
const fav = currentItem && currentItem.id && currentItem.is_favorite;
|
|
btn.dataset.fav = fav ? '1' : '0';
|
|
document.getElementById('favText').textContent = fav ? '已收藏' : '收藏';
|
|
document.getElementById('favStar').style.fill = fav ? '#e5a00d' : '#ccc';
|
|
btn.style.display = (!currentItem || currentItem.is_folder) ? 'none' : 'flex';
|
|
}
|
|
|
|
async function saveNote() {
|
|
const title = document.getElementById('noteTitle').value.trim();
|
|
const content = document.getElementById('noteContent').value;
|
|
const category = document.getElementById('noteCategory').value.trim();
|
|
const tagsInput = document.getElementById('noteTags').value.trim();
|
|
const isPublic = document.getElementById('notePublic').checked;
|
|
const password = document.getElementById('notePassword').value;
|
|
|
|
if (!title) {
|
|
showToast('请输入标题', 'error');
|
|
return;
|
|
}
|
|
|
|
let tags = '[]';
|
|
if (tagsInput) {
|
|
const arr = tagsInput.split(',').map(t => t.trim()).filter(t => t);
|
|
tags = JSON.stringify(arr);
|
|
}
|
|
|
|
const payload = {
|
|
title,
|
|
content,
|
|
category,
|
|
tags,
|
|
is_public: isPublic,
|
|
is_favorite: document.getElementById('favBtn').dataset.fav === '1',
|
|
parent_id: currentItem && currentItem.parent_id ? currentItem.parent_id : 0
|
|
};
|
|
|
|
// 只有填写了密码才传递
|
|
if (password) {
|
|
payload.password = password;
|
|
} else {
|
|
// 如果没填密码但原有笔记有密码,不清除
|
|
// 如果没填密码且原有笔记也没有密码,不传
|
|
payload.remove_password = !currentItem?.has_password;
|
|
}
|
|
|
|
try {
|
|
let res, data;
|
|
if (currentItem && currentItem.id && !currentItem.is_folder) {
|
|
// 更新
|
|
res = await fetch(`${ADMIN_API}/notes/${currentItem.id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
} else {
|
|
// 新建
|
|
res = await fetch(`${ADMIN_API}/notes`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
}
|
|
data = await res.json();
|
|
|
|
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);
|
|
}
|
|
} else {
|
|
showToast(data.message || '保存失败', 'error');
|
|
}
|
|
} catch (err) {
|
|
showToast('保存失败', 'error');
|
|
}
|
|
}
|
|
|
|
// 导出笔记为 Markdown 文件
|
|
function exportNote() {
|
|
if (!currentItem || !currentItem.id || currentItem.is_folder) {
|
|
showToast('请先选择一个笔记(不是目录)', 'error');
|
|
return;
|
|
}
|
|
window.location.href = `${ADMIN_API}/export/${currentItem.id}`;
|
|
}
|
|
|
|
// 导入 Markdown 文件
|
|
async function importNote(file) {
|
|
if (!file) return;
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
try {
|
|
const res = await fetch(`${ADMIN_API}/import`, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
body: formData
|
|
});
|
|
const data = await res.json();
|
|
|
|
if (data.code === 0) {
|
|
showToast('导入成功', 'success');
|
|
await loadTree();
|
|
if (data.data && data.data.id) {
|
|
selectItem(data.data.id);
|
|
}
|
|
} else {
|
|
showToast(data.message || '导入失败', 'error');
|
|
}
|
|
} catch (err) {
|
|
showToast('导入失败', 'error');
|
|
}
|
|
|
|
// 清空文件输入
|
|
document.getElementById('importFile').value = '';
|
|
}
|
|
|
|
// 删除确认
|
|
function confirmDelete() {
|
|
if (!currentItem || !currentItem.id) return;
|
|
const message = currentItem.is_folder
|
|
? '确定要删除这个目录及其下所有内容吗?此操作不可撤销。'
|
|
: '确定要删除这篇笔记吗?此操作不可撤销。';
|
|
document.getElementById('deleteMessage').textContent = message;
|
|
document.getElementById('deleteModal').classList.add('active');
|
|
}
|
|
|
|
function closeDeleteModal() {
|
|
document.getElementById('deleteModal').classList.remove('active');
|
|
}
|
|
|
|
async function doDelete() {
|
|
if (!currentItem || !currentItem.id) return;
|
|
|
|
try {
|
|
const res = await fetch(`${ADMIN_API}/notes/${currentItem.id}`, { method: 'DELETE' });
|
|
const data = await res.json();
|
|
|
|
if (data.code === 0) {
|
|
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();
|
|
} else {
|
|
showToast(data.message || '删除失败', 'error');
|
|
}
|
|
} catch (err) {
|
|
showToast('删除失败', 'error');
|
|
}
|
|
}
|
|
|
|
// 更新预览
|
|
function updatePreview() {
|
|
const content = document.getElementById('noteContent').value;
|
|
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(); // 启动自动保存定时器
|
|
});
|
|
|
|
// 退出登录
|
|
async function logout() {
|
|
await fetch('/admin/logout', { method: 'POST' });
|
|
window.location.href = '/admin/login';
|
|
}
|
|
|
|
// 工具函数
|
|
function parseTags(tagsJson) {
|
|
if (!tagsJson) return [];
|
|
try { return JSON.parse(tagsJson); } catch { return []; }
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
if (!text) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function showToast(message, type = 'info') {
|
|
const toast = document.getElementById('toast');
|
|
toast.textContent = message;
|
|
toast.className = `toast show ${type}`;
|
|
setTimeout(() => toast.classList.remove('show'), 3000);
|
|
}
|
|
|
|
// 简单的 Markdown 渲染(支持代码高亮)
|
|
function renderMarkdown(text) {
|
|
if (!text) return '<p style="color: #999;">无内容</p>';
|
|
|
|
// 第一步:所有 Markdown 语法先转为占位符,不输出 HTML
|
|
const placeholders = [];
|
|
let placeholderIndex = 0;
|
|
|
|
// 代码块占位符
|
|
text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, function(match, lang, code) {
|
|
const ph = '__PH_' + placeholderIndex++ + '__';
|
|
placeholders.push({ ph: ph, type: 'code', lang: lang || 'plaintext', code: code.trim() });
|
|
return ph;
|
|
});
|
|
|
|
// 图片占位符
|
|
text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function(match, alt, src) {
|
|
const ph = '__PH_' + placeholderIndex++ + '__';
|
|
placeholders.push({ ph: ph, type: 'img', src: src, alt: alt });
|
|
return ph;
|
|
});
|
|
|
|
// 链接占位符
|
|
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(match, text, href) {
|
|
const ph = '__PH_' + placeholderIndex++ + '__';
|
|
placeholders.push({ ph: ph, type: 'link', text: text, href: href });
|
|
return ph;
|
|
});
|
|
|
|
// 行内代码占位符
|
|
text = text.replace(/`([^`]+)`/g, function(match, code) {
|
|
const ph = '__PH_' + placeholderIndex++ + '__';
|
|
placeholders.push({ ph: ph, type: 'inlinecode', code: code });
|
|
return ph;
|
|
});
|
|
|
|
// 标题占位符
|
|
text = text.replace(/^### (.+)$/gm, function(match, content) {
|
|
const ph = '__PH_' + placeholderIndex++ + '__';
|
|
const id = content.toLowerCase().replace(/[^\w\u4e00-\u9fa5]+/g, '-');
|
|
placeholders.push({ ph: ph, type: 'h3', content: content, id: id });
|
|
return ph;
|
|
});
|
|
text = text.replace(/^## (.+)$/gm, function(match, content) {
|
|
const ph = '__PH_' + placeholderIndex++ + '__';
|
|
const id = content.toLowerCase().replace(/[^\w\u4e00-\u9fa5]+/g, '-');
|
|
placeholders.push({ ph: ph, type: 'h2', content: content, id: id });
|
|
return ph;
|
|
});
|
|
text = text.replace(/^# (.+)$/gm, function(match, content) {
|
|
const ph = '__PH_' + placeholderIndex++ + '__';
|
|
const id = content.toLowerCase().replace(/[^\w\u4e00-\u9fa5]+/g, '-');
|
|
placeholders.push({ ph: ph, type: 'h1', content: content, id: id });
|
|
return ph;
|
|
});
|
|
|
|
// 转义剩余的 HTML 特殊字符
|
|
text = escapeHtml(text);
|
|
|
|
// 恢复所有占位符为 HTML
|
|
placeholders.forEach(function(item) {
|
|
let html = '';
|
|
switch(item.type) {
|
|
case 'code':
|
|
var highlighted = hljs.highlightAuto(item.code, item.lang !== 'plaintext' ? [item.lang] : undefined).value;
|
|
html = '<pre><code class="hljs language-' + item.lang + '">' + highlighted + '</code></pre>';
|
|
break;
|
|
case 'img':
|
|
html = '<img src="' + item.src + '" alt="' + item.alt + '" style="max-width: 100%; border-radius: 4px;">';
|
|
break;
|
|
case 'link':
|
|
html = '<a href="' + item.href + '" target="_blank">' + item.text + '</a>';
|
|
break;
|
|
case 'inlinecode':
|
|
html = '<code>' + item.code + '</code>';
|
|
break;
|
|
case 'h3':
|
|
html = '<h3 id="' + item.id + '">' + item.content + '</h3>';
|
|
break;
|
|
case 'h2':
|
|
html = '<h2 id="' + item.id + '">' + item.content + '</h2>';
|
|
break;
|
|
case 'h1':
|
|
html = '<h1 id="' + item.id + '">' + item.content + '</h1>';
|
|
break;
|
|
}
|
|
text = text.replace(item.ph, html);
|
|
});
|
|
|
|
// 其他格式
|
|
text = text.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
|
|
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
|
text = text.replace(/~~(.+?)~~/g, '<del>$1</del>');
|
|
text = text.replace(/^> (.*$)/gm, '<blockquote>$1</blockquote>');
|
|
|
|
// 换行
|
|
text = text.replace(/\n\n/g, '</p><p>');
|
|
text = text.replace(/\n/g, '<br>');
|
|
|
|
return '<p>' + text + '</p>';
|
|
}
|
|
|
|
// 分栏拖动调整
|
|
function setupResize() {
|
|
const handle = document.getElementById('resizeHandle');
|
|
const editorPane = document.getElementById('editorPane');
|
|
const previewPane = document.getElementById('previewPane');
|
|
|
|
let isResizing = false;
|
|
|
|
handle?.addEventListener('mousedown', (e) => {
|
|
isResizing = true;
|
|
document.body.style.cursor = 'col-resize';
|
|
document.body.style.userSelect = 'none';
|
|
});
|
|
|
|
document.addEventListener('mousemove', (e) => {
|
|
if (!isResizing) return;
|
|
const container = document.getElementById('contentArea');
|
|
const containerRect = container.getBoundingClientRect();
|
|
const percentage = ((e.clientX - containerRect.left) / containerRect.width) * 100;
|
|
|
|
if (percentage > 20 && percentage < 80) {
|
|
editorPane.style.flex = `0 0 ${percentage}%`;
|
|
previewPane.style.flex = `0 0 ${100 - percentage}%`;
|
|
}
|
|
});
|
|
|
|
document.addEventListener('mouseup', () => {
|
|
isResizing = false;
|
|
document.body.style.cursor = '';
|
|
document.body.style.userSelect = '';
|
|
});
|
|
}
|
|
|
|
// ═══════════ 增强功能:深色模式 / 批量导出 / 回收站 / 版本历史 / 分享 ═══════════
|
|
|
|
// ── 深色模式 ──
|
|
function toggleDarkMode() {
|
|
const isDark = document.body.classList.toggle('dark');
|
|
localStorage.setItem('nm_dark', isDark ? '1' : '0');
|
|
document.getElementById('darkModeBtn').textContent = isDark ? '☀️ 白天' : '🌙 深色';
|
|
}
|
|
function initDarkMode() {
|
|
const dark = localStorage.getItem('nm_dark') === '1';
|
|
if (dark) {
|
|
document.body.classList.add('dark');
|
|
document.getElementById('darkModeBtn').textContent = '☀️ 白天';
|
|
}
|
|
}
|
|
|
|
// ── 批量导出全部笔记为 zip ──
|
|
async function exportAll() {
|
|
showToast('正在导出全部笔记...', 'info');
|
|
try {
|
|
const res = await fetch(`${ADMIN_API}/export-all`);
|
|
if (!res.ok) { showToast('导出失败', 'error'); return; }
|
|
const blob = await res.blob();
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = 'notes-export.zip';
|
|
a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
showToast('导出成功', 'success');
|
|
} catch (e) {
|
|
showToast('导出失败', 'error');
|
|
}
|
|
}
|
|
|
|
// ── 回收站 ──
|
|
async function openTrash() {
|
|
const modal = document.getElementById('trashModal');
|
|
modal.style.display = 'flex';
|
|
document.getElementById('trashList').innerHTML = '<p style="color:#999;padding:20px;text-align:center;">加载中...</p>';
|
|
const res = await fetch(`${ADMIN_API}/trash`);
|
|
const data = await res.json();
|
|
if (data.code === 0) {
|
|
renderTrash(data.data);
|
|
} else {
|
|
document.getElementById('trashList').innerHTML = `<p style="color:#d32f2f;padding:20px;text-align:center;">${escapeHtml(data.message || '加载失败')}</p>`;
|
|
}
|
|
}
|
|
function closeTrash() {
|
|
document.getElementById('trashModal').style.display = 'none';
|
|
}
|
|
function renderTrash(items) {
|
|
const box = document.getElementById('trashList');
|
|
if (!items || !items.length) {
|
|
box.innerHTML = '<p style="color:#999;padding:30px;text-align:center;">回收站是空的</p>';
|
|
return;
|
|
}
|
|
const rows = items.map(item => {
|
|
const icon = item.is_folder ? '📁' : '📄';
|
|
return `<div style="display:flex;align-items:center;gap:8px;padding:10px;border-bottom:1px solid #eee;">
|
|
<span>${icon}</span>
|
|
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(item.title || '(无标题)')}</span>
|
|
<span style="font-size:12px;color:#999;">${item.is_folder ? '目录' : ''}</span>
|
|
<button class="btn btn-secondary" style="font-size:12px;padding:4px 10px;" onclick="restoreTrashItem(${item.id})">恢复</button>
|
|
<button class="btn btn-danger" style="font-size:12px;padding:4px 10px;" onclick="purgeTrashItem(${item.id})">彻底删除</button>
|
|
</div>`;
|
|
}).join('');
|
|
box.innerHTML = rows;
|
|
}
|
|
async function restoreTrashItem(id) {
|
|
const res = await fetch(`${ADMIN_API}/restore/${id}`, { method: 'POST' });
|
|
const data = await res.json();
|
|
if (data.code === 0) { showToast('已恢复', 'success'); openTrash(); loadTree(); }
|
|
else showToast(data.message || '恢复失败', 'error');
|
|
}
|
|
async function purgeTrashItem(id) {
|
|
if (!confirm('确定彻底删除这条记录吗?此操作不可恢复!')) return;
|
|
const res = await fetch(`${ADMIN_API}/purge/${id}`, { method: 'POST' });
|
|
const data = await res.json();
|
|
if (data.code === 0) { showToast('已彻底删除', 'success'); openTrash(); }
|
|
else showToast(data.message || '删除失败', 'error');
|
|
}
|
|
async function emptyTrash() {
|
|
if (!confirm('确定清空回收站吗?所有记录将被彻底删除,不可恢复!')) return;
|
|
const res = await fetch(`${ADMIN_API}/empty-trash`, { method: 'POST' });
|
|
const data = await res.json();
|
|
if (data.code === 0) { showToast('回收站已清空', 'success'); openTrash(); }
|
|
else showToast(data.message || '清空失败', 'error');
|
|
}
|
|
|
|
// ── 版本历史 ──
|
|
async function openVersions() {
|
|
if (!currentItem || !currentItem.id) { showToast('请先选择一篇笔记', 'error'); return; }
|
|
if (currentItem.is_folder) { showToast('目录没有版本历史', 'error'); return; }
|
|
const modal = document.getElementById('versionsModal');
|
|
modal.style.display = 'flex';
|
|
document.getElementById('versionsList').innerHTML = '<p style="color:#999;padding:20px;text-align:center;">加载中...</p>';
|
|
const res = await fetch(`${ADMIN_API}/notes/${currentItem.id}/versions`);
|
|
const data = await res.json();
|
|
if (data.code === 0) renderVersions(data.data);
|
|
else document.getElementById('versionsList').innerHTML = `<p style="color:#d32f2f;padding:20px;text-align:center;">${escapeHtml(data.message || '加载失败')}</p>`;
|
|
}
|
|
function closeVersions() {
|
|
document.getElementById('versionsModal').style.display = 'none';
|
|
}
|
|
function renderVersions(versions) {
|
|
const box = document.getElementById('versionsList');
|
|
if (!versions || !versions.length) {
|
|
box.innerHTML = '<p style="color:#999;padding:30px;text-align:center;">暂无历史版本</p>';
|
|
return;
|
|
}
|
|
const rows = versions.map((v, idx) => {
|
|
const d = new Date(v.created_at);
|
|
const preview = (v.content || '').replace(/[#*`>\-\[\]]/g, '').slice(0, 80);
|
|
return `<div style="padding:10px;border-bottom:1px solid #eee;">
|
|
<div style="display:flex;align-items:center;gap:8px;">
|
|
<strong>${idx === 0 ? '当前版本' : '版本 ' + (versions.length - idx)}</strong>
|
|
<span style="font-size:12px;color:#999;">${d.toLocaleString('zh-CN')}</span>
|
|
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;color:#777;">${escapeHtml(preview)}</span>
|
|
${idx !== 0 ? `<button class="btn btn-secondary" style="font-size:12px;padding:4px 10px;" onclick="restoreVersion(${v.id})">恢复此版本</button>` : ''}
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
box.innerHTML = rows;
|
|
}
|
|
async function restoreVersion(versionId) {
|
|
if (!confirm('确定恢复到该版本吗?当前内容会先保存为新版本。')) return;
|
|
const fd = new FormData();
|
|
fd.append('version_id', versionId);
|
|
const res = await fetch(`${ADMIN_API}/notes/${currentItem.id}/restore-version`, { method: 'POST', body: fd });
|
|
const data = await res.json();
|
|
if (data.code === 0) {
|
|
showToast('已恢复', 'success');
|
|
closeVersions();
|
|
await selectItem(currentItem.id);
|
|
loadTree();
|
|
} else showToast(data.message || '恢复失败', 'error');
|
|
}
|
|
|
|
// ── 分享 ──
|
|
function openShare() {
|
|
if (!currentItem || !currentItem.id) { showToast('请先选择一篇笔记', 'error'); return; }
|
|
if (currentItem.is_folder) { showToast('目录不能分享', 'error'); return; }
|
|
const modal = document.getElementById('shareModal');
|
|
modal.style.display = 'flex';
|
|
if (currentItem.share_token) {
|
|
document.getElementById('shareNoToken').style.display = 'none';
|
|
document.getElementById('shareHasToken').style.display = 'block';
|
|
document.getElementById('shareUrl').value = location.origin + '/share/' + currentItem.share_token;
|
|
} else {
|
|
document.getElementById('shareNoToken').style.display = 'block';
|
|
document.getElementById('shareHasToken').style.display = 'none';
|
|
}
|
|
}
|
|
function closeShare() {
|
|
document.getElementById('shareModal').style.display = 'none';
|
|
}
|
|
async function createShare() {
|
|
const expire = document.getElementById('shareExpire').value;
|
|
const fd = new FormData();
|
|
fd.append('expire_hours', expire);
|
|
const res = await fetch(`${ADMIN_API}/notes/${currentItem.id}/share`, { method: 'POST', body: fd });
|
|
const data = await res.json();
|
|
if (data.code === 0) {
|
|
showToast('分享链接已生成', 'success');
|
|
currentItem.share_token = data.data.share_token;
|
|
openShare();
|
|
} else showToast(data.message || '生成失败', 'error');
|
|
}
|
|
async function revokeShare() {
|
|
const res = await fetch(`${ADMIN_API}/notes/${currentItem.id}/revoke-share`, { method: 'POST' });
|
|
const data = await res.json();
|
|
if (data.code === 0) {
|
|
showToast('已撤销分享', 'success');
|
|
currentItem.share_token = '';
|
|
openShare();
|
|
} else showToast(data.message || '操作失败', 'error');
|
|
}
|
|
function copyShareUrl() {
|
|
const url = document.getElementById('shareUrl').value;
|
|
if (navigator.clipboard) {
|
|
navigator.clipboard.writeText(url).then(() => showToast('链接已复制', 'success')).catch(() => showToast('复制失败', 'error'));
|
|
} else {
|
|
document.getElementById('shareUrl').select();
|
|
document.execCommand('copy');
|
|
showToast('链接已复制', 'success');
|
|
}
|
|
}
|
|
|
|
// ═══════════ 新增功能:搜索 / 自动保存草稿 / 反向链接 / 知识图谱 / 标签管理 ═══════════
|
|
|
|
// ── 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>
|
|
</body>
|
|
</html>
|