后台独立登录页面,并加入权限控制。

This commit is contained in:
2026-08-11 18:04:02 +08:00
parent 808f2e7523
commit a1d57d6b44
12 changed files with 638 additions and 178 deletions
+127 -93
View File
@@ -4,25 +4,34 @@ const API_BASE = '/api';
// 当前登录用户
let currentUser = null;
// 页面初始化
// 跳转到独立登录页
function goLogin() {
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('menus');
currentUser = null;
location.replace('/admin/login');
}
// 页面初始化(登录已移至独立页面 /admin/login,菜单与页面框架由服务端模板按权限渲染)
document.addEventListener('DOMContentLoaded', () => {
const token = localStorage.getItem('token');
if (token) {
currentUser = JSON.parse(localStorage.getItem('user') || '{}');
document.getElementById('username').textContent = currentUser.nickname || currentUser.username;
showSidebar();
showPage('dashboard');
} else {
// 未登录状态:隐藏所有页面,只显示登录页
document.querySelectorAll('.page').forEach(p => p.classList.add('hidden'));
const loginPage = document.getElementById('login-page');
if (loginPage) {
loginPage.classList.remove('hidden');
}
hideSidebar();
if (!token) {
goLogin();
return;
}
currentUser = JSON.parse(localStorage.getItem('user') || '{}');
const usernameEl = document.getElementById('username');
if (usernameEl) {
usernameEl.textContent = currentUser.nickname || currentUser.username;
}
// 绑定菜单点击事件
// 向服务端校验登录态与角色(不信任本地缓存,防止残留会话导致权限误判)
refreshCurrentUser();
showPage('dashboard');
// 绑定菜单点击事件(菜单由服务端模板渲染,只包含有权限的页面)
document.querySelectorAll('.menu-item').forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
@@ -43,20 +52,17 @@ document.addEventListener('DOMContentLoaded', () => {
});
});
// 登录表单
document.getElementById('login-form').addEventListener('submit', handleLogin);
// 文章表单
document.getElementById('post-form').addEventListener('submit', handlePostSubmit);
// 模态框表单
document.getElementById('modal-form').addEventListener('submit', handleModalSubmit);
// 退出登录
const logoutBtn = document.getElementById('logout-btn');
if (logoutBtn) {
logoutBtn.addEventListener('click', logout);
}
// 用户表单
document.getElementById('user-form').addEventListener('submit', handleUserSubmit);
// 基础设置表单
document.getElementById('settings-form').addEventListener('submit', handleSettingsSubmit);
// 各表单绑定(页面未渲染时对应元素不存在,跳过绑定)
bindForm('post-form', handlePostSubmit);
bindForm('modal-form', handleModalSubmit);
bindForm('user-form', handleUserSubmit);
bindForm('settings-form', handleSettingsSubmit);
// 封面图上传
const coverFileInput = document.getElementById('post-cover-file');
@@ -71,33 +77,50 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
// 显示登录页
function showLogin() {
hideSidebar();
document.querySelectorAll('.page').forEach(p => p.classList.add('hidden'));
const loginPage = document.getElementById('login-page');
if (loginPage) {
loginPage.classList.remove('hidden');
// 安全绑定表单提交(元素不存在时忽略)
function bindForm(formId, handler) {
const form = document.getElementById(formId);
if (form) {
form.addEventListener('submit', handler);
}
}
// 显示侧边栏
function showSidebar() {
document.querySelector('.sidebar').style.display = 'flex';
// 向服务端获取权威用户信息,校验 token 与角色
async function refreshCurrentUser() {
try {
const res = await fetch(`${API_BASE}/auth/me`, { headers: getHeaders() });
if (!res.ok) {
goLogin();
return;
}
const data = await res.json();
const fresh = data.data || {};
// 角色发生变化时重新加载页面,让服务端按新角色重新渲染菜单
if (currentUser && fresh.role && currentUser.role !== fresh.role) {
localStorage.setItem('user', JSON.stringify(fresh));
location.reload();
return;
}
currentUser = Object.assign({}, currentUser, fresh);
localStorage.setItem('user', JSON.stringify(currentUser));
const usernameEl = document.getElementById('username');
if (usernameEl) {
usernameEl.textContent = currentUser.nickname || currentUser.username;
}
} catch (err) {
console.error('校验登录态失败:', err);
}
}
// 隐藏侧边栏
function hideSidebar() {
document.querySelector('.sidebar').style.display = 'none';
}
// 切换页面
// 切换页面(页面框架由服务端模板渲染,未渲染的页面不存在也不加载数据)
function showPage(pageName) {
document.querySelectorAll('.page').forEach(p => p.classList.add('hidden'));
const targetPage = document.getElementById(pageName + '-page');
if (targetPage) {
targetPage.classList.remove('hidden');
if (!targetPage) {
alert('无权访问该页面');
return;
}
targetPage.classList.remove('hidden');
// 加载对应数据
switch(pageName) {
@@ -137,41 +160,14 @@ function showPage(pageName) {
}
}
// 登录
async function handleLogin(e) {
e.preventDefault();
const username = document.getElementById('login-username').value;
const password = document.getElementById('login-password').value;
// 退出登录:通知服务端清除 Cookie 后跳转登录页
async function logout() {
try {
const res = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (res.ok) {
localStorage.setItem('token', data.token);
localStorage.setItem('user', JSON.stringify(data.user));
currentUser = data.user;
document.getElementById('username').textContent = data.user.nickname || data.user.username;
showSidebar();
showPage('dashboard');
} else {
alert(data.error || '登录失败');
}
await fetch(`${API_BASE}/auth/logout`, { method: 'POST', headers: getHeaders() });
} catch (err) {
alert('网络错误');
// 忽略网络异常,本地登出继续执行
}
}
// 退出登录
function logout() {
localStorage.removeItem('token');
localStorage.removeItem('user');
currentUser = null;
showLogin();
goLogin();
}
// 获取请求头
@@ -189,7 +185,7 @@ async function loadDashboard() {
fetch(`${API_BASE}/posts?page_size=1`).then(r => r.json()),
fetch(`${API_BASE}/categories`).then(r => r.json()),
fetch(`${API_BASE}/tags`).then(r => r.json()),
fetch(`${API_BASE}/comments?status=pending`).then(r => r.json())
fetch(`${commentsAPIBase()}/comments?status=pending`).then(r => r.json())
]);
document.getElementById('stat-posts').textContent = posts.total || 0;
@@ -201,16 +197,26 @@ async function loadDashboard() {
}
}
// 加载文章列表
async function loadPosts() {
// 文章列表分页状态(每页 15 条)
const POSTS_PAGE_SIZE = 15;
let postsCurrentPage = 1;
// 加载文章列表(分页)
async function loadPosts(page = postsCurrentPage) {
try {
const res = await fetch(`${API_BASE}/posts?page_size=100`, { headers: getHeaders() });
const res = await fetch(`${API_BASE}/posts?page=${page}&page_size=${POSTS_PAGE_SIZE}`, { headers: getHeaders() });
const data = await res.json();
const list = data.data || [];
const total = data.total || 0;
const totalPages = Math.max(1, Math.ceil(total / POSTS_PAGE_SIZE));
postsCurrentPage = Math.min(Math.max(1, data.page || page), totalPages);
const tbody = document.getElementById('posts-list');
tbody.innerHTML = data.data?.map(post => `
tbody.innerHTML = list.map(post => `
<tr>
<td>${post.title}</td>
<td>${post.author?.nickname || post.author?.username || '-'}</td>
<td>${post.category?.name || '-'}</td>
<td><span class="status-badge status-${post.status}">${getStatusText(post.status)}</span></td>
<td>${post.views}</td>
@@ -221,12 +227,29 @@ async function loadPosts() {
<button class="btn-danger" onclick="deletePost(${post.id})">删除</button>
</td>
</tr>
`).join('') || '<tr><td colspan="6" style="text-align:center">暂无文章</td></tr>';
`).join('') || '<tr><td colspan="7" style="text-align:center">暂无文章</td></tr>';
renderPostsPagination(total, totalPages);
} catch (err) {
console.error('加载文章失败:', err);
}
}
// 渲染文章列表分页条
function renderPostsPagination(total, totalPages) {
const box = document.getElementById('posts-pagination');
if (!box) return;
const buttons = [];
buttons.push(`<button ${postsCurrentPage <= 1 ? 'disabled' : ''} onclick="loadPosts(${postsCurrentPage - 1})">上一页</button>`);
for (let i = 1; i <= totalPages; i++) {
buttons.push(`<button class="${i === postsCurrentPage ? 'active' : ''}" onclick="loadPosts(${i})">${i}</button>`);
}
buttons.push(`<button ${postsCurrentPage >= totalPages ? 'disabled' : ''} onclick="loadPosts(${postsCurrentPage + 1})">下一页</button>`);
box.innerHTML = buttons.join('') + `<span class="pagination-info">共 ${total} 条,第 ${postsCurrentPage}/${totalPages} 页</span>`;
}
// 显示文章表单
async function showPostForm(isEdit = false) {
document.getElementById('post-form-title').textContent = isEdit ? '编辑文章' : '新建文章';
@@ -235,6 +258,12 @@ async function showPostForm(isEdit = false) {
setCover('');
mdShowEdit();
// 非管理员隐藏置顶选项
const istopGroup = document.getElementById('post-istop')?.closest('.form-group');
if (istopGroup) {
istopGroup.style.display = (currentUser && currentUser.role === 'admin') ? '' : 'none';
}
// 加载分类选项
const res = await fetch(`${API_BASE}/categories`);
const data = await res.json();
@@ -296,7 +325,7 @@ async function handlePostSubmit(e) {
};
try {
const url = id ? `${API_BASE}/admin/posts/${id}` : `${API_BASE}/admin/posts`;
const url = id ? `${API_BASE}/posts/${id}` : `${API_BASE}/posts`;
const method = id ? 'PUT' : 'POST';
const res = await fetch(url, {
@@ -376,7 +405,7 @@ async function mdShowPreview() {
preview.classList.remove('hidden');
preview.innerHTML = '<p class="md-preview-tip">渲染中...</p>';
try {
const res = await fetch(`${API_BASE}/admin/markdown/preview`, {
const res = await fetch(`${API_BASE}/markdown/preview`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ content: ta.value })
@@ -394,7 +423,7 @@ async function changePostStatus(id, status) {
if (!confirm(`确定要${actionText}这篇文章吗?`)) return;
try {
const res = await fetch(`${API_BASE}/admin/posts/${id}`, {
const res = await fetch(`${API_BASE}/posts/${id}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ status })
@@ -416,7 +445,7 @@ async function deletePost(id) {
if (!confirm('确定要删除这篇文章吗?')) return;
try {
const res = await fetch(`${API_BASE}/admin/posts/${id}`, {
const res = await fetch(`${API_BASE}/posts/${id}`, {
method: 'DELETE',
headers: getHeaders()
});
@@ -501,10 +530,15 @@ async function loadPages() {
}
}
// 评论管理 API 基础路径(管理员走 admin 组,普通用户走 my 组)
function commentsAPIBase() {
return (currentUser && currentUser.role === 'admin') ? `${API_BASE}/admin` : `${API_BASE}/my`;
}
// 加载评论(postId 为空时加载全部)
async function loadComments(postId = '') {
try {
const url = postId ? `${API_BASE}/admin/comments?post_id=${postId}` : `${API_BASE}/admin/comments`;
const url = postId ? `${commentsAPIBase()}/comments?post_id=${postId}` : `${commentsAPIBase()}/comments`;
const res = await fetch(url, { headers: getHeaders() });
const data = await res.json();
@@ -708,7 +742,7 @@ async function deletePage(id) {
// 通过评论
async function approveComment(id) {
await fetch(`${API_BASE}/admin/comments/${id}/approve`, {
await fetch(`${commentsAPIBase()}/comments/${id}/approve`, {
method: 'PUT',
headers: getHeaders()
});
@@ -718,7 +752,7 @@ async function approveComment(id) {
// 删除评论
async function deleteComment(id) {
if (!confirm('确定要删除这条评论吗?')) return;
await fetch(`${API_BASE}/admin/comments/${id}`, {
await fetch(`${commentsAPIBase()}/comments/${id}`, {
method: 'DELETE',
headers: getHeaders()
});
@@ -1421,7 +1455,7 @@ async function uploadImageFile(file) {
const formData = new FormData();
formData.append('file', file);
const res = await fetch(`${API_BASE}/admin/upload`, {
const res = await fetch(`${API_BASE}/upload`, {
method: 'POST',
// 注意:上传 FormData 时不能手动设置 Content-Type,浏览器会自动处理 boundary
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') },
+65 -47
View File
@@ -1,11 +1,11 @@
<!DOCTYPE html>
{{define "admin_index"}}<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>后台管理 - GoBlog</title>
<link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/static/admin/style.css?v=2">
<link rel="stylesheet" href="/static/admin/style.css?v=3">
</head>
<body>
<div id="app">
@@ -15,56 +15,49 @@
<p>后台管理</p>
</div>
<nav class="menu">
<a href="#dashboard" class="menu-item active" data-page="dashboard">仪表盘</a>
{{range .Menus}}{{if eq .Group ""}}{{range $i, $item := .Items}}<a href="#{{$item.Key}}" class="menu-item{{if eq $item.Key "dashboard"}} active{{end}}" data-page="{{$item.Key}}">{{$item.Name}}</a>
{{end}}{{else}}
<div class="menu-group open">
<button class="menu-group-header" type="button">内容管理</button>
<button class="menu-group-header" type="button">{{.Group}}</button>
<div class="menu-sub">
<a href="#posts" class="menu-item" data-page="posts">文章管理</a>
<a href="#categories" class="menu-item" data-page="categories">分类管理</a>
<a href="#tags" class="menu-item" data-page="tags">标签管理</a>
<a href="#pages" class="menu-item" data-page="pages">页面管理</a>
<a href="#comments" class="menu-item" data-page="comments">评论管理</a>
</div>
</div>
<div class="menu-group open">
<button class="menu-group-header" type="button">系统管理</button>
<div class="menu-sub">
<a href="#users" class="menu-item" data-page="users">用户管理</a>
<a href="#files" class="menu-item" data-page="files">文件管理</a>
<a href="#themes" class="menu-item" data-page="themes">主题管理</a>
<a href="#settings" class="menu-item" data-page="settings">基础设置</a>
{{range .Items}}<a href="#{{.Key}}" class="menu-item" data-page="{{.Key}}">{{.Name}}</a>
{{end}}
</div>
</div>
{{end}}{{end}}
</nav>
<div class="user-info">
<span id="username">Admin</span>
<button onclick="logout()">退出</button>
<span id="username">{{.DisplayName}}</span>
<button id="logout-btn">退出</button>
</div>
</aside>
<main class="main-content">
<!-- 登录页面 -->
<div id="login-page" class="page">
<div class="login-box">
<h2>管理员登录</h2>
<form id="login-form">
<div class="form-group">
<label>用户名</label>
<input type="text" id="login-username" required>
</div>
<div class="form-group">
<label>密码</label>
<input type="password" id="login-password" required>
</div>
<button type="submit">登录</button>
</form>
</div>
</div>
{{if index .PageSet "dashboard"}}{{template "admin_page_dashboard" .}}{{end}}
{{if index .PageSet "posts"}}{{template "admin_page_posts" .}}{{template "admin_page_post_form" .}}{{end}}
{{if index .PageSet "categories"}}{{template "admin_page_categories" .}}{{end}}
{{if index .PageSet "tags"}}{{template "admin_page_tags" .}}{{end}}
{{if index .PageSet "pages"}}{{template "admin_page_pages" .}}{{end}}
{{if index .PageSet "comments"}}{{template "admin_page_comments" .}}{{end}}
{{if index .PageSet "users"}}{{template "admin_page_users" .}}{{end}}
{{if index .PageSet "settings"}}{{template "admin_page_settings" .}}{{end}}
{{if index .PageSet "files"}}{{template "admin_page_files" .}}{{end}}
{{if index .PageSet "themes"}}{{template "admin_page_themes" .}}{{template "admin_page_theme_editor" .}}{{end}}
</main>
</div>
{{if or (index .PageSet "categories") (index .PageSet "tags") (index .PageSet "pages")}}{{template "admin_modal_common" .}}{{end}}
{{if index .PageSet "users"}}{{template "admin_modal_user" .}}{{end}}
{{if index .PageSet "themes"}}{{template "admin_modal_theme" .}}{{end}}
<script src="/static/admin/app.js?v=19"></script>
</body>
</html>
{{end}}
{{define "admin_page_dashboard"}}
<!-- 仪表盘 -->
<div id="dashboard-page" class="page hidden">
<div id="dashboard-page" class="page">
<h1>仪表盘</h1>
<div class="stats-grid">
<div class="stat-card">
@@ -85,7 +78,9 @@
</div>
</div>
</div>
{{end}}
{{define "admin_page_posts"}}
<!-- 文章管理 -->
<div id="posts-page" class="page hidden">
<div class="page-header">
@@ -96,6 +91,7 @@
<thead>
<tr>
<th>标题</th>
<th>作者</th>
<th>分类</th>
<th>状态</th>
<th>浏览量</th>
@@ -105,8 +101,11 @@
</thead>
<tbody id="posts-list"></tbody>
</table>
<div id="posts-pagination" class="pagination"></div>
</div>
{{end}}
{{define "admin_page_post_form"}}
<!-- 文章表单 -->
<div id="post-form-page" class="page hidden">
<div class="page-header">
@@ -191,7 +190,9 @@
<button type="submit" class="btn-primary" onclick="mdShowEdit()">保存</button>
</form>
</div>
{{end}}
{{define "admin_page_categories"}}
<!-- 分类管理 -->
<div id="categories-page" class="page hidden">
<div class="page-header">
@@ -210,7 +211,9 @@
<tbody id="categories-list"></tbody>
</table>
</div>
{{end}}
{{define "admin_page_tags"}}
<!-- 标签管理 -->
<div id="tags-page" class="page hidden">
<div class="page-header">
@@ -229,7 +232,9 @@
<tbody id="tags-list"></tbody>
</table>
</div>
{{end}}
{{define "admin_page_pages"}}
<!-- 页面管理 -->
<div id="pages-page" class="page hidden">
<div class="page-header">
@@ -249,7 +254,9 @@
<tbody id="pages-list"></tbody>
</table>
</div>
{{end}}
{{define "admin_page_comments"}}
<!-- 评论管理 -->
<div id="comments-page" class="page hidden">
<div class="page-header">
@@ -272,7 +279,9 @@
<tbody id="comments-list"></tbody>
</table>
</div>
{{end}}
{{define "admin_page_users"}}
<!-- 用户管理 -->
<div id="users-page" class="page hidden">
<div class="page-header">
@@ -294,9 +303,11 @@
<tbody id="users-list"></tbody>
</table>
</div>
{{end}}
{{define "admin_page_settings"}}
<!-- 基础设置 -->
<div id="settings-page" class="page hidden">
<div id="settings-page" class="page hidden">
<div class="page-header">
<h1>基础设置</h1>
</div>
@@ -322,7 +333,9 @@
<button type="submit" class="btn-primary">保存设置</button>
</form>
</div>
{{end}}
{{define "admin_page_files"}}
<!-- 文件管理 -->
<div id="files-page" class="page hidden">
<div class="page-header">
@@ -335,7 +348,9 @@
<p class="upload-hint">支持 jpg/png/gif/webp 格式,单个文件最大 5MB</p>
<div class="file-grid" id="file-grid"></div>
</div>
{{end}}
{{define "admin_page_themes"}}
<!-- 主题管理 -->
<div id="themes-page" class="page hidden">
<div class="page-header">
@@ -356,7 +371,9 @@
<!-- 主题列表将动态加载 -->
</div>
</div>
{{end}}
{{define "admin_page_theme_editor"}}
<!-- 主题编辑器 -->
<div id="theme-editor-page" class="page hidden">
<div class="page-header">
@@ -379,9 +396,9 @@
</div>
</div>
</div>
</main>
</div>
{{end}}
{{define "admin_modal_common"}}
<!-- 分类/标签/页面 表单弹窗 -->
<div id="modal" class="modal hidden">
<div class="modal-content">
@@ -407,7 +424,9 @@
</form>
</div>
</div>
{{end}}
{{define "admin_modal_user"}}
<!-- 用户表单弹窗 -->
<div id="user-modal" class="modal hidden">
<div class="modal-content">
@@ -453,7 +472,9 @@
</form>
</div>
</div>
{{end}}
{{define "admin_modal_theme"}}
<!-- 创建主题弹窗 -->
<div id="theme-modal" class="modal hidden">
<div class="modal-content modal-large">
@@ -477,7 +498,4 @@
</form>
</div>
</div>
<script src="/static/admin/app.js?v=16"></script>
</body>
</html>
{{end}}
+76
View File
@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - GoBlog 后台管理</title>
<link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/static/admin/style.css?v=3">
</head>
<body>
<div id="login-page">
<div class="login-box">
<h2>管理员登录</h2>
<form id="login-form">
<div class="form-group">
<label>用户名</label>
<input type="text" id="login-username" required autocomplete="username">
</div>
<div class="form-group">
<label>密码</label>
<input type="password" id="login-password" required autocomplete="current-password">
</div>
<div id="login-error" class="login-error hidden"></div>
<button type="submit" id="login-submit">登录</button>
</form>
</div>
</div>
<script>
// 已登录则直接进入后台
if (localStorage.getItem('token')) {
location.replace('/admin');
}
function showError(msg) {
const el = document.getElementById('login-error');
el.textContent = msg;
el.classList.remove('hidden');
}
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('login-submit');
btn.disabled = true;
btn.textContent = '登录中...';
const username = document.getElementById('login-username').value;
const password = document.getElementById('login-password').value;
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (res.ok) {
localStorage.setItem('token', data.token);
localStorage.setItem('user', JSON.stringify(data.user));
// 菜单由登录接口返回,缓存供前端参考(页面菜单以服务端模板渲染为准)
localStorage.setItem('menus', JSON.stringify(data.menus || []));
location.href = '/admin';
} else {
showError(data.error || '登录失败');
btn.disabled = false;
btn.textContent = '登录';
}
} catch (err) {
showError('网络错误,请稍后重试');
btn.disabled = false;
btn.textContent = '登录';
}
});
</script>
</body>
</html>
+54
View File
@@ -313,6 +313,60 @@ body {
font-size: 16px;
}
.login-box button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.login-error {
color: #e74c3c;
background: #fdecea;
border-radius: 4px;
padding: 8px 12px;
margin-bottom: 16px;
font-size: 14px;
}
/* 列表分页条 */
.pagination {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-top: 16px;
}
.pagination button {
padding: 6px 12px;
border: 1px solid #ddd;
background: #fff;
border-radius: 4px;
cursor: pointer;
color: #333;
}
.pagination button:hover:not(:disabled):not(.active) {
border-color: #3498db;
color: #3498db;
}
.pagination button.active {
background: #3498db;
border-color: #3498db;
color: #fff;
}
.pagination button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination .pagination-info {
margin-left: auto;
color: #888;
font-size: 13px;
}
/* 弹窗 */
.modal {
position: fixed;