初始提交:姜十三论坛 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

73
embed_static/embed.go Normal file
View 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")
}

View 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; }
}

View 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();
}

View 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%; }

View 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

File diff suppressed because one or more lines are too long

View 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};

File diff suppressed because one or more lines are too long

View 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

File diff suppressed because one or more lines are too long

View 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};

View 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};

View File

@@ -0,0 +1 @@
function a(n,t){return n.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").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};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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};

View 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};

File diff suppressed because one or more lines are too long

View 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};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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>

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}}