feat: 多租户账号体系 + 前台收藏按钮
- 新增 users 表(user_id 数据隔离,bcrypt 密码) - 认证: 注册/登录(用户名+密码)/会话绑定用户, 首个用户成为管理员并接管旧数据 - 数据隔离: 笔记/分类/标签/回收站/版本/草稿/图谱/FTS 全部按用户隔离 - 前台: 登录/注册弹窗, 登录后★收藏自己的笔记, 游客只读公开笔记 - 后台: 用户名+密码登录, 每人管理自己的工作区, 越权访问返回404 - 冒烟测试重构+新增多租户隔离用例(78/78)
This commit is contained in:
+201
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user