初始提交:姜十三论坛 Jiang13 Forum
轻量自用论坛,Go 单二进制 + React SPA 内嵌 + SQLite。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
73
embed_static/embed.go
Normal file
73
embed_static/embed.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package embed_static
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
//go:embed static/*
|
||||
var staticFS embed.FS
|
||||
|
||||
//go:embed templates/*
|
||||
var templatesFS embed.FS
|
||||
|
||||
// SetupEmbed 配置内嵌资源:SPA 前端 + 后台 HTML 模板
|
||||
func SetupEmbed(r *gin.Engine) error {
|
||||
tmpl, err := LoadTemplates()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.SetHTMLTemplate(tmpl)
|
||||
|
||||
// React SPA 构建产物(Vite)
|
||||
if sub, err := fs.Sub(staticFS, "static/spa/assets"); err == nil {
|
||||
r.GET("/assets/*filepath", gin.WrapH(http.StripPrefix("/assets", http.FileServer(http.FS(sub)))))
|
||||
}
|
||||
|
||||
// 后台管理遗留静态资源
|
||||
if sub, err := fs.Sub(staticFS, "static/legacy"); err == nil {
|
||||
r.GET("/legacy/*filepath", gin.WrapH(http.StripPrefix("/legacy", http.FileServer(http.FS(sub)))))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ServeSPA 返回 React SPA 入口
|
||||
func ServeSPA(c *gin.Context) {
|
||||
data, err := staticFS.ReadFile("static/spa/index.html")
|
||||
if err != nil {
|
||||
c.String(http.StatusNotFound, "前端未构建,请运行: cd frontend && npm run build")
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
|
||||
}
|
||||
|
||||
// IsSPARoute 判断是否应由 SPA 处理
|
||||
func IsSPARoute(path string) bool {
|
||||
if strings.HasPrefix(path, "/api") ||
|
||||
strings.HasPrefix(path, "/admin") ||
|
||||
strings.HasPrefix(path, "/uploads") ||
|
||||
strings.HasPrefix(path, "/legacy") ||
|
||||
strings.HasPrefix(path, "/assets") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func LoadTemplates() (*template.Template, error) {
|
||||
sub, err := fs.Sub(templatesFS, "templates")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tmpl := template.New("").Funcs(template.FuncMap{
|
||||
"safeHTML": func(s string) template.HTML { return template.HTML(s) },
|
||||
"add": func(a, b int) int { return a + b },
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
})
|
||||
return tmpl.ParseFS(sub, "*.html", "admin/*.html")
|
||||
}
|
||||
80
embed_static/static/css/style.css
Normal file
80
embed_static/static/css/style.css
Normal file
@@ -0,0 +1,80 @@
|
||||
/* 姜十三论坛全局样式 */
|
||||
:root {
|
||||
--primary: #2c5530;
|
||||
--primary-light: #3d7a44;
|
||||
--accent: #c9a227;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
margin-top: auto;
|
||||
background: #212529;
|
||||
color: #adb5bd;
|
||||
padding: 1.5rem 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.post-content {
|
||||
line-height: 1.8;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.post-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.comment-floor {
|
||||
border-left: 3px solid var(--primary);
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.avatar-sm {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.avatar-md {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.badge-pin {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.admin-sidebar .nav-link.active {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.admin-sidebar .nav-link {
|
||||
color: #333;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.login-slogan {
|
||||
font-style: italic;
|
||||
color: #6c757d;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.navbar-brand span.en { display: none; }
|
||||
}
|
||||
28
embed_static/static/js/app.js
Normal file
28
embed_static/static/js/app.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// 姜十三论坛前端通用脚本
|
||||
function apiPost(url, data, isForm) {
|
||||
const opts = { method: 'POST', credentials: 'same-origin' };
|
||||
if (isForm) {
|
||||
opts.body = new FormData(data instanceof HTMLFormElement ? data : undefined);
|
||||
if (!(data instanceof HTMLFormElement)) {
|
||||
const fd = new FormData();
|
||||
for (const k in data) fd.append(k, data[k]);
|
||||
opts.body = fd;
|
||||
}
|
||||
} else {
|
||||
opts.headers = { 'Content-Type': 'application/json' };
|
||||
opts.body = JSON.stringify(data);
|
||||
}
|
||||
return fetch(url, opts).then(r => r.json());
|
||||
}
|
||||
|
||||
function showToast(msg, type) {
|
||||
const el = document.getElementById('toast');
|
||||
if (!el) { alert(msg); return; }
|
||||
el.className = 'toast align-items-center text-bg-' + (type || 'success') + ' border-0 show';
|
||||
el.querySelector('.toast-body').textContent = msg;
|
||||
new bootstrap.Toast(el).show();
|
||||
}
|
||||
|
||||
function confirmAction(msg, callback) {
|
||||
if (confirm(msg)) callback();
|
||||
}
|
||||
499
embed_static/static/legacy/css/style.css
Normal file
499
embed_static/static/legacy/css/style.css
Normal file
@@ -0,0 +1,499 @@
|
||||
/* 姜十三论坛 - 前台 + 管理后台样式 */
|
||||
:root {
|
||||
--primary: #1a7f4b;
|
||||
--primary-dark: #156b3f;
|
||||
--primary-light: #e8f5ee;
|
||||
--accent: #c9a227;
|
||||
--admin-sidebar-w: 220px;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background-color: #f4f6f8;
|
||||
color: #1d2129;
|
||||
}
|
||||
|
||||
/* ===== 管理后台 ===== */
|
||||
.admin-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background: #f2f3f5;
|
||||
}
|
||||
|
||||
.admin-topbar {
|
||||
height: 56px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.1);
|
||||
}
|
||||
|
||||
.admin-topbar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-topbar-mark {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255,.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.admin-topbar-title {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.admin-topbar-sub {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
opacity: .75;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.admin-topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-topbar-user {
|
||||
font-size: 13px;
|
||||
opacity: .9;
|
||||
padding-right: 4px;
|
||||
border-right: 1px solid rgba(255,255,255,.25);
|
||||
margin-right: 4px;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
width: var(--admin-sidebar-w);
|
||||
flex-shrink: 0;
|
||||
background: #fff;
|
||||
border-right: 1px solid #e5e6eb;
|
||||
padding: 20px 14px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.admin-sidebar-section {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #86909c;
|
||||
padding: 12px 12px 6px;
|
||||
letter-spacing: .06em;
|
||||
}
|
||||
|
||||
.admin-sidebar-section:first-child {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.admin-sidebar .nav-link {
|
||||
display: block;
|
||||
color: #4e5969;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 2px;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
transition: background .15s, color .15s;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.admin-sidebar .nav-link:hover {
|
||||
background: #f7f8fa;
|
||||
color: #1d2129;
|
||||
}
|
||||
|
||||
.admin-sidebar .nav-link.active {
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
border-left-color: var(--primary);
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 28px 32px;
|
||||
overflow-y: auto;
|
||||
max-width: 1280px;
|
||||
}
|
||||
|
||||
.admin-page-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin-page-head h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 6px;
|
||||
color: #1d2129;
|
||||
}
|
||||
|
||||
.admin-page-head p {
|
||||
margin: 0;
|
||||
color: #86909c;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.admin-search-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.admin-search-bar .form-control {
|
||||
width: 240px;
|
||||
border-color: #e5e6eb;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.admin-stat-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
transition: box-shadow .2s;
|
||||
}
|
||||
|
||||
.admin-stat-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.06);
|
||||
}
|
||||
|
||||
.admin-stat-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.admin-stat-users .admin-stat-icon { background: #e8f3ff; color: #3491fa; }
|
||||
.admin-stat-posts .admin-stat-icon { background: #e8f5ee; color: var(--primary); }
|
||||
.admin-stat-boards .admin-stat-icon { background: #fff7e8; color: #ff7d00; }
|
||||
.admin-stat-comments .admin-stat-icon { background: #f5e8ff; color: #722ed1; }
|
||||
.admin-stat-online .admin-stat-icon { background: #e8ffea; color: #00b42a; }
|
||||
|
||||
.admin-stat-users .value { color: #3491fa; }
|
||||
.admin-stat-posts .value { color: var(--primary); }
|
||||
.admin-stat-boards .value { color: #ff7d00; }
|
||||
.admin-stat-comments .value { color: #722ed1; }
|
||||
.admin-stat-online .value { color: #00b42a; }
|
||||
|
||||
.admin-stat-card .value {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.admin-stat-card .label {
|
||||
font-size: 13px;
|
||||
color: #86909c;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.admin-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.04);
|
||||
}
|
||||
|
||||
.admin-card-head {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f2f3f5;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: #1d2129;
|
||||
}
|
||||
|
||||
.admin-card-link {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
|
||||
.admin-card-link:hover {
|
||||
opacity: .75;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.admin-card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
--bs-table-hover-bg: #f7f8fa;
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
background: #fafbfc;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
color: #4e5969;
|
||||
font-size: 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e5e6eb;
|
||||
}
|
||||
|
||||
.admin-table td {
|
||||
vertical-align: middle;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f2f3f5;
|
||||
color: #1d2129;
|
||||
}
|
||||
|
||||
.admin-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.admin-table tbody tr:hover td {
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.admin-table .btn-link {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.admin-table .btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.text-truncate-cell {
|
||||
max-width: 360px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-pin {
|
||||
background: #ff7d00;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.badge-admin {
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-banned {
|
||||
background: #ffece8;
|
||||
color: #f53f3f;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.admin-empty {
|
||||
text-align: center;
|
||||
padding: 48px 20px;
|
||||
color: #86909c;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.admin-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #f2f3f5;
|
||||
}
|
||||
|
||||
.admin-pagination-info {
|
||||
font-size: 13px;
|
||||
color: #86909c;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.admin-body .btn-success {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.admin-body .btn-success:hover {
|
||||
background: var(--primary-dark);
|
||||
border-color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.admin-body .btn-outline-success {
|
||||
color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.admin-body .btn-outline-success:hover {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.admin-body .form-control:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(26,127,75,.12);
|
||||
}
|
||||
|
||||
.admin-body .form-label {
|
||||
color: #4e5969;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.admin-login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(160deg, #f2f3f5 0%, #e8f5ee 60%, #d4edda 100%);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.admin-login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 16px;
|
||||
padding: 36px 32px;
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,.08);
|
||||
}
|
||||
|
||||
.admin-login-mark {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 14px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 18px;
|
||||
box-shadow: 0 4px 12px rgba(26,127,75,.25);
|
||||
}
|
||||
|
||||
.login-slogan {
|
||||
color: #86909c;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f2f3f5;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
.info-row .info-label { width: 100px; color: #86909c; flex-shrink: 0; }
|
||||
.info-row .info-value { flex: 1; word-break: break-all; color: #1d2129; }
|
||||
|
||||
#adminToast.toast {
|
||||
background: #1d2129;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.15);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.admin-stat-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-shell { flex-direction: column; }
|
||||
.admin-sidebar {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #e5e6eb;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.admin-sidebar-section { display: none; }
|
||||
.admin-sidebar .nav-link {
|
||||
padding: 7px 12px;
|
||||
font-size: 13px;
|
||||
border-left: none;
|
||||
}
|
||||
.admin-sidebar .nav-link.active {
|
||||
border-left: none;
|
||||
}
|
||||
.admin-main { padding: 16px; max-width: none; }
|
||||
.admin-stat-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.admin-stat-card { padding: 14px 16px; }
|
||||
.admin-stat-card .value { font-size: 22px; }
|
||||
.admin-topbar-user { display: none; }
|
||||
.admin-search-bar .form-control { width: 160px; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.admin-stat-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 旧版前台兼容 ===== */
|
||||
.navbar-brand { font-weight: 700; letter-spacing: 0.05em; }
|
||||
.post-content { line-height: 1.8; word-break: break-word; }
|
||||
.post-content img { max-width: 100%; height: auto; }
|
||||
.avatar-sm { width: 36px; height: 36px; object-fit: cover; border-radius: 50%; }
|
||||
79
embed_static/static/legacy/js/app.js
Normal file
79
embed_static/static/legacy/js/app.js
Normal file
@@ -0,0 +1,79 @@
|
||||
// 姜十三论坛 - 管理后台通用脚本
|
||||
|
||||
async function adminFetch(url, opts) {
|
||||
opts = opts || {};
|
||||
var res = await fetch(url, Object.assign({ credentials: 'same-origin' }, opts));
|
||||
var data;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch (e) {
|
||||
throw new Error('服务器响应异常,请重新登录后再试');
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || '请求失败');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function showToast(msg, type) {
|
||||
var el = document.getElementById('adminToast');
|
||||
if (!el) {
|
||||
alert(msg);
|
||||
return;
|
||||
}
|
||||
el.className = 'toast align-items-center text-bg-' + (type || 'success') + ' border-0 show';
|
||||
el.querySelector('.toast-body').textContent = msg;
|
||||
bootstrap.Toast.getOrCreateInstance(el, { delay: 2800 }).show();
|
||||
}
|
||||
|
||||
function adminConfirm(msg, fn) {
|
||||
if (confirm(msg)) fn();
|
||||
}
|
||||
|
||||
function setBtnLoading(btn, loading) {
|
||||
if (!btn) return;
|
||||
btn.disabled = loading;
|
||||
if (loading) {
|
||||
btn.dataset.originalHtml = btn.innerHTML;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
|
||||
} else if (btn.dataset.originalHtml) {
|
||||
btn.innerHTML = btn.dataset.originalHtml;
|
||||
}
|
||||
}
|
||||
|
||||
function adminLogout() {
|
||||
adminFetch('/admin/api/logout', { method: 'POST' })
|
||||
.then(function () { location.href = '/admin/login'; })
|
||||
.catch(function (e) { showToast(e.message, 'danger'); });
|
||||
}
|
||||
|
||||
function postForm(url, method, fields) {
|
||||
var fd = new FormData();
|
||||
Object.keys(fields).forEach(function (k) { fd.append(k, fields[k]); });
|
||||
return adminFetch(url, { method: method, body: fd });
|
||||
}
|
||||
|
||||
function reloadSoon() {
|
||||
setTimeout(function () { location.reload(); }, 600);
|
||||
}
|
||||
|
||||
// 兼容旧模板
|
||||
function apiPost(url, data, isForm) {
|
||||
var opts = { method: 'POST', credentials: 'same-origin' };
|
||||
if (isForm) {
|
||||
opts.body = new FormData(data instanceof HTMLFormElement ? data : undefined);
|
||||
if (!(data instanceof HTMLFormElement)) {
|
||||
var fd = new FormData();
|
||||
for (var k in data) fd.append(k, data[k]);
|
||||
opts.body = fd;
|
||||
}
|
||||
} else {
|
||||
opts.headers = { 'Content-Type': 'application/json' };
|
||||
opts.body = JSON.stringify(data);
|
||||
}
|
||||
return fetch(url, opts).then(function (r) { return r.json(); });
|
||||
}
|
||||
|
||||
function confirmAction(msg, callback) {
|
||||
adminConfirm(msg, callback);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
53
embed_static/static/spa/assets/ComposePage-Dv2Wt-UO.js
Normal file
53
embed_static/static/spa/assets/ComposePage-Dv2Wt-UO.js
Normal file
File diff suppressed because one or more lines are too long
1
embed_static/static/spa/assets/FavoritesPage-Dvc9bR9_.js
Normal file
1
embed_static/static/spa/assets/FavoritesPage-Dvc9bR9_.js
Normal file
@@ -0,0 +1 @@
|
||||
import{e as j,r as n,j as s}from"./react-vendor-CgzNJrV9.js";import{u as f,a as g,B as p}from"./index-474mTIgd.js";import{S as N}from"./spinner-DH1syOsQ.js";import{n as v}from"./notify-Zesh5czd.js";import{f as y}from"./content-CKr4Ur51.js";import{A as k}from"./ui-vendor-CdH1UOKW.js";function E(){const t=j(),{user:a,loading:r}=f(),[i,h]=n.useState([]),[u,x]=n.useState(!0);return n.useEffect(()=>{if(!r){if(!a){t("/login");return}g.favorites().then(e=>h(Array.isArray(e.favorites)?e.favorites:[])).catch(e=>v.error(e.message)).finally(()=>x(!1))}},[a,r,t]),r||u?s.jsx("div",{className:"flex justify-center py-16",children:s.jsx(N,{size:"lg"})}):a?s.jsx("div",{className:"page-wrap",children:s.jsxs("div",{className:"page-inner-wide",children:[s.jsxs(p,{variant:"ghost",className:"mb-3",onClick:()=>t("/"),children:[s.jsx(k,{}),"返回"]}),s.jsx("h1",{className:"page-title",children:"我的收藏"}),s.jsxs("p",{className:"page-desc",children:["共 ",i.length," 篇收藏帖子"]}),i.length===0?s.jsxs("div",{className:"empty-state",children:[s.jsx("p",{children:"还没有收藏任何帖子"}),s.jsx(p,{onClick:()=>t("/"),children:"去逛逛"})]}):s.jsx("div",{className:"content-surface",children:i.map(e=>{var o,l,c,d,m;return s.jsx("div",{className:"post-row",onClick:()=>t(`/post/${e.post_id}`),children:s.jsxs("div",{className:"post-body",children:[s.jsx("div",{className:"post-title",children:((o=e.post)==null?void 0:o.title)||"帖子已删除"}),s.jsxs("div",{className:"post-meta",children:[((c=(l=e.post)==null?void 0:l.board)==null?void 0:c.name)&&s.jsx("span",{children:e.post.board.name}),((m=(d=e.post)==null?void 0:d.user)==null?void 0:m.nickname)&&s.jsx("span",{children:e.post.user.nickname}),s.jsxs("span",{children:["收藏于 ",y(e.created_at)]})]})]})},e.id)})})]})}):null}export{E as default};
|
||||
1
embed_static/static/spa/assets/HomePage-D-Yzai0_.js
Normal file
1
embed_static/static/spa/assets/HomePage-D-Yzai0_.js
Normal file
File diff suppressed because one or more lines are too long
1
embed_static/static/spa/assets/LoginPage-XRVgE8q8.js
Normal file
1
embed_static/static/spa/assets/LoginPage-XRVgE8q8.js
Normal file
@@ -0,0 +1 @@
|
||||
import{e as f,r as g,j as s,L as b}from"./react-vendor-CgzNJrV9.js";import{u as w,a as F,F as y,b as t,c as n,d as l,e as c,I as i,f as m,o as N,s as d}from"./form-BzHbu9bJ.js";import{u as v,B as L,a as S}from"./index-474mTIgd.js";import{n as u}from"./notify-Zesh5czd.js";import"./ui-vendor-CdH1UOKW.js";const C=N({username:d().min(1,"请输入用户名"),password:d().min(1,"请输入密码")});function z(){const x=f(),{refresh:p}=v(),[h,a]=g.useState(!1),r=w({resolver:F(C),defaultValues:{username:"",password:""}}),j=async e=>{a(!0);try{await S.login(e.username,e.password),await p(),u.success("登录成功"),x("/",{replace:!0})}catch(o){u.error(o instanceof Error?o.message:"登录失败")}finally{a(!1)}};return s.jsx("div",{className:"auth-page",children:s.jsxs("div",{className:"auth-box",children:[s.jsx("div",{className:"logo-mark",children:"姜"}),s.jsx("h1",{children:"登录姜十三论坛"}),s.jsx("p",{className:"subtitle",children:"拾三一隅,自在交流"}),s.jsx(y,{...r,children:s.jsxs("form",{onSubmit:r.handleSubmit(j),className:"space-y-4",children:[s.jsx(t,{control:r.control,name:"username",render:({field:e})=>s.jsxs(n,{children:[s.jsx(l,{children:"用户名"}),s.jsx(c,{children:s.jsx(i,{placeholder:"用户名",autoComplete:"username",...e})}),s.jsx(m,{})]})}),s.jsx(t,{control:r.control,name:"password",render:({field:e})=>s.jsxs(n,{children:[s.jsx(l,{children:"密码"}),s.jsx(c,{children:s.jsx(i,{type:"password",placeholder:"密码",autoComplete:"current-password",...e})}),s.jsx(m,{})]})}),s.jsx(L,{type:"submit",className:"w-full",loading:h,children:"登录"})]})}),s.jsxs("p",{style:{textAlign:"center",marginTop:16,fontSize:13,color:"var(--color-text-3)"},children:["没有账号?",s.jsx(b,{to:"/register",children:"注册"})]})]})})}export{z as default};
|
||||
File diff suppressed because one or more lines are too long
1
embed_static/static/spa/assets/ProfilePage-nO8fm023.js
Normal file
1
embed_static/static/spa/assets/ProfilePage-nO8fm023.js
Normal file
File diff suppressed because one or more lines are too long
1
embed_static/static/spa/assets/RegisterPage-C4KCPGxX.js
Normal file
1
embed_static/static/spa/assets/RegisterPage-C4KCPGxX.js
Normal file
@@ -0,0 +1 @@
|
||||
import{e as f,r as g,j as s,L as w}from"./react-vendor-CgzNJrV9.js";import{u as b,a as F,F as y,b as a,c as n,d as o,e as t,I as c,f as l,o as k,s as i}from"./form-BzHbu9bJ.js";import{u as N,B as S,a as v}from"./index-474mTIgd.js";import{n as x}from"./notify-Zesh5czd.js";import"./ui-vendor-CdH1UOKW.js";const C=k({username:i().min(1,"请输入用户名"),nickname:i().optional(),password:i().min(6,"密码至少 6 位")});function R(){const u=f(),{refresh:j}=N(),[p,m]=g.useState(!1),r=b({resolver:F(C),defaultValues:{username:"",nickname:"",password:""}}),h=async e=>{m(!0);try{await v.register(e.username,e.password,e.nickname||e.username),await j(),x.success("注册成功"),u("/",{replace:!0})}catch(d){x.error(d instanceof Error?d.message:"注册失败")}finally{m(!1)}};return s.jsx("div",{className:"auth-page",children:s.jsxs("div",{className:"auth-box",children:[s.jsx("div",{className:"logo-mark",children:"姜"}),s.jsx("h1",{children:"注册账号"}),s.jsx("p",{className:"subtitle",children:"首个注册用户自动成为管理员"}),s.jsx(y,{...r,children:s.jsxs("form",{onSubmit:r.handleSubmit(h),className:"space-y-4",children:[s.jsx(a,{control:r.control,name:"username",render:({field:e})=>s.jsxs(n,{children:[s.jsx(o,{children:"用户名"}),s.jsx(t,{children:s.jsx(c,{placeholder:"3-32 位字母数字下划线",autoComplete:"username",...e})}),s.jsx(l,{})]})}),s.jsx(a,{control:r.control,name:"nickname",render:({field:e})=>s.jsxs(n,{children:[s.jsx(o,{children:"昵称"}),s.jsx(t,{children:s.jsx(c,{placeholder:"显示名称(可选)",autoComplete:"nickname",...e})}),s.jsx(l,{})]})}),s.jsx(a,{control:r.control,name:"password",render:({field:e})=>s.jsxs(n,{children:[s.jsx(o,{children:"密码"}),s.jsx(t,{children:s.jsx(c,{type:"password",placeholder:"至少 6 位",autoComplete:"new-password",...e})}),s.jsx(l,{})]})}),s.jsx(S,{type:"submit",className:"w-full",loading:p,children:"注册"})]})}),s.jsxs("p",{style:{textAlign:"center",marginTop:16,fontSize:13,color:"var(--color-text-3)"},children:["已有账号?",s.jsx(w,{to:"/login",children:"登录"})]})]})})}export{R as default};
|
||||
1
embed_static/static/spa/assets/badge-CsEfIQQ2.js
Normal file
1
embed_static/static/spa/assets/badge-CsEfIQQ2.js
Normal file
@@ -0,0 +1 @@
|
||||
import{j as n}from"./react-vendor-CgzNJrV9.js";import{c as a,d as o}from"./index-474mTIgd.js";const s=o("inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",{variants:{variant:{default:"border-transparent bg-primary text-primary-foreground shadow",secondary:"border-transparent bg-secondary text-secondary-foreground",destructive:"border-transparent bg-destructive text-destructive-foreground shadow",outline:"text-foreground",green:"border-transparent bg-[var(--j13-green-bg)] text-[var(--j13-green)]",orange:"border-transparent bg-orange-100 text-orange-700 dark:bg-orange-950 dark:text-orange-300"}},defaultVariants:{variant:"default"}});function g({className:r,variant:e,...t}){return n.jsx("div",{className:a(s({variant:e}),r),...t})}export{g as B};
|
||||
1
embed_static/static/spa/assets/content-CKr4Ur51.js
Normal file
1
embed_static/static/spa/assets/content-CKr4Ur51.js
Normal file
@@ -0,0 +1 @@
|
||||
function a(n,t){return n.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/@([\w\u4e00-\u9fa5_-]+)/g,'<span class="mention">@$1</span>')}function o(n){const t=new Date(n),e=(new Date().getTime()-t.getTime())/1e3;return e<60?"刚刚":e<3600?`${Math.floor(e/60)}分钟前`:e<86400?`${Math.floor(e/3600)}小时前`:`${t.getMonth()+1}-${t.getDate()} ${String(t.getHours()).padStart(2,"0")}:${String(t.getMinutes()).padStart(2,"0")}`}export{o as f,a as h};
|
||||
64
embed_static/static/spa/assets/form-BzHbu9bJ.js
Normal file
64
embed_static/static/spa/assets/form-BzHbu9bJ.js
Normal file
File diff suppressed because one or more lines are too long
2
embed_static/static/spa/assets/index-474mTIgd.js
Normal file
2
embed_static/static/spa/assets/index-474mTIgd.js
Normal file
File diff suppressed because one or more lines are too long
1
embed_static/static/spa/assets/index-JNN-o0YU.css
Normal file
1
embed_static/static/spa/assets/index-JNN-o0YU.css
Normal file
File diff suppressed because one or more lines are too long
66
embed_static/static/spa/assets/markdown-vendor-DxR1h-Bq.js
Normal file
66
embed_static/static/spa/assets/markdown-vendor-DxR1h-Bq.js
Normal file
File diff suppressed because one or more lines are too long
1
embed_static/static/spa/assets/notify-Zesh5czd.js
Normal file
1
embed_static/static/spa/assets/notify-Zesh5czd.js
Normal file
@@ -0,0 +1 @@
|
||||
import{t as s}from"./index-474mTIgd.js";const n={success:r=>s.success(r),error:r=>s.error(r),warning:r=>s.warning(r)};export{n};
|
||||
23
embed_static/static/spa/assets/postContent-CNPsgNvN.js
Normal file
23
embed_static/static/spa/assets/postContent-CNPsgNvN.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import{p as m}from"./markdown-vendor-DxR1h-Bq.js";const d={ADD_TAGS:["members-only"],ADD_ATTR:["data-locked","data-length"]},c=`
|
||||
<div class="post-members-only__badge">
|
||||
<span class="post-members-only__badge-icon" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
</span>
|
||||
<span>登录可见</span>
|
||||
</div>`,a='<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';function p(s){const r=s>0?Math.min(6,Math.max(3,Math.ceil(s/42))):4,n=Array.from({length:r},(l,i)=>{const o=i%3;return`<div class="post-members-only__preview-line${o===1?" post-members-only__preview-line--medium":o===2?" post-members-only__preview-line--short":""}"></div>`}).join(""),e=s>0?`约 ${s} 字的`:"一段";return`
|
||||
<div class="post-members-only__locked-wrap">
|
||||
<div class="post-members-only__badge post-members-only__badge--locked">
|
||||
<span class="post-members-only__badge-icon" aria-hidden="true">${a}</span>
|
||||
<span>登录可见</span>
|
||||
</div>
|
||||
<div class="post-members-only__preview" aria-hidden="true">
|
||||
${n}
|
||||
</div>
|
||||
<div class="post-members-only__gate">
|
||||
<div class="post-members-only__gate-icon" aria-hidden="true">${a}</div>
|
||||
<p class="post-members-only__gate-title">此处有${e}专属内容</p>
|
||||
<p class="post-members-only__gate-desc">作者已将这部分内容设为仅登录用户可见,登录后即可阅读全文。</p>
|
||||
<button type="button" class="post-members-only__gate-btn" data-members-login>登录查看</button>
|
||||
<span class="post-members-only__gate-alt">还没有账号?<button type="button" class="post-members-only__gate-link" data-members-register>免费注册</button></span>
|
||||
</div>
|
||||
</div>`}function b(s,r){if(!s.trim())return"";const n=new DOMParser().parseFromString(m.sanitize(s,d),"text/html");return n.querySelectorAll("members-only").forEach(e=>{var o;if(e.getAttribute("data-locked")==="true"||!r){const t=parseInt(e.getAttribute("data-length")||"0",10)||0;e.setAttribute("data-locked","true"),e.className="post-members-only post-members-only--locked",e.innerHTML=p(t);return}const i=((o=e.querySelector(".post-members-only__body"))==null?void 0:o.innerHTML)??Array.from(e.childNodes).filter(t=>!(t instanceof Element&&t.classList.contains("post-members-only__badge"))).map(t=>t instanceof Element?t.outerHTML:t.textContent??"").join("");e.className="post-members-only post-members-only--visible",e.innerHTML=`${c}<div class="post-members-only__body">${i}</div>`}),n.body.innerHTML}export{d as P,b as r};
|
||||
67
embed_static/static/spa/assets/react-vendor-CgzNJrV9.js
vendored
Normal file
67
embed_static/static/spa/assets/react-vendor-CgzNJrV9.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
embed_static/static/spa/assets/spinner-DH1syOsQ.js
Normal file
1
embed_static/static/spa/assets/spinner-DH1syOsQ.js
Normal file
@@ -0,0 +1 @@
|
||||
import{j as e}from"./react-vendor-CgzNJrV9.js";import{c as s}from"./index-474mTIgd.js";import{p as m}from"./ui-vendor-CdH1UOKW.js";const t={sm:"h-4 w-4",md:"h-5 w-5",lg:"h-8 w-8"};function p({className:r,size:a="md"}){return e.jsx(m,{className:s("animate-spin text-[var(--j13-green)]",t[a],r),"aria-label":"加载中"})}export{p as S};
|
||||
256
embed_static/static/spa/assets/ui-vendor-CdH1UOKW.js
Normal file
256
embed_static/static/spa/assets/ui-vendor-CdH1UOKW.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
38
embed_static/static/spa/index.html
Normal file
38
embed_static/static/spa/index.html
Normal file
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>姜十三论坛 Jiang13 Forum</title>
|
||||
<style>
|
||||
/* 关键布局样式:在 JS/CSS 包加载前即固定三栏结构,避免刷新时组件错位 */
|
||||
html { scrollbar-gutter: stable; }
|
||||
html, body, #root { height: 100%; margin: 0; }
|
||||
body { overflow: hidden; font-size: 14px; line-height: 1.5; }
|
||||
.app-shell { height: 100%; max-height: 100dvh; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.app-frame { flex: 1; min-height: 0; height: 100%; max-width: 1400px; width: 100%; margin: 0 auto; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.app-header { height: 56px; flex-shrink: 0; }
|
||||
.app-body { flex: 1; display: flex; min-height: 0; width: 100%; overflow: hidden; }
|
||||
.content-workspace { flex: 1; display: flex; min-width: 0; min-height: 0; overflow: hidden; }
|
||||
.sidebar { width: 210px; flex-shrink: 0; }
|
||||
.main-content { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.aside-panel { width: 280px; flex-shrink: 0; }
|
||||
@media (max-width: 1100px) { .aside-panel { display: none; } }
|
||||
@media (max-width: 768px) { .sidebar { display: none; } }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var theme = localStorage.getItem('j13-theme') || 'light';
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-474mTIgd.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/react-vendor-CgzNJrV9.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-CdH1UOKW.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-JNN-o0YU.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
95
embed_static/templates/admin/boards.html
Normal file
95
embed_static/templates/admin/boards.html
Normal 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}}
|
||||
58
embed_static/templates/admin/comments.html
Normal file
58
embed_static/templates/admin/comments.html
Normal 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}}
|
||||
76
embed_static/templates/admin/dashboard.html
Normal file
76
embed_static/templates/admin/dashboard.html
Normal 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}}
|
||||
88
embed_static/templates/admin/layout.html
Normal file
88
embed_static/templates/admin/layout.html
Normal 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}}
|
||||
49
embed_static/templates/admin/login.html
Normal file
49
embed_static/templates/admin/login.html
Normal 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}}
|
||||
67
embed_static/templates/admin/posts.html
Normal file
67
embed_static/templates/admin/posts.html
Normal 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}}
|
||||
56
embed_static/templates/admin/settings.html
Normal file
56
embed_static/templates/admin/settings.html
Normal 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}}
|
||||
62
embed_static/templates/admin/users.html
Normal file
62
embed_static/templates/admin/users.html
Normal 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}}
|
||||
16
embed_static/templates/board.html
Normal file
16
embed_static/templates/board.html
Normal 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}}
|
||||
7
embed_static/templates/error.html
Normal file
7
embed_static/templates/error.html
Normal 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}}
|
||||
10
embed_static/templates/favorites.html
Normal file
10
embed_static/templates/favorites.html
Normal 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}}
|
||||
34
embed_static/templates/index.html
Normal file
34
embed_static/templates/index.html
Normal 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}}
|
||||
56
embed_static/templates/layout.html
Normal file
56
embed_static/templates/layout.html
Normal 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">© 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}}
|
||||
36
embed_static/templates/login.html
Normal file
36
embed_static/templates/login.html
Normal 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}}
|
||||
72
embed_static/templates/post.html
Normal file
72
embed_static/templates/post.html
Normal 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}}
|
||||
31
embed_static/templates/post_edit.html
Normal file
31
embed_static/templates/post_edit.html
Normal 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}}
|
||||
37
embed_static/templates/post_new.html
Normal file
37
embed_static/templates/post_new.html
Normal 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">可使用 <b>、<i>、<a>、<img> 等 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}}
|
||||
34
embed_static/templates/profile.html
Normal file
34
embed_static/templates/profile.html
Normal 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}}
|
||||
40
embed_static/templates/register.html
Normal file
40
embed_static/templates/register.html
Normal 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}}
|
||||
8
embed_static/templates/user_profile.html
Normal file
8
embed_static/templates/user_profile.html
Normal 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}}
|
||||
Reference in New Issue
Block a user