初始提交:姜十三论坛 Jiang13 Forum

轻量自用论坛,Go 单二进制 + React SPA 内嵌 + SQLite。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
freefire
2026-06-15 21:08:52 +08:00
commit e1c1708715
140 changed files with 16115 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
{{define "admin/boards.html"}}{{template "admin/layout" .}}{{end}}
{{define "admin_content_boards"}}
<div class="admin-page-head">
<div>
<h1>板块管理</h1>
<p>创建和维护论坛板块,有帖子的板块无法删除</p>
</div>
</div>
<div class="admin-card">
<div class="admin-card-head">新建板块</div>
<div class="admin-card-body">
<form id="createBoardForm" class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small">板块名称</label>
<input name="name" class="form-control" placeholder="如:技术交流" required maxlength="64">
</div>
<div class="col-md-4">
<label class="form-label small">简介</label>
<input name="description" class="form-control" placeholder="板块说明(可选)" maxlength="500">
</div>
<div class="col-md-2">
<label class="form-label small">排序</label>
<input name="sort_order" type="number" class="form-control" value="0">
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-success w-100" id="createBoardBtn">创建板块</button>
</div>
</form>
</div>
</div>
<div class="admin-card">
<div class="admin-card-head">板块列表 <span class="text-muted fw-normal">共 {{len .Boards}} 个</span></div>
<div class="table-responsive">
<table class="table admin-table mb-0">
<thead><tr><th>ID</th><th>名称</th><th>描述</th><th>排序</th><th>帖子数</th><th width="160">操作</th></tr></thead>
<tbody>
{{range .Boards}}
<tr>
<td>{{.ID}}</td>
<td><input class="form-control form-control-sm" id="name-{{.ID}}" value="{{.Name}}"></td>
<td><input class="form-control form-control-sm" id="desc-{{.ID}}" value="{{.Description}}"></td>
<td><input class="form-control form-control-sm" id="sort-{{.ID}}" type="number" value="{{.SortOrder}}" style="width:72px"></td>
<td><span class="badge bg-secondary">{{.PostCount}}</span></td>
<td>
<button class="btn btn-sm btn-primary" onclick="updateBoard({{.ID}}, this)">保存</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteBoard({{.ID}}, {{.PostCount}})">删除</button>
</td>
</tr>
{{else}}
<tr><td colspan="6" class="admin-empty">还没有板块,请先创建</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}
{{define "admin_scripts_boards"}}
<script>
document.getElementById('createBoardForm').addEventListener('submit', function(e) {
e.preventDefault();
var btn = document.getElementById('createBoardBtn');
setBtnLoading(btn, true);
adminFetch('/admin/api/boards', { method: 'POST', body: new FormData(this) })
.then(function(d) { showToast(d.message); reloadSoon(); })
.catch(function(e) { showToast(e.message, 'danger'); })
.finally(function() { setBtnLoading(btn, false); });
});
function updateBoard(id, btn) {
setBtnLoading(btn, true);
postForm('/admin/api/boards/' + id, 'PUT', {
name: document.getElementById('name-' + id).value,
description: document.getElementById('desc-' + id).value,
sort_order: document.getElementById('sort-' + id).value
}).then(function(d) { showToast(d.message); })
.catch(function(e) { showToast(e.message, 'danger'); })
.finally(function() { setBtnLoading(btn, false); });
}
function deleteBoard(id, postCount) {
if (postCount > 0) {
showToast('该板块下还有 ' + postCount + ' 篇帖子,无法删除', 'warning');
return;
}
adminConfirm('确定删除该板块?此操作不可恢复。', function() {
adminFetch('/admin/api/boards/' + id, { method: 'DELETE' })
.then(function(d) { showToast(d.message); reloadSoon(); })
.catch(function(e) { showToast(e.message, 'danger'); });
});
}
</script>
{{end}}

View File

@@ -0,0 +1,58 @@
{{define "admin/comments.html"}}{{template "admin/layout" .}}{{end}}
{{define "admin_content_comments"}}
<div class="admin-page-head">
<div>
<h1>评论管理</h1>
<p>查看和删除评论,共 {{.Total}} 条</p>
</div>
</div>
<div class="admin-card">
<div class="table-responsive">
<table class="table admin-table mb-0">
<thead>
<tr><th>ID</th><th>楼层</th><th>帖子</th><th>作者</th><th>回复</th><th>内容</th><th>时间</th><th width="100">操作</th></tr>
</thead>
<tbody>
{{range .Comments}}
<tr>
<td>{{.ID}}</td>
<td>#{{.Floor}}</td>
<td class="text-truncate-cell">
{{if .Post}}
<a href="/post/{{.PostID}}" target="_blank">{{.Post.Title}}</a>
{{else}}帖子 #{{.PostID}}{{end}}
</td>
<td>{{if .UserID}}{{if .User}}{{.User.Nickname}}{{else}}-{{end}}{{else}}{{.GuestNick}}{{end}}</td>
<td>
{{if .ReplyTarget}}
@{{if .ReplyTarget.UserID}}{{if .ReplyTarget.User}}{{.ReplyTarget.User.Nickname}}{{else}}-{{end}}{{else}}{{.ReplyTarget.GuestNick}}{{end}}
{{else}}-{{end}}
</td>
<td class="text-truncate-cell">{{.Content}}</td>
<td>{{.CreatedAt.Format "01-02 15:04"}}</td>
<td>
<button class="btn btn-sm btn-outline-danger" onclick="deleteComment({{.ID}})">删除</button>
</td>
</tr>
{{else}}
<tr><td colspan="8" class="admin-empty">暂无评论</td></tr>
{{end}}
</tbody>
</table>
</div>
{{template "admin_pagination" .}}
</div>
{{end}}
{{define "admin_scripts_comments"}}
<script>
function deleteComment(id) {
adminConfirm('确定删除该评论?', function() {
adminFetch('/admin/api/comments/' + id, { method: 'DELETE' })
.then(function(d) { showToast(d.message); reloadSoon(); })
.catch(function(e) { showToast(e.message, 'danger'); });
});
}
</script>
{{end}}

View File

@@ -0,0 +1,76 @@
{{define "admin/dashboard.html"}}{{template "admin/layout" .}}{{end}}
{{define "admin_content_dashboard"}}
<div class="admin-page-head">
<div>
<h1>仪表盘</h1>
<p>欢迎回来{{if .CurrentUser}}{{.CurrentUser.Nickname}}{{end}} · 论坛运行概况</p>
</div>
</div>
<div class="admin-stat-grid">
<div class="admin-stat-card admin-stat-users">
<div class="admin-stat-icon"></div>
<div class="admin-stat-info">
<div class="value">{{.UserCount}}</div>
<div class="label">注册用户</div>
</div>
</div>
<div class="admin-stat-card admin-stat-posts">
<div class="admin-stat-icon"></div>
<div class="admin-stat-info">
<div class="value">{{.PostCount}}</div>
<div class="label">帖子总数</div>
</div>
</div>
<div class="admin-stat-card admin-stat-boards">
<div class="admin-stat-icon"></div>
<div class="admin-stat-info">
<div class="value">{{.BoardCount}}</div>
<div class="label">板块数量</div>
</div>
</div>
<div class="admin-stat-card admin-stat-comments">
<div class="admin-stat-icon"></div>
<div class="admin-stat-info">
<div class="value">{{.CommentCount}}</div>
<div class="label">评论总数</div>
</div>
</div>
<div class="admin-stat-card admin-stat-online">
<div class="admin-stat-icon">线</div>
<div class="admin-stat-info">
<div class="value">{{.OnlineCount}}</div>
<div class="label">当前浏览</div>
</div>
</div>
</div>
<div class="admin-card">
<div class="admin-card-head">
<span>最近帖子</span>
<a href="/admin/posts" class="admin-card-link">查看全部 →</a>
</div>
<div class="table-responsive">
<table class="table admin-table mb-0">
<thead><tr><th>标题</th><th>板块</th><th>作者</th><th>时间</th><th width="80"></th></tr></thead>
<tbody>
{{range .RecentPosts}}
<tr>
<td class="text-truncate-cell">
{{if .Pinned}}<span class="badge badge-pin me-1">置顶</span>{{end}}{{.Title}}
</td>
<td>{{if .Board}}{{.Board.Name}}{{else}}-{{end}}</td>
<td>{{if .User}}{{.User.Nickname}}{{else}}-{{end}}</td>
<td class="text-muted">{{.CreatedAt.Format "01-02 15:04"}}</td>
<td><a href="/post/{{.ID}}" target="_blank" class="btn btn-sm btn-link px-0">查看</a></td>
</tr>
{{else}}
<tr><td colspan="5" class="admin-empty">暂无帖子</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}
{{define "admin_scripts_dashboard"}}{{end}}

View File

@@ -0,0 +1,88 @@
{{define "admin/layout"}}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} - 姜十三论坛管理后台</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/legacy/css/style.css" rel="stylesheet">
</head>
<body class="admin-body">
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index:9999">
<div id="adminToast" class="toast" role="alert">
<div class="d-flex">
<div class="toast-body"></div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
</div>
<header class="admin-topbar">
<div class="admin-topbar-brand">
<div class="admin-topbar-mark"></div>
<div>
<span class="admin-topbar-title">姜十三论坛</span>
<span class="admin-topbar-sub">管理后台</span>
</div>
</div>
<div class="admin-topbar-actions">
{{if .CurrentUser}}
<span class="admin-topbar-user">{{.CurrentUser.Nickname}}</span>
{{end}}
<a href="/" class="btn btn-outline-light btn-sm">返回前台</a>
<button class="btn btn-light btn-sm" onclick="adminLogout()">退出</button>
</div>
</header>
<div class="admin-shell">
<aside class="admin-sidebar">
<div class="admin-sidebar-section">概览</div>
<a href="/admin/dashboard" class="nav-link {{if eq .ActiveNav "dashboard"}}active{{end}}">仪表盘</a>
<div class="admin-sidebar-section">内容</div>
<a href="/admin/boards" class="nav-link {{if eq .ActiveNav "boards"}}active{{end}}">板块管理</a>
<a href="/admin/posts" class="nav-link {{if eq .ActiveNav "posts"}}active{{end}}">帖子管理</a>
<a href="/admin/comments" class="nav-link {{if eq .ActiveNav "comments"}}active{{end}}">评论管理</a>
<div class="admin-sidebar-section">系统</div>
<a href="/admin/users" class="nav-link {{if eq .ActiveNav "users"}}active{{end}}">用户管理</a>
<a href="/admin/settings" class="nav-link {{if eq .ActiveNav "settings"}}active{{end}}">系统设置</a>
</aside>
<main class="admin-main">
{{if eq .ActiveNav "dashboard"}}{{template "admin_content_dashboard" .}}
{{else if eq .ActiveNav "boards"}}{{template "admin_content_boards" .}}
{{else if eq .ActiveNav "posts"}}{{template "admin_content_posts" .}}
{{else if eq .ActiveNav "comments"}}{{template "admin_content_comments" .}}
{{else if eq .ActiveNav "users"}}{{template "admin_content_users" .}}
{{else if eq .ActiveNav "settings"}}{{template "admin_content_settings" .}}
{{end}}
</main>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/legacy/js/app.js"></script>
{{if eq .ActiveNav "boards"}}{{template "admin_scripts_boards" .}}
{{else if eq .ActiveNav "posts"}}{{template "admin_scripts_posts" .}}
{{else if eq .ActiveNav "comments"}}{{template "admin_scripts_comments" .}}
{{else if eq .ActiveNav "users"}}{{template "admin_scripts_users" .}}
{{else if eq .ActiveNav "settings"}}{{template "admin_scripts_settings" .}}
{{end}}
</body>
</html>
{{end}}
{{define "admin_pagination"}}
{{if gt .TotalPages 1}}
<nav class="admin-pagination">
{{if gt .Page 1}}
<a class="btn btn-sm btn-outline-secondary" href="?page={{sub .Page 1}}{{if .Keyword}}&keyword={{.Keyword}}{{end}}">上一页</a>
{{end}}
<span class="admin-pagination-info">第 {{.Page}} / {{.TotalPages}} 页</span>
{{if lt .Page .TotalPages}}
<a class="btn btn-sm btn-outline-secondary" href="?page={{add .Page 1}}{{if .Keyword}}&keyword={{.Keyword}}{{end}}">下一页</a>
{{end}}
</nav>
{{end}}
{{end}}

View File

@@ -0,0 +1,49 @@
{{define "admin/login.html"}}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>后台登录 - 姜十三论坛</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/legacy/css/style.css" rel="stylesheet">
</head>
<body class="admin-login-page">
<div class="admin-login-card">
<div class="admin-login-mark"></div>
<h4 class="text-center mb-1">管理后台登录</h4>
<p class="login-slogan text-center mb-4">姜十三论坛 · 仅管理员可访问</p>
{{if eq .QueryBanned "1"}}
<div class="alert alert-warning small py-2">账号已被禁言,无法登录后台</div>
{{end}}
<form id="adminLoginForm">
<div class="mb-2">
<label class="form-label small">管理员账号</label>
<input type="text" name="username" class="form-control" placeholder="用户名" required autofocus>
</div>
<div class="mb-3">
<label class="form-label small">密码</label>
<input type="password" name="password" class="form-control" placeholder="密码" required>
</div>
<button type="submit" class="btn btn-success w-100" id="loginBtn">登录</button>
</form>
<p class="text-center mt-3 mb-0 small">
<a href="/" class="text-muted">← 返回论坛前台</a>
</p>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/legacy/js/app.js"></script>
<script>
document.getElementById('adminLoginForm').addEventListener('submit', function(e) {
e.preventDefault();
var btn = document.getElementById('loginBtn');
setBtnLoading(btn, true);
adminFetch('/admin/api/login', { method: 'POST', body: new FormData(this) })
.then(function() { location.href = '/admin/dashboard'; })
.catch(function(err) { alert(err.message); })
.finally(function() { setBtnLoading(btn, false); });
});
</script>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,67 @@
{{define "admin/posts.html"}}{{template "admin/layout" .}}{{end}}
{{define "admin_content_posts"}}
<div class="admin-page-head">
<div>
<h1>帖子管理</h1>
<p>置顶、删除帖子,共 {{.Total}} 篇</p>
</div>
<form class="admin-search-bar" method="get">
<input name="keyword" class="form-control form-control-sm" placeholder="搜索标题/内容..." value="{{.Keyword}}">
<button class="btn btn-sm btn-success">搜索</button>
{{if .Keyword}}<a href="/admin/posts" class="btn btn-sm btn-outline-secondary">清除</a>{{end}}
</form>
</div>
<div class="admin-card">
<div class="table-responsive">
<table class="table admin-table mb-0">
<thead>
<tr>
<th>ID</th><th>标题</th><th>板块</th><th>作者</th>
<th>置顶</th><th>点赞</th><th>浏览</th><th>时间</th><th width="180">操作</th>
</tr>
</thead>
<tbody>
{{range .Posts}}
<tr id="post-row-{{.ID}}">
<td>{{.ID}}</td>
<td class="text-truncate-cell">{{.Title}}</td>
<td>{{if .Board}}{{.Board.Name}}{{else}}-{{end}}</td>
<td>{{if .User}}{{.User.Nickname}}{{else}}-{{end}}</td>
<td>{{if .Pinned}}<span class="badge badge-pin"></span>{{else}}<span class="text-muted"></span>{{end}}</td>
<td>{{.LikeCount}}</td>
<td>{{.ViewCount}}</td>
<td>{{.CreatedAt.Format "01-02 15:04"}}</td>
<td>
<a href="/post/{{.ID}}" target="_blank" class="btn btn-sm btn-link">查看</a>
<button class="btn btn-sm btn-outline-warning" onclick="togglePin({{.ID}}, {{.Pinned}})">{{if .Pinned}}取消置顶{{else}}置顶{{end}}</button>
<button class="btn btn-sm btn-outline-danger" onclick="deletePost({{.ID}})">删除</button>
</td>
</tr>
{{else}}
<tr><td colspan="9" class="admin-empty">没有找到帖子</td></tr>
{{end}}
</tbody>
</table>
</div>
{{template "admin_pagination" .}}
</div>
{{end}}
{{define "admin_scripts_posts"}}
<script>
function togglePin(id, pinned) {
postForm('/admin/api/posts/' + id + '/pin', 'POST', { pinned: pinned ? 'false' : 'true' })
.then(function(d) { showToast(d.message); reloadSoon(); })
.catch(function(e) { showToast(e.message, 'danger'); });
}
function deletePost(id) {
adminConfirm('确定删除该帖子?相关评论也将一并删除。', function() {
adminFetch('/admin/api/posts/' + id, { method: 'DELETE' })
.then(function(d) { showToast(d.message); reloadSoon(); })
.catch(function(e) { showToast(e.message, 'danger'); });
});
}
</script>
{{end}}

View File

@@ -0,0 +1,56 @@
{{define "admin/settings.html"}}{{template "admin/layout" .}}{{end}}
{{define "admin_content_settings"}}
<div class="admin-page-head">
<div>
<h1>系统设置</h1>
<p>数据目录、敏感词配置与数据库备份</p>
</div>
</div>
<div class="row g-3">
<div class="col-lg-6">
<div class="admin-card">
<div class="admin-card-head">运行信息</div>
<div class="admin-card-body">
<div class="info-row"><div class="info-label">数据目录</div><div class="info-value"><code>{{.DataDir}}</code></div></div>
<div class="info-row"><div class="info-label">数据库文件</div><div class="info-value"><code>{{.DBPath}}</code></div></div>
<div class="info-row"><div class="info-label">监听端口</div><div class="info-value">{{.Port}}</div></div>
<div class="info-row"><div class="info-label">敏感词配置</div><div class="info-value"><code>{{.FilterPath}}</code></div></div>
<p class="text-muted small mt-3 mb-0">敏感词文件每行一个词,<code>#</code> 开头为注释,修改后需重启服务生效。</p>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="admin-card">
<div class="admin-card-head">数据库备份</div>
<div class="admin-card-body">
<p class="text-muted small">将 SQLite 数据库复制到数据目录,生成带时间戳的备份文件。</p>
<button class="btn btn-success" id="backupBtn" onclick="doBackup()">一键导出备份</button>
<div id="backupResult" class="mt-3"></div>
</div>
</div>
</div>
</div>
{{end}}
{{define "admin_scripts_settings"}}
<script>
function doBackup() {
var btn = document.getElementById('backupBtn');
setBtnLoading(btn, true);
adminFetch('/admin/api/backup', { method: 'POST' })
.then(function(d) {
showToast(d.message);
var html = '<div class="alert alert-success small mb-0">';
html += '<div>备份文件:<code>' + d.filename + '</code></div>';
if (d.download) {
html += '<a href="' + d.download + '" class="btn btn-sm btn-outline-success mt-2">下载备份文件</a>';
}
html += '<div class="text-muted mt-2">存储路径:' + d.path + '</div></div>';
document.getElementById('backupResult').innerHTML = html;
})
.catch(function(e) { showToast(e.message, 'danger'); })
.finally(function() { setBtnLoading(btn, false); });
}
</script>
{{end}}

View File

@@ -0,0 +1,62 @@
{{define "admin/users.html"}}{{template "admin/layout" .}}{{end}}
{{define "admin_content_users"}}
<div class="admin-page-head">
<div>
<h1>用户管理</h1>
<p>禁言违规用户,共 {{.Total}} 位注册用户</p>
</div>
</div>
<div class="admin-card">
<div class="table-responsive">
<table class="table admin-table mb-0">
<thead>
<tr><th>ID</th><th>用户名</th><th>昵称</th><th>角色</th><th>状态</th><th>注册时间</th><th width="120">操作</th></tr>
</thead>
<tbody>
{{range .Users}}
<tr>
<td>{{.ID}}</td>
<td>{{.Username}}</td>
<td>{{.Nickname}}</td>
<td>
{{if eq .Role "admin"}}
<span class="badge badge-admin">管理员</span>
{{else}}普通用户{{end}}
</td>
<td>
{{if .Banned}}<span class="badge badge-banned">已禁言</span>{{else}}<span class="text-success">正常</span>{{end}}
</td>
<td>{{.CreatedAt.Format "2006-01-02"}}</td>
<td>
{{if eq .Role "admin"}}
<span class="text-muted small"></span>
{{else if .Banned}}
<button class="btn btn-sm btn-success" onclick="banUser({{.ID}}, false)">解除禁言</button>
{{else}}
<button class="btn btn-sm btn-outline-warning" onclick="banUser({{.ID}}, true)">禁言</button>
{{end}}
</td>
</tr>
{{else}}
<tr><td colspan="7" class="admin-empty">暂无用户</td></tr>
{{end}}
</tbody>
</table>
</div>
{{template "admin_pagination" .}}
</div>
{{end}}
{{define "admin_scripts_users"}}
<script>
function banUser(id, banned) {
var msg = banned ? '确定禁言该用户?禁言后无法登录和发帖。' : '确定解除该用户的禁言?';
adminConfirm(msg, function() {
postForm('/admin/api/users/' + id + '/ban', 'POST', { banned: banned ? 'true' : 'false' })
.then(function(d) { showToast(d.message); reloadSoon(); })
.catch(function(e) { showToast(e.message, 'danger'); });
});
}
</script>
{{end}}

View File

@@ -0,0 +1,16 @@
{{define "board.html"}}{{template "layout" .}}{{end}}
{{define "content"}}
<nav aria-label="breadcrumb"><ol class="breadcrumb"><li class="breadcrumb-item"><a href="/">首页</a></li><li class="breadcrumb-item active">{{.Board.Name}}</li></ol></nav>
<h3>{{.Board.Name}}</h3>
<p class="text-muted">{{.Board.Description}}</p>
{{if .CurrentUser}}<a href="/post/new?board={{.Board.ID}}" class="btn btn-success btn-sm mb-3">在此板块发帖</a>{{end}}
{{range .Posts}}
<div class="card mb-2">
<div class="card-body py-2">
{{if .Pinned}}<span class="badge badge-pin me-1">置顶</span>{{end}}
<a href="/post/{{.ID}}" class="text-decoration-none fw-semibold">{{.Title}}</a>
<div class="small text-muted mt-1">{{.User.Nickname}} · {{.CreatedAt.Format "2006-01-02 15:04"}} · 👍 {{.LikeCount}}</div>
</div>
</div>
{{else}}<p class="text-muted">该板块暂无帖子</p>{{end}}
{{end}}

View File

@@ -0,0 +1,7 @@
{{define "error.html"}}{{template "layout" .}}{{end}}
{{define "content"}}
<div class="text-center py-5">
<h3>{{.Message}}</h3>
<a href="/" class="btn btn-primary mt-3">返回首页</a>
</div>
{{end}}

View File

@@ -0,0 +1,10 @@
{{define "favorites.html"}}{{template "layout" .}}{{end}}
{{define "content"}}
<h3>我的收藏</h3>
{{range .Favorites}}
<div class="card mb-2"><div class="card-body py-2">
<a href="/post/{{.Post.ID}}" class="fw-semibold text-decoration-none">{{.Post.Title}}</a>
<div class="small text-muted">{{.Post.Board.Name}} · 收藏于 {{.CreatedAt.Format "2006-01-02"}}</div>
</div></div>
{{else}}<p class="text-muted">暂无收藏</p>{{end}}
{{end}}

View File

@@ -0,0 +1,34 @@
{{define "index.html"}}{{template "layout" .}}{{end}}
{{define "content"}}
<div class="row">
<div class="col-lg-8">
<h4 class="mb-3">最新帖子</h4>
{{range .Posts}}
<div class="card mb-2">
<div class="card-body py-2">
{{if .Pinned}}<span class="badge badge-pin me-1">置顶</span>{{end}}
<a href="/post/{{.ID}}" class="text-decoration-none fw-semibold">{{.Title}}</a>
<div class="small text-muted mt-1">
{{.Board.Name}} · {{.User.Nickname}} · {{.CreatedAt.Format "2006-01-02 15:04"}} · 👍 {{.LikeCount}} · 👁 {{.ViewCount}}
</div>
</div>
</div>
{{else}}
<p class="text-muted">暂无帖子,快来发帖吧!</p>
{{end}}
</div>
<div class="col-lg-4">
<h5>板块列表</h5>
<div class="list-group">
{{range .Boards}}
<a href="/board/{{.ID}}" class="list-group-item list-group-item-action">
<strong>{{.Name}}</strong>
<small class="d-block text-muted">{{.Description}}</small>
</a>
{{else}}
<p class="text-muted small">管理员尚未创建板块</p>
{{end}}
</div>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,56 @@
{{define "layout"}}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} - 姜十三论坛</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/assets/css/style.css" rel="stylesheet">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark" style="background:var(--primary)">
<div class="container">
<a class="navbar-brand" href="/">姜十三论坛 <span class="en fs-6 opacity-75">Jiang13 Forum</span></a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#nav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="nav">
<ul class="navbar-nav me-auto">
<li class="nav-item"><a class="nav-link" href="/">首页</a></li>
{{if .CurrentUser}}
<li class="nav-item"><a class="nav-link" href="/post/new">发帖</a></li>
<li class="nav-item"><a class="nav-link" href="/favorites">我的收藏</a></li>
{{end}}
{{if .IsAdmin}}<li class="nav-item"><a class="nav-link" href="/admin/dashboard">管理后台</a></li>{{end}}
</ul>
<ul class="navbar-nav">
{{if .CurrentUser}}
<li class="nav-item"><a class="nav-link" href="/profile">{{.CurrentUser.Nickname}}</a></li>
<li class="nav-item"><a class="nav-link" href="#" onclick="logout()">退出</a></li>
{{else}}
<li class="nav-item"><a class="nav-link" href="/login">登录</a></li>
<li class="nav-item"><a class="nav-link" href="/register">注册</a></li>
{{end}}
</ul>
</div>
</div>
</nav>
<main class="container py-4 flex-grow-1">
{{template "content" .}}
</main>
<footer class="site-footer text-center">
<div class="container">&copy; 2026 姜十三论坛 Jiang13 Forum · 拾三一隅,自在交流</div>
</footer>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div id="toast" class="toast" role="alert"><div class="toast-body"></div></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/assets/js/app.js"></script>
<script>
function logout(){ fetch('/api/logout',{method:'POST',credentials:'same-origin'}).then(()=>location.href='/'); }
</script>
{{block "scripts" .}}{{end}}
</body>
</html>
{{end}}

View File

@@ -0,0 +1,36 @@
{{define "login.html"}}{{template "layout" .}}{{end}}
{{define "content"}}
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card shadow-sm">
<div class="card-body p-4">
<h3 class="text-center mb-1">登录</h3>
<p class="text-center login-slogan mb-4">拾三一隅,自在交流</p>
<form id="loginForm">
<div class="mb-3">
<label class="form-label">用户名</label>
<input type="text" name="username" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">密码</label>
<input type="password" name="password" class="form-control" required>
</div>
<button type="submit" class="btn btn-success w-100">登录</button>
</form>
<p class="text-center mt-3 mb-0">还没有账号?<a href="/register">立即注册</a></p>
</div>
</div>
</div>
</div>
{{end}}
{{define "scripts"}}
<script>
document.getElementById('loginForm').addEventListener('submit', function(e){
e.preventDefault();
apiPost('/api/login', this, true).then(d=>{
if(d.error){ showToast(d.error,'danger'); return; }
location.href='/';
});
});
</script>
{{end}}

View File

@@ -0,0 +1,72 @@
{{define "post.html"}}{{template "layout" .}}{{end}}
{{define "content"}}
<nav aria-label="breadcrumb"><ol class="breadcrumb"><li class="breadcrumb-item"><a href="/">首页</a></li><li class="breadcrumb-item"><a href="/board/{{.Post.BoardID}}">{{.Post.Board.Name}}</a></li><li class="breadcrumb-item active">帖子</li></ol></nav>
<div class="card mb-4">
<div class="card-body">
<h3>{{if .Post.Pinned}}<span class="badge badge-pin me-1">置顶</span>{{end}}{{.Post.Title}}</h3>
<div class="small text-muted mb-3">
<a href="/user/{{.Post.UserID}}">{{.Post.User.Nickname}}</a> · {{.Post.CreatedAt.Format "2006-01-02 15:04"}} · 👁 {{.Post.ViewCount}}
{{if .Post.Tags}}· 标签:{{.Post.Tags}}{{end}}
</div>
<div class="post-content">{{safeHTML .Post.Content}}</div>
<div class="mt-3 d-flex gap-2 flex-wrap">
{{if .CurrentUser}}
<button class="btn btn-outline-primary btn-sm" id="likeBtn" onclick="toggleLike()">{{if .Liked}}已点赞{{else}}点赞{{end}}</button>
<button class="btn btn-outline-warning btn-sm" id="favBtn" onclick="toggleFav()">{{if .Favorited}}已收藏{{else}}收藏{{end}}</button>
{{if or (eq .CurrentUserID .Post.UserID) .IsAdmin}}
<a href="/post/{{.Post.ID}}/edit" class="btn btn-outline-secondary btn-sm">编辑</a>
<button class="btn btn-outline-danger btn-sm" onclick="deletePost()">删除</button>
{{end}}
{{if .IsAdmin}}
<button class="btn btn-outline-dark btn-sm" onclick="pinPost()">{{if .Post.Pinned}}取消置顶{{else}}置顶{{end}}</button>
{{end}}
{{else}}<a href="/login" class="btn btn-outline-primary btn-sm">登录后互动</a>{{end}}
</div>
</div>
</div>
<h5>评论 ({{len .Comments}})</h5>
{{range .Comments}}
<div class="card mb-2 comment-floor">
<div class="card-body py-2">
<div class="d-flex justify-content-between">
<strong>#{{.Floor}} {{.User.Nickname}}</strong>
<small class="text-muted">{{.CreatedAt.Format "01-02 15:04"}}</small>
</div>
{{if .ReplyUser}}<div class="small text-muted">回复 #{{.ReplyUser.Floor}} {{.ReplyUser.User.Nickname}}</div>{{end}}
<div class="mt-1">{{.Content}}</div>
{{if $.CurrentUser}}
<button class="btn btn-link btn-sm p-0" onclick="replyTo({{.ID}},{{.Floor}},'{{.User.Nickname}}')">回复</button>
{{if or (eq $.CurrentUserID .UserID) $.IsAdmin}}
<button class="btn btn-link btn-sm p-0 text-danger" onclick="deleteComment({{.ID}})">删除</button>
{{end}}
{{end}}
</div>
</div>
{{end}}
{{if .CurrentUser}}
<div class="card mt-3">
<div class="card-body">
<form id="commentForm">
<div id="replyHint" class="small text-muted mb-2" style="display:none"></div>
<input type="hidden" name="reply_to" id="replyTo">
<textarea name="content" class="form-control mb-2" rows="3" placeholder="写下你的评论..." required></textarea>
<button type="submit" class="btn btn-success btn-sm">发表评论</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="cancelReply()" id="cancelReplyBtn" style="display:none">取消回复</button>
</form>
</div>
</div>
{{end}}
{{end}}
{{define "scripts"}}
<script>
const postId={{.Post.ID}};
function toggleLike(){ fetch('/api/posts/'+postId+'/like',{method:'POST',credentials:'same-origin'}).then(r=>r.json()).then(d=>{ if(d.error){showToast(d.error,'danger');return;} document.getElementById('likeBtn').textContent=d.liked?'已点赞':'点赞'; }); }
function toggleFav(){ fetch('/api/posts/'+postId+'/favorite',{method:'POST',credentials:'same-origin'}).then(r=>r.json()).then(d=>{ if(d.error){showToast(d.error,'danger');return;} document.getElementById('favBtn').textContent=d.favorited?'已收藏':'收藏'; }); }
function deletePost(){ confirmAction('确定删除此帖?',()=>{ fetch('/api/posts/'+postId,{method:'DELETE',credentials:'same-origin'}).then(r=>r.json()).then(d=>{ if(d.error){showToast(d.error,'danger');return;} location.href='/board/{{.Post.BoardID}}'; }); }); }
function pinPost(){ fetch('/admin/api/posts/'+postId+'/pin',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'pinned={{if .Post.Pinned}}false{{else}}true{{end}}'}).then(r=>r.json()).then(()=>location.reload()); }
function replyTo(id,floor,name){ document.getElementById('replyTo').value=id; document.getElementById('replyHint').style.display='block'; document.getElementById('replyHint').textContent='回复 #'+floor+' '+name; document.getElementById('cancelReplyBtn').style.display='inline-block'; }
function cancelReply(){ document.getElementById('replyTo').value=''; document.getElementById('replyHint').style.display='none'; document.getElementById('cancelReplyBtn').style.display='none'; }
function deleteComment(id){ confirmAction('确定删除?',()=>{ fetch('/api/comments/'+id,{method:'DELETE',credentials:'same-origin'}).then(()=>location.reload()); }); }
document.getElementById('commentForm')?.addEventListener('submit',function(e){ e.preventDefault(); apiPost('/api/posts/'+postId+'/comments',this,true).then(d=>{ if(d.error){showToast(d.error,'danger');return;} location.reload(); }); });
</script>
{{end}}

View File

@@ -0,0 +1,31 @@
{{define "post_edit.html"}}{{template "layout" .}}{{end}}
{{define "content"}}
<h3>编辑帖子</h3>
<form id="editForm">
<div class="mb-3">
<label class="form-label">标题</label>
<input type="text" name="title" class="form-control" value="{{.Post.Title}}" required>
</div>
<div class="mb-3">
<label class="form-label">标签</label>
<input type="text" name="tags" class="form-control" value="{{.Post.Tags}}">
</div>
<div class="mb-3">
<label class="form-label">内容</label>
<textarea name="content" class="form-control" rows="12" required>{{.Post.Content}}</textarea>
</div>
<button type="submit" class="btn btn-success">保存</button>
<a href="/post/{{.Post.ID}}" class="btn btn-secondary">取消</a>
</form>
{{end}}
{{define "scripts"}}
<script>
document.getElementById('editForm').addEventListener('submit',function(e){
e.preventDefault();
fetch('/api/posts/{{.Post.ID}}',{method:'PUT',credentials:'same-origin',body:new FormData(this)}).then(r=>r.json()).then(d=>{
if(d.error){showToast(d.error,'danger');return;}
location.href='/post/{{.Post.ID}}';
});
});
</script>
{{end}}

View File

@@ -0,0 +1,37 @@
{{define "post_new.html"}}{{template "layout" .}}{{end}}
{{define "content"}}
<h3>发布新帖</h3>
<form id="postForm">
<div class="mb-3">
<label class="form-label">选择板块</label>
<select name="board_id" class="form-select" required>
{{range .Boards}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
</div>
<div class="mb-3">
<label class="form-label">标题</label>
<input type="text" name="title" class="form-control" required maxlength="256">
</div>
<div class="mb-3">
<label class="form-label">标签(逗号分隔)</label>
<input type="text" name="tags" class="form-control" placeholder="Go,技术,交流">
</div>
<div class="mb-3">
<label class="form-label">内容(支持 HTML 富文本)</label>
<textarea name="content" class="form-control" rows="12" required></textarea>
<div class="form-text">可使用 &lt;b&gt;&lt;i&gt;&lt;a&gt;&lt;img&gt; 等 HTML 标签</div>
</div>
<button type="submit" class="btn btn-success">发布</button>
</form>
{{end}}
{{define "scripts"}}
<script>
document.getElementById('postForm').addEventListener('submit',function(e){
e.preventDefault();
apiPost('/api/posts',this,true).then(d=>{
if(d.error){showToast(d.error,'danger');return;}
location.href='/post/'+d.post_id;
});
});
</script>
{{end}}

View File

@@ -0,0 +1,34 @@
{{define "profile.html"}}{{template "layout" .}}{{end}}
{{define "content"}}
<div class="row">
<div class="col-md-4 text-center mb-4">
<img src="{{if .ProfileUser.Avatar}}{{.ProfileUser.Avatar}}{{else}}https://via.placeholder.com/128{{end}}" class="avatar-md mb-2" alt="avatar">
<h4>{{.ProfileUser.Nickname}}</h4>
<p class="text-muted">@{{.ProfileUser.Username}}</p>
<form id="avatarForm" enctype="multipart/form-data">
<input type="file" name="avatar" accept="image/*" class="form-control form-control-sm mb-2">
<button type="submit" class="btn btn-outline-primary btn-sm">更换头像</button>
</form>
</div>
<div class="col-md-8">
<div class="card mb-3"><div class="card-header">修改昵称</div><div class="card-body">
<form id="nickForm"><input type="text" name="nickname" class="form-control mb-2" value="{{.ProfileUser.Nickname}}">
<button class="btn btn-success btn-sm">保存昵称</button></form>
</div></div>
<div class="card"><div class="card-header">修改密码</div><div class="card-body">
<form id="pwdForm">
<input type="password" name="old_password" class="form-control mb-2" placeholder="原密码" required>
<input type="password" name="new_password" class="form-control mb-2" placeholder="新密码至少6位" required>
<button class="btn btn-success btn-sm">修改密码</button>
</form>
</div></div>
</div>
</div>
{{end}}
{{define "scripts"}}
<script>
document.getElementById('nickForm').addEventListener('submit',function(e){ e.preventDefault(); apiPost('/api/profile/nickname',this,true).then(d=>{ showToast(d.error||d.message,d.error?'danger':'success'); }); });
document.getElementById('pwdForm').addEventListener('submit',function(e){ e.preventDefault(); apiPost('/api/profile/password',this,true).then(d=>{ showToast(d.error||d.message,d.error?'danger':'success'); }); });
document.getElementById('avatarForm').addEventListener('submit',function(e){ e.preventDefault(); fetch('/api/profile/avatar',{method:'POST',credentials:'same-origin',body:new FormData(this)}).then(r=>r.json()).then(d=>{ if(d.error){showToast(d.error,'danger');return;} location.reload(); }); });
</script>
{{end}}

View File

@@ -0,0 +1,40 @@
{{define "register.html"}}{{template "layout" .}}{{end}}
{{define "content"}}
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card shadow-sm">
<div class="card-body p-4">
<h3 class="text-center mb-4">注册账号</h3>
<p class="text-center text-muted small mb-3">本站首个注册用户将自动成为管理员</p>
<form id="regForm">
<div class="mb-3">
<label class="form-label">用户名3-32位字母数字下划线</label>
<input type="text" name="username" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">昵称</label>
<input type="text" name="nickname" class="form-control">
</div>
<div class="mb-3">
<label class="form-label">密码至少6位</label>
<input type="password" name="password" class="form-control" required>
</div>
<button type="submit" class="btn btn-success w-100">注册</button>
</form>
<p class="text-center mt-3 mb-0">已有账号?<a href="/login">去登录</a></p>
</div>
</div>
</div>
</div>
{{end}}
{{define "scripts"}}
<script>
document.getElementById('regForm').addEventListener('submit', function(e){
e.preventDefault();
apiPost('/api/register', this, true).then(d=>{
if(d.error){ showToast(d.error,'danger'); return; }
showToast('注册成功'); location.href='/';
});
});
</script>
{{end}}

View File

@@ -0,0 +1,8 @@
{{define "user_profile.html"}}{{template "layout" .}}{{end}}
{{define "content"}}
<div class="text-center py-4">
<img src="{{if .ProfileUser.Avatar}}{{.ProfileUser.Avatar}}{{else}}https://via.placeholder.com/128{{end}}" class="avatar-md mb-2">
<h3>{{.ProfileUser.Nickname}}</h3>
<p class="text-muted">@{{.ProfileUser.Username}} · 注册于 {{.ProfileUser.CreatedAt.Format "2006-01-02"}}</p>
</div>
{{end}}