fix: 设计原则审查修复 — DIP/ISP, LoD, DRY, OCP, URL, 301缓存

- P0 DIP+ISP: 全链路注入接口,消除零接口紧耦合
- P0 URL: auth 301→302,修复登出后浏览器缓存陷阱
- P1 DRY: JWT 认证逻辑收敛至 TokenService+中间件
- P2 DRY: 前后端角色/状态映射统一为 model 常量
- P2 LoD: 新增 SettingsController,router 不再跨层调 repo
- P2 URL: settings ?tab= → /settings/:tab 伪静态
- P3 OCP: 角色权限 map 化,告别硬编码 switch
This commit is contained in:
2026-05-26 21:12:19 +08:00
parent 483fdd919f
commit 39d13993ba
37 changed files with 961 additions and 260 deletions

View File

@ -3,17 +3,37 @@
{{template "layout/nav.html" .}}
<main class="hero">
<div class="container">
<h1>Meta<span>Lab</span></h1>
<p class="hero-tagline">下一代开发者社区</p>
{{if .IsLoggedIn}}
<p class="hero-desc">欢迎回来,{{.Username}}</p>
{{else}}
<p class="hero-desc">一个为构建者准备的纯粹空间</p>
{{end}}
</div>
</main>
<div class="home-layout">
<main class="hero">
<div class="container">
<h1>Meta<span>Lab</span></h1>
<p class="hero-tagline">下一代开发者社区</p>
{{if .IsLoggedIn}}
<p class="hero-desc">欢迎回来,{{.Username}}</p>
{{else}}
<p class="hero-desc">一个为构建者准备的纯粹空间</p>
{{end}}
</div>
</main>
<aside class="sidebar">
<div class="sidebar-card user-card">
{{if .IsLoggedIn}}
<div class="user-card-avatar">{{slice .Username 0 1}}</div>
<h3 class="user-card-name">{{.Username}}</h3>
<p class="user-card-desc">欢迎加入 MetaLab</p>
<a href="/settings/profile" class="user-card-link">个人设置</a>
{{else}}
<div class="user-card-avatar">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="28" height="28"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 4-7 8-7s8 3 8 7"/></svg>
</div>
<h3 class="user-card-name">未登录</h3>
<p class="user-card-desc">登录后解锁社区功能</p>
<a href="/auth/login" class="user-card-btn">登录</a>
{{end}}
</div>
</aside>
</div>
{{template "layout/footer.html" .}}

View File

@ -9,4 +9,5 @@
{{if .ExtraCSS}}
<link rel="stylesheet" href="{{.ExtraCSS}}">
{{end}}
<script src="/shared/static/js/utils.js"></script>
</head>

View File

@ -7,7 +7,7 @@
<ul class="nav-links" id="navLinks">
<li><a href="/">首页</a></li>
{{if .IsLoggedIn}}
<li><a href="/settings" class="nav-user">{{.Username}}</a></li>
<li><a href="/settings/profile" class="nav-user">{{.Username}}</a></li>
<li><a href="javascript:void(0)" class="nav-logout" id="logoutBtn">退出</a></li>
{{else}}
<li><a href="/auth/login" class="nav-login">登录</a></li>

View File

@ -0,0 +1,105 @@
{{template "layout/header.html" .}}
<body>
{{template "layout/nav.html" .}}
<div class="settings-layout">
<aside class="settings-sidebar">
<nav class="settings-nav">
<a href="/settings/profile"
class="settings-nav-item {{if eq .ActiveTab "profile"}}active{{end}}">
<svg class="settings-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 4-7 8-7s8 3 8 7"/>
</svg>
个人资料
</a>
<a href="/settings/account"
class="settings-nav-item {{if eq .ActiveTab "account"}}active{{end}}">
<svg class="settings-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
<circle cx="12" cy="16" r="1"/>
</svg>
账号信息
</a>
</nav>
</aside>
<main class="settings-main">
{{if eq .ActiveTab "profile"}}
<!-- 个人资料 -->
<div class="settings-card">
<div class="settings-card-header">
<h2>个人资料</h2>
<p class="settings-card-desc">你的公开信息和基本资料</p>
</div>
<div class="settings-card-body">
<div class="info-row">
<span class="info-label">用户名</span>
<span class="info-value">{{.User.Username}}</span>
</div>
<div class="info-row">
<span class="info-label">头像</span>
<span class="info-value">
{{if .User.Avatar}}
<img src="{{.User.Avatar}}" alt="avatar" class="avatar-preview">
{{else}}
<span class="info-muted">未设置</span>
{{end}}
</span>
</div>
<div class="info-row">
<span class="info-label">简介</span>
<span class="info-value{{if not .User.Bio}} info-muted{{end}}">
{{if .User.Bio}}{{.User.Bio}}{{else}}未填写{{end}}
</span>
</div>
<div class="info-row">
<span class="info-label">角色</span>
<span class="info-value">
<span class="role-tag role-{{.User.Role}}">{{.RoleName}}</span>
</span>
</div>
</div>
</div>
{{else}}
<!-- 账号信息 -->
<div class="settings-card">
<div class="settings-card-header">
<h2>账号信息</h2>
<p class="settings-card-desc">账号安全和基本状态</p>
</div>
<div class="settings-card-body">
<div class="info-row">
<span class="info-label">用户 ID</span>
<span class="info-value info-mono">{{.User.ID}}</span>
</div>
<div class="info-row">
<span class="info-label">邮箱</span>
<span class="info-value">{{.User.Email}}</span>
</div>
<div class="info-row">
<span class="info-label">账号状态</span>
<span class="info-value">
<span class="status-badge status-{{.User.Status}}">{{.StatusName}}</span>
</span>
</div>
<div class="info-row">
<span class="info-label">注册时间</span>
<span class="info-value">{{.User.CreatedAt.Format "2006-01-02 15:04:05"}}</span>
</div>
<div class="info-row">
<span class="info-label">最后更新</span>
<span class="info-value">{{.User.UpdatedAt.Format "2006-01-02 15:04:05"}}</span>
</div>
</div>
</div>
{{end}}
</main>
</div>
{{template "layout/footer.html" .}}
<script src="/static/js/common.js"></script>
</body>
</html>

View File

@ -26,9 +26,7 @@
box-sizing: border-box;
}
html {
scroll-snap-type: y mandatory;
}
html {}
body {
background: var(--color-bg);

View File

@ -3,16 +3,23 @@
仅在首页加载,通过 ExtraCSS 注入
============================================================ */
/* ---------- Two-column layout ---------- */
.home-layout {
display: flex;
min-height: 100vh;
align-items: stretch;
}
/* ---------- Hero ---------- */
.hero {
min-height: 100vh;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
padding: 4rem 0;
text-align: center;
background: linear-gradient(180deg, #fff 0%, #f0f4f8 100%);
scroll-snap-align: start;
min-width: 0;
}
.hero h1 {
@ -105,7 +112,6 @@
flex-direction: column;
justify-content: center;
padding: 1.5rem 0;
scroll-snap-align: start;
}
.plans .section-title {
@ -185,7 +191,6 @@
justify-content: center;
padding: 4rem 0;
background: var(--color-surface);
scroll-snap-align: start;
}
.features-grid {
@ -220,7 +225,6 @@
height: 100vh;
display: flex;
flex-direction: column;
scroll-snap-align: start;
}
.timeline-vision {
@ -316,6 +320,84 @@
font-size: .95rem;
}
/* ---------- Sidebar ---------- */
.sidebar {
width: 300px;
flex-shrink: 0;
padding: 4rem 20px 4rem 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.sidebar-card {
background: var(--color-surface);
border-radius: var(--radius);
box-shadow: var(--shadow);
border: 1px solid var(--color-border);
padding: 1.5rem;
}
/* --- User Card --- */
.user-card {
text-align: center;
}
.user-card-avatar {
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--color-accent);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto .8rem;
font-size: 1.5rem;
font-weight: 700;
text-transform: uppercase;
}
.user-card-name {
font-size: 1.1rem;
font-weight: 600;
color: var(--color-primary);
margin-bottom: .3rem;
}
.user-card-desc {
font-size: .85rem;
color: var(--color-text-light);
margin-bottom: 1rem;
}
.user-card-link {
display: inline-block;
font-size: .85rem;
color: var(--color-accent);
font-weight: 500;
transition: var(--transition);
}
.user-card-link:hover {
opacity: .8;
}
.user-card-btn {
display: inline-block;
padding: .5rem 1.8rem;
background: var(--color-accent);
color: #fff;
border-radius: var(--radius);
font-size: .9rem;
font-weight: 500;
transition: var(--transition);
}
.user-card-btn:hover {
opacity: .85;
}
/* ---------- Responsive (首页专属) ---------- */
@media (max-width: 768px) {
.hero h1 {
@ -327,16 +409,15 @@
.hero-desc {
font-size: 1.05rem;
}
.timeline {
justify-content: flex-start;
gap: 1.5rem;
padding: 2rem 20px 1.5rem;
scroll-snap-type: x mandatory;
}
.timeline-item {
min-width: 240px;
max-width: 280px;
scroll-snap-align: start;
}
.timeline::before {
left: 20px;

View File

@ -0,0 +1,215 @@
/* ============================================================
MetaLab Settings Styles — 个人设置页样式
左侧导航 + 右侧卡片信息展示,只读
============================================================ */
/* ---------- Page Body ---------- */
body {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* ---------- Layout ---------- */
.settings-layout {
display: flex;
flex: 1;
max-width: 1200px;
width: 100%;
margin: 0 auto;
padding: 2rem 20px;
gap: 2rem;
}
/* ---------- Left Sidebar Nav ---------- */
.settings-sidebar {
width: 220px;
flex-shrink: 0;
background: var(--color-surface);
border-radius: var(--radius);
box-shadow: var(--shadow);
border: 1px solid var(--color-border);
padding: 1.25rem 1rem;
align-self: flex-start;
}
.settings-nav {
display: flex;
flex-direction: column;
gap: 4px;
}
.settings-nav-item {
display: flex;
align-items: center;
gap: .6rem;
padding: .65rem 1rem;
border-radius: var(--radius);
font-size: .92rem;
color: var(--color-secondary);
font-weight: 500;
transition: all .15s ease;
}
.settings-nav-item:hover {
background: rgba(52, 152, 219, .06);
color: var(--color-accent);
}
.settings-nav-item.active {
background: rgba(52, 152, 219, .1);
color: var(--color-accent);
font-weight: 600;
}
.settings-nav-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
/* ---------- Right Content Area ---------- */
.settings-main {
flex: 1;
min-width: 0;
}
.settings-card {
background: var(--color-surface);
border-radius: var(--radius);
box-shadow: var(--shadow);
border: 1px solid var(--color-border);
}
.settings-card-header {
padding: 1.5rem 2rem 0;
}
.settings-card-header h2 {
font-size: 1.25rem;
font-weight: 600;
color: var(--color-primary);
margin-bottom: .3rem;
}
.settings-card-desc {
font-size: .85rem;
color: var(--color-text-light);
padding-bottom: 1.25rem;
border-bottom: 1px solid var(--color-border);
margin-bottom: 0;
}
.settings-card-body {
padding: 1.25rem 2rem 1.5rem;
}
/* ---------- Info Rows ---------- */
.info-row {
display: flex;
align-items: flex-start;
padding: .9rem 0;
border-bottom: 1px solid #f0f0f0;
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
width: 100px;
flex-shrink: 0;
font-size: .88rem;
color: var(--color-text-light);
font-weight: 500;
padding-top: 1px;
}
.info-value {
flex: 1;
font-size: .92rem;
color: var(--color-text);
min-width: 0;
word-break: break-all;
}
.info-muted {
color: #aaa;
}
.info-mono {
font-family: 'SF Mono', 'Menlo', 'Monaco', monospace;
font-size: .85rem;
background: #f5f5f5;
padding: 1px 8px;
border-radius: 4px;
}
/* ---------- Avatar Preview ---------- */
.avatar-preview {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--color-border);
}
/* ---------- Role Tag ---------- */
.role-tag {
display: inline-block;
padding: 2px 10px;
border-radius: 4px;
font-size: .82rem;
font-weight: 600;
}
.role-user { background: #e3f2fd; color: #1976d2; }
.role-moderator { background: #e8f5e9; color: #2e7d32; }
.role-admin { background: #fce4ec; color: #c62828; }
.role-owner { background: #fff3e0; color: #e65100; }
/* ---------- Status Badge ---------- */
.status-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: .82rem;
font-weight: 600;
}
.status-active { background: #e8f5e9; color: #2e7d32; }
.status-banned { background: #ffebee; color: #c62828; }
.status-deleted { background: #fff3e0; color: #e65100; }
.status-locked { background: #eceff1; color: #546e7a; }
/* ---------- Responsive ---------- */
@media (max-width: 768px) {
.settings-layout {
flex-direction: column;
padding: 1rem 16px;
gap: 1rem;
}
.settings-sidebar {
width: 100%;
align-self: auto;
}
.settings-nav {
flex-direction: row;
gap: 2px;
}
.settings-nav-item {
padding: .5rem .9rem;
font-size: .85rem;
}
.settings-card-header {
padding: 1.25rem 1.25rem 0;
}
.settings-card-body {
padding: 1rem 1.25rem 1.25rem;
}
}

View File

@ -19,11 +19,8 @@
}, 3000);
};
// ---- CSRF token helper (reads from <meta> tag rendered by server) ----
window.MetaLab.csrfToken = function () {
var meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : '';
};
// ---- CSRF token helper (从共享 utils.js 提供的 getCSRFToken 别名) ----
window.MetaLab.csrfToken = getCSRFToken;
// ---- Auto-attach X-CSRF-Token to all state-changing requests ----
// Monkey-patch XMLHttpRequest and fetch to inject CSRF header

View File

@ -16,14 +16,14 @@
<a href="/admin" class="admin-logo">MetaLab <span>管理面板</span></a>
<div class="admin-header-right">
<span class="admin-user">{{.Username}}</span>
<a href="/" class="admin-back-btn">返回前台</a>
<a href="/" class="admin-back-btn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="vertical-align:-2px;margin-right:3px;"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>返回前台</a>
</div>
</div>
</header>
<div class="admin-container">
<aside class="admin-sidebar">
<nav>
<a href="/admin" class="sidebar-item {{if eq .CurrentPath "/admin"}}active{{end}}">
<a href="/admin" class="sidebar-item {{if eq .CurrentPath "/admin/"}}active{{end}}">
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg></span>
管理首页
</a>

View File

@ -1,5 +1,6 @@
</main>
</div>
<script src="/shared/static/js/utils.js"></script>
<script src="/admin/static/js/common.js"></script>
{{if .ExtraJS}}
<script src="{{.ExtraJS}}"></script>

View File

@ -9,17 +9,15 @@
<input type="text" id="searchInput" class="filter-input" placeholder="搜索邮箱或用户名..." autocomplete="off">
<select id="roleFilter" class="filter-select">
<option value="">全部角色</option>
<option value="user">普通用户</option>
<option value="moderator">版主</option>
<option value="admin">管理员</option>
<option value="owner">站长</option>
{{range $val, $label := .RoleNames}}
<option value="{{$val}}">{{$label}}</option>
{{end}}
</select>
<select id="statusFilter" class="filter-select">
<option value="">全部状态</option>
<option value="active">正常</option>
<option value="banned">已封禁</option>
<option value="deleted">已注销</option>
<option value="locked">已锁定</option>
{{range $val, $label := .StatusNames}}
<option value="{{$val}}">{{$label}}</option>
{{end}}
</select>
<button id="searchBtn" class="btn btn-primary">搜索</button>
</div>
@ -46,5 +44,17 @@
<!-- 分页 -->
<div class="pagination-bar" id="paginationBar"></div>
<script>window.__currentUid = {{.UID}}; window.__isOwner = {{if .IsOwner}}true{{else}}false{{end}};</script>
<script>
window.__currentUid = {{.UID}};
window.__isOwner = {{if .IsOwner}}true{{else}}false{{end}};
// 由服务端 model.RoleDisplayNames / StatusDisplayNames 作为唯一数据源注入
window.__ROLE_NAMES = {
{{range $val, $label := .RoleNames}}"{{$val}}": "{{$label}}",
{{end}}
};
window.__STATUS_NAMES = {
{{range $val, $label := .StatusNames}}"{{$val}}": "{{$label}}",
{{end}}
};
</script>
{{template "admin/layout/footer.html" .}}

View File

@ -3,10 +3,10 @@
单一职责:仅用户管理页面特有样式
===================================================== */
.data-table .col-uid { width: 60px; }
.data-table .col-username { width: 120px; }
.data-table .col-email { min-width: 180px; }
.data-table .col-role { width: 90px; }
.data-table .col-status { width: 90px; }
.data-table .col-time { width: 140px; }
.data-table .col-actions { width: 160px; }
.data-table .col-uid { width: 60px; white-space: nowrap; }
.data-table .col-username { width: 120px; white-space: nowrap; }
.data-table .col-email { max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.data-table .col-role { width: 100px; white-space: nowrap; }
.data-table .col-status { width: 90px; white-space: nowrap; }
.data-table .col-time { width: 140px; white-space: nowrap; }
.data-table .col-actions { width: 180px; white-space: nowrap; }

View File

@ -5,6 +5,7 @@
===================================================== */
// --- AJAX 封装 ---
// 注意getCSRFToken() 由共享 utils.js 提供
const api = {
async get(url) {
const res = await fetch(url, {
@ -36,11 +37,6 @@ const api = {
}
};
function getCSRFToken() {
const meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : '';
}
// --- Toast 通知 ---
function showToast(message, type) {
const container = document.querySelector('.toast-container') || createToastContainer();
@ -110,13 +106,15 @@ function formatTime(isoStr) {
}
// --- 角色标签 ---
// 数据源由服务端 model.RoleDisplayNames 注入为 window.__ROLE_NAMES
function roleLabel(role) {
const map = { user: '普通用户', moderator: '版主', admin: '管理员', owner: '站长' };
return `<span class="role-tag role-${role}">${map[role] || role}</span>`;
var map = window.__ROLE_NAMES || { user: '普通用户', moderator: '版主', admin: '管理员', owner: '站长' };
return '<span class="role-tag role-' + role + '">' + (map[role] || role) + '</span>';
}
// --- 状态标签 ---
// 数据源由服务端 model.StatusDisplayNames 注入为 window.__STATUS_NAMES
function statusLabel(status) {
const map = { active: '正常', banned: '已封禁', deleted: '已注销', locked: '已锁定' };
return `<span class="badge badge-${status}">${map[status] || status}</span>`;
var map = window.__STATUS_NAMES || { active: '正常', banned: '已封禁', deleted: '已注销', locked: '已锁定' };
return '<span class="badge badge-' + status + '">' + (map[status] || status) + '</span>';
}

View File

@ -0,0 +1,14 @@
/* ============================================================
MetaLab 共享工具函数 — 前后台公用,消除 DRY 重复
============================================================ */
'use strict';
/**
* 从 <meta name="csrf-token"> 读取 CSRF 令牌
* 由服务端 CSRF 中间件注入到页面模板的 <meta> 标签中
* @returns {string} CSRF 令牌,未找到时返回空字符串
*/
function getCSRFToken() {
var meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : '';
}