feat: 多租户账号体系 + 前台收藏按钮

- 新增 users 表(user_id 数据隔离,bcrypt 密码)
- 认证: 注册/登录(用户名+密码)/会话绑定用户, 首个用户成为管理员并接管旧数据
- 数据隔离: 笔记/分类/标签/回收站/版本/草稿/图谱/FTS 全部按用户隔离
- 前台: 登录/注册弹窗, 登录后★收藏自己的笔记, 游客只读公开笔记
- 后台: 用户名+密码登录, 每人管理自己的工作区, 越权访问返回404
- 冒烟测试重构+新增多租户隔离用例(78/78)
This commit is contained in:
Your Name
2026-08-11 12:58:12 +08:00
parent 2196189791
commit d9793300f9
16 changed files with 1216 additions and 278 deletions
+201
View File
@@ -610,6 +610,54 @@
.header-right .search-box { max-width: none; }
.wrap-text { word-break: break-word; }
}
/* 登录/注册弹窗 */
.auth-modal {
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
display: flex; align-items: center; justify-content: center; z-index: 9999;
}
.auth-box {
background: #fff; border-radius: 12px; padding: 30px; width: 92%; max-width: 380px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3); position: relative;
}
body.dark .auth-box { background: #1a1e26; color: #e8eaed; }
.auth-box h3 { margin: 0 0 20px; text-align: center; color: #333; }
body.dark .auth-box h3 { color: #e8eaed; }
.auth-form-group { margin-bottom: 14px; }
.auth-form-group label { display: block; margin-bottom: 6px; font-size: 13px; color: #666; }
body.dark .auth-form-group label { color: #9aa0aa; }
.auth-form-group input {
width: 100%; padding: 10px 12px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px;
}
body.dark .auth-form-group input { background: #11141a; border-color: #2a3040; color: #e8eaed; }
.auth-error { color: #dc3545; font-size: 13px; margin: 8px 0; }
.auth-actions { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; }
.auth-btn {
padding: 11px; background: #1976d2; color: #fff; border: none; border-radius: 6px;
font-size: 15px; cursor: pointer;
}
.auth-switch {
padding: 8px; background: none; border: none; color: #1976d2; cursor: pointer; font-size: 13px;
}
.auth-close {
position: absolute; top: 12px; right: 14px; background: none; border: none;
font-size: 16px; cursor: pointer; color: #888;
}
body.dark .auth-close { color: #9aa0aa; }
.auth-link { background: none; border: 1px solid #1976d2; color: #1976d2; border-radius: 16px; padding: 4px 12px; font-size: 13px; cursor: pointer; margin-right: 4px; }
.auth-user { font-size: 13px; color: #666; }
body.dark .auth-user { color: #c9ced8; }
.auth-user b { color: #1976d2; }
/* 收藏按钮 */
.fav-btn {
margin-left: auto; display: inline-flex; align-items: center; gap: 4px;
background: none; border: 1px solid #ddd; border-radius: 16px; padding: 3px 12px;
font-size: 13px; cursor: pointer; color: #666;
}
.fav-btn:hover { border-color: #f5a623; }
.fav-btn.on { border-color: #f5a623; color: #f5a623; }
body.dark .fav-btn { border-color: #2a3040; color: #c9ced8; }
body.dark .fav-btn.on { border-color: #f5a623; color: #f5a623; }
</style>
</head>
<body>
@@ -623,11 +671,28 @@
<svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
<input type="text" id="searchInput" placeholder="搜索笔记..." onkeyup="handleSearch(event)">
</div>
<span id="userArea" style="display:flex;align-items:center;gap:8px;"></span>
<a href="/admin/" style="color: #666; text-decoration: none; font-size: 14px;">管理</a>
<button onclick="toggleSiteDark()" id="darkBtn" style="background:none;border:1px solid #ccc;border-radius:20px;padding:5px 12px;font-size:13px;cursor:pointer;color:#666;">🌙 深色</button>
</div>
</header>
<!-- 登录/注册弹窗 -->
<div class="auth-modal" id="authModal" style="display:none;">
<div class="auth-box">
<h3 id="authTitle">登录</h3>
<div class="auth-form-group"><label>用户名</label><input type="text" id="authUsername" autocomplete="username"></div>
<div class="auth-form-group" id="authDisplayGroup" style="display:none;"><label>昵称(可选)</label><input type="text" id="authDisplay" autocomplete="nickname"></div>
<div class="auth-form-group"><label>密码</label><input type="password" id="authPassword" autocomplete="current-password"></div>
<p class="auth-error" id="authError" style="display:none;"></p>
<div class="auth-actions">
<button class="auth-btn" id="authSubmit">登 录</button>
<button class="auth-switch" id="authSwitch" type="button">没有账号?注册</button>
</div>
<button class="auth-close" id="authClose" type="button"></button>
</div>
</div>
<div class="main">
<div class="sidebar">
<div class="sidebar-header">
@@ -673,6 +738,10 @@
<span id="noteDate"></span>
<span id="noteStats" style="color:#999;font-size:12px;margin-left:8px;"></span>
<div class="tags" id="noteTags"></div>
<button id="favBtn" class="fav-btn" style="display:none;" onclick="toggleFavoritePublic()">
<span id="favStar" style="color:#ccc;"></span>
<span id="favText">收藏</span>
</button>
</div>
<div class="note-content" id="noteContent"></div>
</div>
@@ -693,12 +762,141 @@
let currentNote = null;
let currentFilter = { type: 'all' };
let expandedNodes = new Set();
let currentUser = null; // 当前登录用户
let authMode = 'login';
async function init() {
await checkAuth();
await loadTree();
await loadCategories();
}
// ─────────── 认证 ───────────
async function checkAuth() {
try {
const res = await fetch(`${API}/auth/me`);
const data = await res.json();
currentUser = data.code === 0 && data.data ? data.data : null;
} catch (e) {
currentUser = null;
}
renderUserArea();
}
function renderUserArea() {
const area = document.getElementById('userArea');
if (!area) return;
if (currentUser) {
area.innerHTML = `
<span class="auth-user">你好,<b>${escapeHtml(currentUser.display_name || currentUser.username)}</b></span>
<button class="auth-link" onclick="logout()">退出</button>
`;
} else {
area.innerHTML = `
<button class="auth-link" onclick="openAuth('login')">登录</button>
<button class="auth-link" onclick="openAuth('register')">注册</button>
`;
}
}
function openAuth(mode) {
authMode = mode;
document.getElementById('authTitle').textContent = mode === 'login' ? '登录' : '注册';
document.getElementById('authDisplayGroup').style.display = mode === 'login' ? 'none' : 'block';
document.getElementById('authSwitch').textContent = mode === 'login' ? '没有账号?注册' : '已有账号?登录';
document.getElementById('authSubmit').textContent = mode === 'login' ? '登 录' : '注 册';
document.getElementById('authError').style.display = 'none';
document.getElementById('authModal').style.display = 'flex';
}
function closeAuth() {
document.getElementById('authModal').style.display = 'none';
}
document.getElementById('authClose').addEventListener('click', closeAuth);
document.getElementById('authSwitch').addEventListener('click', () => {
openAuth(authMode === 'login' ? 'register' : 'login');
});
document.getElementById('authSubmit').addEventListener('click', async () => {
const username = document.getElementById('authUsername').value.trim();
const password = document.getElementById('authPassword').value;
const displayName = document.getElementById('authDisplay').value.trim();
const errEl = document.getElementById('authError');
errEl.style.display = 'none';
if (!username || !password) {
errEl.textContent = '请输入用户名和密码';
errEl.style.display = 'block';
return;
}
try {
const url = authMode === 'login' ? `${API}/auth/login` : `${API}/auth/register`;
const body = authMode === 'login'
? { username, password }
: { username, password, display_name: displayName };
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json();
if (data.code === 0) {
closeAuth();
await checkAuth();
await loadTree(); // 登录后树变为自己的笔记
await loadCategories();
if (currentFilter.type === 'favorites') await filterFavorites();
} else {
errEl.textContent = data.message;
errEl.style.display = 'block';
}
} catch (e) {
errEl.textContent = '请求失败,请重试';
errEl.style.display = 'block';
}
});
async function logout() {
await fetch(`${API}/auth/logout`, { method: 'POST' });
currentUser = null;
renderUserArea();
await loadTree(); // 登出后树变为公开笔记
await loadCategories();
if (currentFilter.type === 'favorites') await filterFavorites();
}
// 前台收藏按钮:登录用户可收藏/取消收藏自己的笔记
async function toggleFavoritePublic() {
if (!currentUser || !currentNote) {
showToast('请先登录', 'error');
return;
}
const newVal = !(currentNote.is_favorite === true);
try {
const res = await fetch(`/admin/api/notes/${currentNote.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_favorite: newVal })
});
const data = await res.json();
if (data.code !== 0) {
showToast(data.message || '操作失败', 'error');
return;
}
currentNote.is_favorite = newVal;
updateFavUI();
showToast(newVal ? '已收藏' : '已取消收藏', 'success');
} catch (e) {
showToast('操作失败', 'error');
}
}
function updateFavUI() {
const btn = document.getElementById('favBtn');
if (!btn) return;
const fav = currentNote && currentNote.is_favorite === true;
btn.style.display = currentUser ? 'inline-flex' : 'none';
btn.classList.toggle('on', !!fav);
document.getElementById('favStar').textContent = fav ? '★' : '☆';
document.getElementById('favText').textContent = fav ? '已收藏' : '收藏';
document.getElementById('favStar').style.color = fav ? '#f5a623' : '#ccc';
}
// 加载分类并在筛选区渲染 chips
async function loadCategories() {
try {
@@ -920,6 +1118,9 @@
// 渲染 mermaid 图表(异步)
setTimeout(renderMermaid, 50);
// 更新收藏按钮状态
updateFavUI();
}
function generateTOC(content) {