feat: 多租户账号体系 + 前台收藏按钮
- 新增 users 表(user_id 数据隔离,bcrypt 密码) - 认证: 注册/登录(用户名+密码)/会话绑定用户, 首个用户成为管理员并接管旧数据 - 数据隔离: 笔记/分类/标签/回收站/版本/草稿/图谱/FTS 全部按用户隔离 - 前台: 登录/注册弹窗, 登录后★收藏自己的笔记, 游客只读公开笔记 - 后台: 用户名+密码登录, 每人管理自己的工作区, 越权访问返回404 - 冒烟测试重构+新增多租户隔离用例(78/78)
This commit is contained in:
+103
-19
@@ -81,58 +81,142 @@
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
}
|
||||
.switch-link {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
color: #764ba2;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.switch-link:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-box">
|
||||
<h1>后台管理登录</h1>
|
||||
<h1 id="boxTitle">后台管理登录</h1>
|
||||
<!-- 登录表单 -->
|
||||
<form id="loginForm">
|
||||
<div class="form-group">
|
||||
<label>管理密码</label>
|
||||
<input type="password" id="password" placeholder="请输入管理密码" required>
|
||||
<label>用户名</label>
|
||||
<input type="text" id="username" placeholder="请输入用户名" autocomplete="username" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input type="password" id="password" placeholder="请输入密码" autocomplete="current-password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-login" id="loginBtn">登 录</button>
|
||||
<p class="error-msg" id="errorMsg"></p>
|
||||
</form>
|
||||
<!-- 注册表单 -->
|
||||
<form id="registerForm" style="display:none;">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" id="regUsername" placeholder="设置用户名" autocomplete="username" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>昵称(可选)</label>
|
||||
<input type="text" id="regDisplay" placeholder="显示昵称" autocomplete="nickname">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码(至少 6 位)</label>
|
||||
<input type="password" id="regPassword" placeholder="设置密码" autocomplete="new-password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-login" id="regBtn">注 册</button>
|
||||
<p class="error-msg" id="regErrorMsg"></p>
|
||||
</form>
|
||||
<a href="javascript:void(0)" class="switch-link" id="switchLink">没有账号?去注册</a>
|
||||
<a href="/" class="back-link">返回前台</a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('loginForm');
|
||||
const loginForm = document.getElementById('loginForm');
|
||||
const registerForm = document.getElementById('registerForm');
|
||||
const username = document.getElementById('username');
|
||||
const password = document.getElementById('password');
|
||||
const regUsername = document.getElementById('regUsername');
|
||||
const regDisplay = document.getElementById('regDisplay');
|
||||
const regPassword = document.getElementById('regPassword');
|
||||
const loginBtn = document.getElementById('loginBtn');
|
||||
const regBtn = document.getElementById('regBtn');
|
||||
const errorMsg = document.getElementById('errorMsg');
|
||||
const regErrorMsg = document.getElementById('regErrorMsg');
|
||||
const switchLink = document.getElementById('switchLink');
|
||||
const boxTitle = document.getElementById('boxTitle');
|
||||
let isRegister = false;
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
function submit(btn, text) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = text;
|
||||
}
|
||||
function reset(btn, text) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = text;
|
||||
}
|
||||
|
||||
function setMode(reg) {
|
||||
isRegister = reg;
|
||||
loginForm.style.display = reg ? 'none' : 'block';
|
||||
registerForm.style.display = reg ? 'block' : 'none';
|
||||
boxTitle.textContent = reg ? '注册账号' : '后台管理登录';
|
||||
switchLink.textContent = reg ? '已有账号?去登录' : '没有账号?去注册';
|
||||
errorMsg.style.display = 'none';
|
||||
regErrorMsg.style.display = 'none';
|
||||
}
|
||||
switchLink.addEventListener('click', () => setMode(!isRegister));
|
||||
|
||||
loginForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
errorMsg.style.display = 'none';
|
||||
loginBtn.disabled = true;
|
||||
loginBtn.textContent = '登录中...';
|
||||
|
||||
submit(loginBtn, '登录中...');
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('username', username.value);
|
||||
formData.append('password', password.value);
|
||||
|
||||
const res = await fetch('/admin/login', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const res = await fetch('/admin/login', { method: 'POST', body: formData });
|
||||
const data = await res.json();
|
||||
|
||||
if (data.code === 0) {
|
||||
window.location.href = '/admin/';
|
||||
} else {
|
||||
errorMsg.textContent = data.message;
|
||||
errorMsg.style.display = 'block';
|
||||
loginBtn.disabled = false;
|
||||
loginBtn.textContent = '登 录';
|
||||
reset(loginBtn, '登 录');
|
||||
}
|
||||
} catch (err) {
|
||||
errorMsg.textContent = '登录失败,请重试';
|
||||
errorMsg.style.display = 'block';
|
||||
loginBtn.disabled = false;
|
||||
loginBtn.textContent = '登 录';
|
||||
reset(loginBtn, '登 录');
|
||||
}
|
||||
});
|
||||
|
||||
registerForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
regErrorMsg.style.display = 'none';
|
||||
submit(regBtn, '注册中...');
|
||||
try {
|
||||
const body = {
|
||||
username: regUsername.value,
|
||||
password: regPassword.value,
|
||||
display_name: regDisplay.value
|
||||
};
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.code === 0) {
|
||||
window.location.href = '/admin/';
|
||||
} else {
|
||||
regErrorMsg.textContent = data.message;
|
||||
regErrorMsg.style.display = 'block';
|
||||
reset(regBtn, '注 册');
|
||||
}
|
||||
} catch (err) {
|
||||
regErrorMsg.textContent = '注册失败,请重试';
|
||||
regErrorMsg.style.display = 'block';
|
||||
reset(regBtn, '注 册');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
+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