MD编辑器更新
UI更新
This commit is contained in:
@@ -3,188 +3,17 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>笔记管理</title>
|
||||
<style>
|
||||
:root {
|
||||
--brand: #C8956E;
|
||||
--brand-light: #FFF6EC;
|
||||
--text: #2B2B2B;
|
||||
--text-dim: #8A8078;
|
||||
--border: #EAE3DC;
|
||||
--bg: #FAF8F5;
|
||||
--danger: #D9534F;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
font-size: 14px; line-height: 1.6;
|
||||
}
|
||||
button {
|
||||
font-family: inherit; font-size: 14px; cursor: pointer;
|
||||
border: 1px solid var(--border); background: #fff; color: var(--text);
|
||||
padding: 7px 14px; border-radius: 8px; transition: .15s;
|
||||
}
|
||||
button:hover { border-color: var(--brand); color: var(--brand); }
|
||||
button.primary { background: var(--brand); border-color: var(--brand); color: #fff; }
|
||||
button.primary:hover { opacity: .88; color: #fff; }
|
||||
button.danger:hover { border-color: var(--danger); color: var(--danger); }
|
||||
input, textarea {
|
||||
font-family: inherit; font-size: 14px; color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 8px 12px; background: #fff; outline: none; width: 100%;
|
||||
}
|
||||
input:focus, textarea:focus { border-color: var(--brand); }
|
||||
|
||||
/* ---------- 登录 ---------- */
|
||||
#login-view {
|
||||
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.login-box {
|
||||
width: 340px; background: #fff; padding: 36px 32px;
|
||||
border-radius: 16px; border: 1px solid var(--border);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,.05);
|
||||
}
|
||||
.login-box h1 { margin: 0 0 4px; font-size: 20px; }
|
||||
.login-box p.sub { margin: 0 0 24px; color: var(--text-dim); font-size: 13px; }
|
||||
.login-box .field { margin-bottom: 14px; }
|
||||
.login-box .field label { display: block; margin-bottom: 6px; font-size: 13px; color: var(--text-dim); }
|
||||
.login-box button { width: 100%; margin-top: 8px; padding: 10px; }
|
||||
|
||||
/* ---------- 主界面 ---------- */
|
||||
header {
|
||||
background: #fff; border-bottom: 1px solid var(--border);
|
||||
padding: 14px 24px; display: flex; align-items: center; gap: 16px;
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
}
|
||||
header h1 { margin: 0; font-size: 17px; font-weight: 600; white-space: nowrap; }
|
||||
header .search { flex: 1; max-width: 380px; }
|
||||
header .spacer { flex: 1; }
|
||||
header .who { color: var(--text-dim); font-size: 13px; white-space: nowrap; }
|
||||
|
||||
.layout { display: flex; gap: 24px; padding: 24px; align-items: flex-start; max-width: 1200px; margin: 0 auto; }
|
||||
aside.tags {
|
||||
width: 180px; flex-shrink: 0; background: #fff; border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 16px; position: sticky; top: 84px;
|
||||
}
|
||||
aside.tags h3 { margin: 0 0 12px; font-size: 13px; color: var(--text-dim); font-weight: 500; }
|
||||
.tag-chip {
|
||||
display: block; width: 100%; text-align: left; border: none; background: transparent;
|
||||
padding: 6px 10px; border-radius: 6px; margin-bottom: 2px; color: var(--text);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.tag-chip:hover { background: var(--brand-light); color: var(--brand); }
|
||||
.tag-chip.active { background: var(--brand); color: #fff; }
|
||||
.tag-chip.active:hover { background: var(--brand); color: #fff; }
|
||||
|
||||
main { flex: 1; min-width: 0; }
|
||||
|
||||
#dropzone {
|
||||
border: 2px dashed var(--border); border-radius: 12px; padding: 20px;
|
||||
text-align: center; color: var(--text-dim); margin-bottom: 20px;
|
||||
background: #fff; transition: .15s;
|
||||
}
|
||||
#dropzone.over { border-color: var(--brand); background: var(--brand-light); color: var(--brand); }
|
||||
|
||||
.note-card {
|
||||
background: #fff; border: 1px solid var(--border); border-radius: 12px;
|
||||
padding: 16px 18px; margin-bottom: 12px; transition: .15s;
|
||||
}
|
||||
.note-card:hover { border-color: var(--brand); box-shadow: 0 4px 16px rgba(0,0,0,.04); }
|
||||
.note-card .head { display: flex; align-items: baseline; gap: 8px; }
|
||||
.note-card h3 {
|
||||
margin: 0; font-size: 15px; font-weight: 600; flex: 1;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.note-card .pin { color: var(--brand); font-size: 13px; }
|
||||
.note-card .summary {
|
||||
color: var(--text-dim); font-size: 13px; margin: 6px 0 10px;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.note-card .meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.note-card .tag {
|
||||
background: var(--brand-light); color: var(--brand);
|
||||
padding: 2px 8px; border-radius: 4px; font-size: 12px;
|
||||
}
|
||||
.note-card .time { color: var(--text-dim); font-size: 12px; margin-left: auto; }
|
||||
.note-card .actions { display: flex; gap: 6px; margin-left: 12px; }
|
||||
.note-card .actions button { padding: 3px 10px; font-size: 12px; }
|
||||
|
||||
.empty { text-align: center; color: var(--text-dim); padding: 60px 0; }
|
||||
#loadmore { width: 100%; margin-top: 4px; padding: 10px; }
|
||||
|
||||
/* ---------- 编辑器 ---------- */
|
||||
#editor {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,.4);
|
||||
display: none; align-items: center; justify-content: center; z-index: 100; padding: 24px;
|
||||
}
|
||||
#editor.open { display: flex; }
|
||||
.editor-box {
|
||||
background: #fff; border-radius: 16px; width: 100%; max-width: 1000px;
|
||||
height: 100%; max-height: 760px; display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
.editor-head {
|
||||
padding: 14px 20px; border-bottom: 1px solid var(--border);
|
||||
display: flex; gap: 12px; align-items: center;
|
||||
}
|
||||
.editor-head input.title { flex: 1; font-size: 15px; font-weight: 600; }
|
||||
.editor-head input.tags { width: 220px; }
|
||||
.editor-body { flex: 1; display: flex; min-height: 0; }
|
||||
.editor-body textarea {
|
||||
flex: 1; border: none; border-radius: 0; resize: none;
|
||||
padding: 18px 20px; font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 13px; line-height: 1.7;
|
||||
}
|
||||
.editor-body textarea:focus { border: none; }
|
||||
.editor-body .preview {
|
||||
flex: 1; overflow-y: auto; padding: 18px 20px; border-left: 1px solid var(--border); background: #FCFBF9;
|
||||
}
|
||||
.editor-foot {
|
||||
padding: 12px 20px; border-top: 1px solid var(--border);
|
||||
display: flex; gap: 10px; align-items: center;
|
||||
}
|
||||
.editor-foot .hint { color: var(--text-dim); font-size: 12px; margin-right: auto; }
|
||||
|
||||
/* Markdown 预览样式 */
|
||||
.preview h1, .preview h2, .preview h3 { margin: 18px 0 10px; line-height: 1.3; }
|
||||
.preview h1 { font-size: 22px; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
|
||||
.preview h2 { font-size: 18px; }
|
||||
.preview h3 { font-size: 15px; }
|
||||
.preview p { margin: 10px 0; }
|
||||
.preview code {
|
||||
background: #F2EEE9; padding: 2px 5px; border-radius: 4px;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px;
|
||||
}
|
||||
.preview pre {
|
||||
background: #2B2B2B; color: #F0EDEA; padding: 14px; border-radius: 8px; overflow-x: auto;
|
||||
}
|
||||
.preview pre code { background: none; color: inherit; padding: 0; }
|
||||
.preview blockquote {
|
||||
border-left: 3px solid var(--brand); margin: 10px 0; padding: 2px 14px; color: var(--text-dim);
|
||||
}
|
||||
.preview table { border-collapse: collapse; width: 100%; margin: 10px 0; font-size: 13px; }
|
||||
.preview th, .preview td { border: 1px solid var(--border); padding: 6px 10px; text-align: left; }
|
||||
.preview th { background: var(--brand-light); }
|
||||
.preview img { max-width: 100%; border-radius: 6px; }
|
||||
.preview a { color: var(--brand); }
|
||||
|
||||
#toast {
|
||||
position: fixed; bottom: 28px; left: 50%; transform: translateX(-50%);
|
||||
background: #2B2B2B; color: #fff; padding: 10px 20px; border-radius: 8px;
|
||||
font-size: 13px; opacity: 0; pointer-events: none; transition: .25s; z-index: 999;
|
||||
}
|
||||
#toast.show { opacity: 1; }
|
||||
#toast.err { background: var(--danger); }
|
||||
</style>
|
||||
<title>强宝小助手</title>
|
||||
<link rel="stylesheet" href="/admin/vendor/vditor/dist/index.css">
|
||||
<link rel="stylesheet" href="/admin/admin.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ==================== 登录 ==================== -->
|
||||
<div id="login-view">
|
||||
<div class="login-box">
|
||||
<h1>笔记管理</h1>
|
||||
<p class="sub">使用手机端同一个账号登录</p>
|
||||
<h1>强宝小助手</h1>
|
||||
<p class="sub">强宝管理小助手</p>
|
||||
<div class="field">
|
||||
<label>用户名</label>
|
||||
<input id="login-username" autocomplete="username" placeholder="用户名">
|
||||
@@ -197,31 +26,73 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 主界面 ==================== -->
|
||||
<!-- ==================== 主界面 ====================
|
||||
三层结构:
|
||||
顶栏 —— 系统级,只放品牌和用户操作(跨模块的东西才配放这儿)
|
||||
模块菜单 —— 左侧,以后加 Agent 等模块直接往这里塞
|
||||
模块面板 —— 当前模块自己的工具栏、侧栏和内容
|
||||
-->
|
||||
<div id="app-view" style="display:none">
|
||||
<header>
|
||||
<h1>笔记管理</h1>
|
||||
<div class="search"><input id="search" placeholder="搜索标题和正文…"></div>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="brand"><span class="brand-icon">🐻</span>强宝小助手</div>
|
||||
<div class="spacer"></div>
|
||||
<span class="who" id="who"></span>
|
||||
<button id="new-btn" class="primary">+ 新建</button>
|
||||
<button id="logout-btn">退出</button>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<aside class="tags">
|
||||
<h3>标签</h3>
|
||||
<div id="tag-list"></div>
|
||||
</aside>
|
||||
<div class="console">
|
||||
|
||||
<main>
|
||||
<div id="dropzone">
|
||||
把 <b>.md</b> 文件拖到这里批量导入,或 <a href="#" id="pick-files">点击选择文件</a>
|
||||
<input type="file" id="file-input" accept=".md,.markdown,.txt" multiple style="display:none">
|
||||
<nav class="module-nav">
|
||||
<button class="module-item active" data-module="notes">
|
||||
<span class="mi-icon">📝</span>
|
||||
<span class="mi-text">笔记管理</span>
|
||||
</button>
|
||||
<!-- 以后的模块(Agent、数据看板…)往这里加 -->
|
||||
</nav>
|
||||
|
||||
<section class="module-panel">
|
||||
|
||||
<div class="toolbar">
|
||||
<input id="search" placeholder="搜索标题和正文…">
|
||||
<select id="scope" title="搜索范围">
|
||||
<option value="current" selected>当前目录</option>
|
||||
<option value="all">全部笔记</option>
|
||||
</select>
|
||||
<select id="sort" title="排序方式">
|
||||
<option value="updated">最近更新</option>
|
||||
<option value="created">创建时间</option>
|
||||
<option value="title">标题</option>
|
||||
</select>
|
||||
<div class="spacer"></div>
|
||||
<button id="new-folder-btn">+ 新建目录</button>
|
||||
<button id="new-btn" class="primary">+ 新建笔记</button>
|
||||
</div>
|
||||
<div id="note-list"></div>
|
||||
<button id="loadmore" style="display:none">加载更多</button>
|
||||
</main>
|
||||
|
||||
<div class="module-body">
|
||||
|
||||
<aside class="sidebar">
|
||||
<div id="folder-tree"></div>
|
||||
<div class="side-divider"></div>
|
||||
<div class="side-title">标签</div>
|
||||
<div id="tag-list"></div>
|
||||
</aside>
|
||||
|
||||
<main class="content">
|
||||
<div class="breadcrumb" id="breadcrumb"></div>
|
||||
|
||||
<div id="dropzone">
|
||||
把 <b>.md</b> 文件拖到这里批量导入<span id="dropzone-hint"></span>,或 <a href="#" id="pick-files">点击选择文件</a>
|
||||
<input type="file" id="file-input" accept=".md,.markdown,.txt" multiple style="display:none">
|
||||
</div>
|
||||
|
||||
<div id="note-list"></div>
|
||||
<button id="loadmore" style="display:none">加载更多</button>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -233,418 +104,33 @@
|
||||
<input class="tags" id="ed-tags" placeholder="标签,逗号分隔">
|
||||
</div>
|
||||
<div class="editor-body">
|
||||
<textarea id="ed-content" placeholder="在这里写 Markdown…"></textarea>
|
||||
<div class="preview" id="ed-preview"></div>
|
||||
<div id="vditor"></div>
|
||||
</div>
|
||||
<div class="editor-foot">
|
||||
<span class="hint" id="ed-hint"></span>
|
||||
<button id="ed-upload">插入图片</button>
|
||||
<input type="file" id="ed-image" accept="image/*" style="display:none">
|
||||
<button id="ed-cancel">取消</button>
|
||||
<button class="primary" id="ed-save">保存</button>
|
||||
<button class="primary" id="ed-save">保存 (Ctrl+S)</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 目录名输入 / 目录选择弹窗 ==================== -->
|
||||
<div id="dialog">
|
||||
<div class="dialog-box">
|
||||
<h3 id="dialog-title">新建目录</h3>
|
||||
<input id="dialog-input" placeholder="目录名">
|
||||
<select id="dialog-select" style="display:none"></select>
|
||||
<div class="dialog-foot">
|
||||
<button id="dialog-cancel">取消</button>
|
||||
<button class="primary" id="dialog-ok">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast"></div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked@12/marked.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var API = '/api';
|
||||
var TOKEN_KEY = 'aq_note_admin_token';
|
||||
var USER_KEY = 'aq_note_admin_user';
|
||||
|
||||
var state = {
|
||||
token: localStorage.getItem(TOKEN_KEY) || '',
|
||||
username: localStorage.getItem(USER_KEY) || '',
|
||||
page: 1,
|
||||
size: 20,
|
||||
keyword: '',
|
||||
tag: '',
|
||||
total: 0,
|
||||
editingId: null
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- 工具
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function toast(msg, isError) {
|
||||
var el = $('toast');
|
||||
el.textContent = msg;
|
||||
el.className = (isError ? 'err ' : '') + 'show';
|
||||
clearTimeout(el._t);
|
||||
el._t = setTimeout(function () { el.className = ''; }, 2400);
|
||||
}
|
||||
|
||||
function formatTime(s) {
|
||||
if (!s) return '';
|
||||
return String(s).replace('T', ' ').substring(0, 16);
|
||||
}
|
||||
|
||||
/** 统一的请求封装,带 token 和 401 处理 */
|
||||
function request(path, options) {
|
||||
options = options || {};
|
||||
var headers = options.headers || {};
|
||||
if (state.token) headers['Authorization'] = 'Bearer ' + state.token;
|
||||
if (options.body && !(options.body instanceof FormData)) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
return fetch(API + path, {
|
||||
method: options.method || 'GET',
|
||||
headers: headers,
|
||||
body: options.body
|
||||
}).then(function (res) {
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
doLogout();
|
||||
throw new Error('登录已过期,请重新登录');
|
||||
}
|
||||
var ct = res.headers.get('content-type') || '';
|
||||
var parsed = ct.indexOf('application/json') >= 0 ? res.json() : res.text();
|
||||
return parsed.then(function (data) {
|
||||
if (!res.ok) {
|
||||
var msg = (data && (data.message || data.error)) || ('请求失败 ' + res.status);
|
||||
throw new Error(msg);
|
||||
}
|
||||
return data;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 登录
|
||||
function showApp() {
|
||||
$('login-view').style.display = 'none';
|
||||
$('app-view').style.display = '';
|
||||
$('who').textContent = state.username ? ('当前:' + state.username) : '';
|
||||
state.page = 1;
|
||||
state.keyword = '';
|
||||
state.tag = '';
|
||||
$('search').value = '';
|
||||
loadTags();
|
||||
loadNotes(true);
|
||||
}
|
||||
|
||||
function doLogout() {
|
||||
state.token = '';
|
||||
state.username = '';
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
$('app-view').style.display = 'none';
|
||||
$('login-view').style.display = 'flex';
|
||||
$('note-list').innerHTML = '';
|
||||
$('tag-list').innerHTML = '';
|
||||
}
|
||||
|
||||
function doLogin() {
|
||||
var username = $('login-username').value.trim();
|
||||
var password = $('login-password').value;
|
||||
if (!username || !password) { toast('请填写用户名和密码', true); return; }
|
||||
|
||||
fetch(API + '/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: username, password: password })
|
||||
}).then(function (res) {
|
||||
return res.json().then(function (data) {
|
||||
if (!res.ok || !data.token) {
|
||||
throw new Error(data.message || data.error || '用户名或密码错误');
|
||||
}
|
||||
return data;
|
||||
});
|
||||
}).then(function (data) {
|
||||
state.token = data.token;
|
||||
state.username = data.username || username;
|
||||
localStorage.setItem(TOKEN_KEY, state.token);
|
||||
localStorage.setItem(USER_KEY, state.username);
|
||||
$('login-password').value = '';
|
||||
showApp();
|
||||
}).catch(function (e) {
|
||||
toast(e.message, true);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 列表
|
||||
function loadNotes(reset) {
|
||||
if (reset) { state.page = 1; }
|
||||
|
||||
var qs = '?page=' + state.page + '&size=' + state.size;
|
||||
if (state.keyword) qs += '&keyword=' + encodeURIComponent(state.keyword);
|
||||
if (state.tag) qs += '&tag=' + encodeURIComponent(state.tag);
|
||||
|
||||
request('/notes' + qs).then(function (data) {
|
||||
state.total = data.total;
|
||||
renderNotes(data.list, reset);
|
||||
$('loadmore').style.display = data.hasMore ? 'block' : 'none';
|
||||
if (data.list.length) state.page++;
|
||||
}).catch(function (e) { toast(e.message, true); });
|
||||
}
|
||||
|
||||
function renderNotes(list, reset) {
|
||||
var box = $('note-list');
|
||||
if (reset) {
|
||||
if (!list.length) {
|
||||
box.innerHTML = '<div class="empty">' +
|
||||
(state.keyword || state.tag ? '没有匹配的笔记' : '还没有笔记,拖个 .md 文件进来试试') +
|
||||
'</div>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = '';
|
||||
}
|
||||
list.forEach(function (n) { box.appendChild(noteCard(n)); });
|
||||
}
|
||||
|
||||
function noteCard(n) {
|
||||
var el = document.createElement('div');
|
||||
el.className = 'note-card';
|
||||
|
||||
var tags = (n.tagList || []).map(function (t) {
|
||||
return '<span class="tag">' + esc(t) + '</span>';
|
||||
}).join('');
|
||||
|
||||
el.innerHTML =
|
||||
'<div class="head">' +
|
||||
'<h3>' + esc(n.title) + '</h3>' +
|
||||
(n.isPinned ? '<span class="pin">📌</span>' : '') +
|
||||
'</div>' +
|
||||
'<div class="summary">' + esc(n.summary || '(空笔记)') + '</div>' +
|
||||
'<div class="meta">' + tags +
|
||||
'<span class="time">' + esc(formatTime(n.updateTime)) + '</span>' +
|
||||
'<span class="actions">' +
|
||||
'<button data-act="pin">' + (n.isPinned ? '取消置顶' : '置顶') + '</button>' +
|
||||
'<button data-act="edit">编辑</button>' +
|
||||
'<button class="danger" data-act="del">删除</button>' +
|
||||
'</span>' +
|
||||
'</div>';
|
||||
|
||||
el.querySelector('[data-act="edit"]').onclick = function () { openEditor(n.id); };
|
||||
el.querySelector('[data-act="del"]').onclick = function () { removeNote(n); };
|
||||
el.querySelector('[data-act="pin"]').onclick = function () { togglePin(n); };
|
||||
return el;
|
||||
}
|
||||
|
||||
function removeNote(n) {
|
||||
if (!confirm('确定删除「' + n.title + '」?')) return;
|
||||
request('/notes/' + n.id, { method: 'DELETE' })
|
||||
.then(function () { toast('已删除'); loadTags(); loadNotes(true); })
|
||||
.catch(function (e) { toast(e.message, true); });
|
||||
}
|
||||
|
||||
function togglePin(n) {
|
||||
// 后端 update 是部分更新语义:只发 isPinned,正文不会被覆盖
|
||||
request('/notes/' + n.id, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ isPinned: n.isPinned ? 0 : 1 })
|
||||
}).then(function () { loadNotes(true); })
|
||||
.catch(function (e) { toast(e.message, true); });
|
||||
}
|
||||
|
||||
function loadTags() {
|
||||
request('/notes/tags').then(function (tags) {
|
||||
var box = $('tag-list');
|
||||
box.innerHTML = '';
|
||||
var all = document.createElement('button');
|
||||
all.className = 'tag-chip' + (state.tag === '' ? ' active' : '');
|
||||
all.textContent = '全部';
|
||||
all.onclick = function () { state.tag = ''; loadTags(); loadNotes(true); };
|
||||
box.appendChild(all);
|
||||
|
||||
tags.forEach(function (t) {
|
||||
var b = document.createElement('button');
|
||||
b.className = 'tag-chip' + (state.tag === t ? ' active' : '');
|
||||
b.textContent = t;
|
||||
b.title = t;
|
||||
b.onclick = function () { state.tag = t; loadTags(); loadNotes(true); };
|
||||
box.appendChild(b);
|
||||
});
|
||||
}).catch(function () { /* 标签加载失败不影响主流程 */ });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 编辑器
|
||||
function renderPreview() {
|
||||
var md = $('ed-content').value;
|
||||
var box = $('ed-preview');
|
||||
if (typeof marked === 'undefined') {
|
||||
// CDN 不可用时的降级:原样显示,至少不白屏
|
||||
box.innerHTML = '<pre style="background:none;color:inherit;padding:0">' + esc(md) + '</pre>';
|
||||
return;
|
||||
}
|
||||
var html = marked.parse(md);
|
||||
box.innerHTML = (typeof DOMPurify !== 'undefined') ? DOMPurify.sanitize(html) : html;
|
||||
}
|
||||
|
||||
function openEditor(id) {
|
||||
state.editingId = id || null;
|
||||
$('ed-hint').textContent = id ? '编辑已有笔记' : '新建笔记';
|
||||
|
||||
if (!id) {
|
||||
$('ed-title').value = '';
|
||||
$('ed-tags').value = '';
|
||||
$('ed-content').value = '';
|
||||
renderPreview();
|
||||
$('editor').classList.add('open');
|
||||
$('ed-title').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
request('/notes/' + id).then(function (n) {
|
||||
$('ed-title').value = n.title || '';
|
||||
$('ed-tags').value = (n.tagList || []).join(',');
|
||||
$('ed-content').value = n.content || '';
|
||||
renderPreview();
|
||||
$('editor').classList.add('open');
|
||||
}).catch(function (e) { toast(e.message, true); });
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
$('editor').classList.remove('open');
|
||||
state.editingId = null;
|
||||
}
|
||||
|
||||
function saveNote() {
|
||||
var payload = {
|
||||
title: $('ed-title').value,
|
||||
content: $('ed-content').value,
|
||||
tags: $('ed-tags').value,
|
||||
isPinned: 0
|
||||
};
|
||||
|
||||
var req = state.editingId
|
||||
? request('/notes/' + state.editingId, { method: 'PUT', body: JSON.stringify(payload) })
|
||||
: request('/notes', { method: 'POST', body: JSON.stringify(payload) });
|
||||
|
||||
req.then(function () {
|
||||
toast('已保存');
|
||||
closeEditor();
|
||||
loadTags();
|
||||
loadNotes(true);
|
||||
}).catch(function (e) { toast(e.message, true); });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 批量导入
|
||||
function importFiles(files) {
|
||||
var list = Array.prototype.slice.call(files).filter(function (f) {
|
||||
return /\.(md|markdown|txt)$/i.test(f.name);
|
||||
});
|
||||
if (!list.length) { toast('没有找到 .md 文件', true); return; }
|
||||
|
||||
Promise.all(list.map(function (f) {
|
||||
return new Promise(function (resolve) {
|
||||
var reader = new FileReader();
|
||||
reader.onload = function () {
|
||||
var text = String(reader.result || '');
|
||||
var firstLine = '';
|
||||
text.split(/\r?\n/).some(function (line) {
|
||||
var l = line.replace(/^\s*#{1,6}\s*/, '').trim();
|
||||
if (l) { firstLine = l; return true; }
|
||||
return false;
|
||||
});
|
||||
resolve({
|
||||
title: firstLine,
|
||||
content: text,
|
||||
sourcePath: f.name,
|
||||
tags: ''
|
||||
});
|
||||
};
|
||||
reader.readAsText(f, 'utf-8');
|
||||
});
|
||||
})).then(function (notes) {
|
||||
return request('/notes/import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ notes: notes })
|
||||
});
|
||||
}).then(function (res) {
|
||||
toast('成功导入 ' + (res.imported || list.length) + ' 篇笔记');
|
||||
loadTags();
|
||||
loadNotes(true);
|
||||
}).catch(function (e) {
|
||||
toast('导入失败:' + e.message, true);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 图片上传
|
||||
function uploadImage(file) {
|
||||
var fd = new FormData();
|
||||
fd.append('file', file);
|
||||
request('/notes/attachments', { method: 'POST', body: fd })
|
||||
.then(function (res) {
|
||||
var ta = $('ed-content');
|
||||
var snippet = '';
|
||||
var start = ta.selectionStart || 0;
|
||||
ta.value = ta.value.slice(0, start) + snippet + ta.value.slice(ta.selectionEnd || start);
|
||||
ta.selectionStart = ta.selectionEnd = start + snippet.length;
|
||||
ta.focus();
|
||||
renderPreview();
|
||||
toast('图片已插入');
|
||||
})
|
||||
.catch(function (e) { toast('上传失败:' + e.message, true); });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 事件绑定
|
||||
$('login-btn').onclick = doLogin;
|
||||
$('login-password').onkeydown = function (e) { if (e.key === 'Enter') doLogin(); };
|
||||
$('login-username').onkeydown = function (e) { if (e.key === 'Enter') $('login-password').focus(); };
|
||||
$('logout-btn').onclick = doLogout;
|
||||
$('new-btn').onclick = function () { openEditor(null); };
|
||||
|
||||
var searchTimer = null;
|
||||
$('search').oninput = function () {
|
||||
clearTimeout(searchTimer);
|
||||
var v = this.value.trim();
|
||||
searchTimer = setTimeout(function () { state.keyword = v; loadNotes(true); }, 350);
|
||||
};
|
||||
|
||||
$('loadmore').onclick = function () { loadNotes(false); };
|
||||
|
||||
$('pick-files').onclick = function (e) { e.preventDefault(); $('file-input').click(); };
|
||||
$('file-input').onchange = function () { importFiles(this.files); this.value = ''; };
|
||||
|
||||
var dz = $('dropzone');
|
||||
['dragenter', 'dragover'].forEach(function (ev) {
|
||||
dz.addEventListener(ev, function (e) { e.preventDefault(); dz.classList.add('over'); });
|
||||
});
|
||||
['dragleave', 'drop'].forEach(function (ev) {
|
||||
dz.addEventListener(ev, function (e) { e.preventDefault(); dz.classList.remove('over'); });
|
||||
});
|
||||
dz.addEventListener('drop', function (e) {
|
||||
if (e.dataTransfer && e.dataTransfer.files) importFiles(e.dataTransfer.files);
|
||||
});
|
||||
|
||||
$('ed-content').oninput = renderPreview;
|
||||
$('ed-cancel').onclick = closeEditor;
|
||||
$('ed-save').onclick = saveNote;
|
||||
$('ed-upload').onclick = function () { $('ed-image').click(); };
|
||||
$('ed-image').onchange = function () {
|
||||
if (this.files && this.files[0]) uploadImage(this.files[0]);
|
||||
this.value = '';
|
||||
};
|
||||
$('editor').onclick = function (e) { if (e.target === this) closeEditor(); };
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && $('editor').classList.contains('open')) closeEditor();
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's' && $('editor').classList.contains('open')) {
|
||||
e.preventDefault(); saveNote();
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- 启动
|
||||
if (state.token) {
|
||||
// 用一次轻量请求验证本地 token 还有效
|
||||
request('/notes?page=1&size=1')
|
||||
.then(showApp)
|
||||
.catch(function () { doLogout(); });
|
||||
} else {
|
||||
doLogout();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<!-- Vditor:本地托管,不依赖任何外部 CDN -->
|
||||
<script src="/admin/vendor/vditor/dist/index.min.js"></script>
|
||||
<script src="/admin/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user