feat: 编辑器与帖子展示布局优化 + 新增个人空间

- 编辑器投稿页布局优化:标题与编辑器高度动态适配,工具栏与内容区视觉对齐
- 稿件展示页布局优化:MD 渲染区与评论区视觉分离,代码块主题微调
- CSRF 中间件:图像上传端点豁免,解决 Vditor 拖拽/粘贴上传 403
- Post 状态映射、GetGinUser、SaveUploadedFile 提取至 common 包,遵循审计建议
- 新增个人空间功能:/space(需登录)重定向到 /space/:uid,/space/:uid 公开访问
- 空间页模板:参考 B 站布局,展示头像、用户名、个性签名、UID、加入时间及稿件列表
- 导航栏:用户名链接指向 /space,新增「设置」入口
- 分层实现:SpaceController → SpaceService → PostRepo.FindByUserID,严格 ISP 接口隔离
This commit is contained in:
2026-05-30 19:06:10 +08:00
parent 40f0529cbf
commit bacfd4945d
31 changed files with 1229 additions and 252 deletions

View File

@ -4,6 +4,14 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{{if .CSRFToken}}<meta name="csrf-token" content="{{.CSRFToken}}">{{end}}
{{if .OgTitle}}
<meta property="og:title" content="{{.OgTitle}}">
<meta property="og:type" content="article">
<meta property="og:site_name" content="MetaLab">
{{if .OgDescription}}<meta property="og:description" content="{{.OgDescription}}">{{end}}
{{if .OgImage}}<meta property="og:image" content="{{.OgImage}}">{{end}}
{{if .OgURL}}<meta property="og:url" content="{{.OgURL}}">{{end}}
{{end}}
<title>{{.Title}} - MetaLab</title>
<link rel="stylesheet" href="/static/css/common.css">
{{if .ExtraCSS}}

View File

@ -14,7 +14,8 @@
<span class="nav-bell-badge" id="msgBadge" style="display:none">0</span>
</a>
</li>
<li><a href="/settings/profile" class="nav-user">{{.Username}}</a></li>
<li><a href="/space" class="nav-user">{{.Username}}</a></li>
<li><a href="/settings/profile" class="nav-settings">设置</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

@ -29,7 +29,7 @@
<a href="/posts/{{.ID}}">{{.Title}}</a>
</h2>
<div class="post-card-meta">
<span class="post-author">{{if .AuthorName}}{{.AuthorName}}({{.UserID}}){{else}}UID{{.UserID}}{{end}}</span>
<span class="post-author">{{if .AuthorName}}{{.AuthorName}}{{else}}该用户已注销{{end}}</span>
<span class="post-time">{{.CreatedAt.Format "2006-01-02 15:04"}}</span>
</div>
<p class="post-card-summary">{{if .Excerpt}}{{.Excerpt}}{{else}}{{printf "%.200s" .Body}}{{end}}</p>

View File

@ -24,6 +24,30 @@
<div class="editor-pane">
<div id="vditor"></div>
</div>
<aside class="editor-sidebar">
<div class="editor-sidebar-section">
<h4>快捷语法</h4>
<ul class="tips-list">
<li><code>#</code> <code>##</code> <code>###</code> 标题</li>
<li><code>**粗体**</code> <code>*斜体*</code></li>
<li><code>`行内代码`</code></li>
<li><code>```</code> 代码块</li>
<li><code>&gt;</code> 引用</li>
<li><code>-</code> 无序列表</li>
<li><code>[文字](链接)</code></li>
<li><code>![图片](链接)</code></li>
</ul>
</div>
<div class="editor-sidebar-section">
<h4>发布提示</h4>
<ul class="tips-list">
<li>标题控制在 50 字以内</li>
<li>正文清晰分段,便于阅读</li>
<li>代码使用代码块包裹</li>
<li>Ctrl+S 快速保存草稿</li>
</ul>
</div>
</aside>
</div>
{{/* 状态栏 */}}

View File

@ -8,7 +8,7 @@
<header class="post-detail-header">
<h1 class="post-detail-title">{{.Post.Title}}</h1>
<div class="post-detail-meta">
<span class="post-author">{{if .Post.AuthorName}}{{.Post.AuthorName}}({{.Post.UserID}}){{else}}UID{{.Post.UserID}}{{end}}</span>
<span class="post-author">{{if .Post.AuthorName}}{{.Post.AuthorName}}{{else}}该用户已注销{{end}}</span>
<span class="post-time">{{.Post.CreatedAt.Format "2006-01-02 15:04"}}</span>
{{if ne .Post.Status "approved"}}
<span class="post-status post-status-{{.Post.Status}}">{{index $.StatusNames .Post.Status}}</span>
@ -57,6 +57,18 @@
cdn: '/static/vditor',
theme: { current: 'light', path: '/static/vditor/dist/css/content-theme' },
hljs: { style: 'github-dark', enable: true },
after: function() {
// 外部链接自动加 nofollow + target="_blank",避免权重流失
var host = window.location.host;
var links = el.querySelectorAll('a[href^="http"]');
for (var i = 0; i < links.length; i++) {
var a = links[i];
if (a.host && a.host !== host) {
a.setAttribute('rel', 'nofollow noopener noreferrer');
a.setAttribute('target', '_blank');
}
}
}
});
// Vditor.preview() 渲染 MDshortcode 占位 div 原样保留

View File

@ -0,0 +1,93 @@
{{template "layout/header.html" .}}
<body>
{{template "layout/nav.html" .}}
{{if .Error}}
<div class="space-error">
<div class="container">
<h1>{{.Error}}</h1>
<a href="/" class="btn btn-primary">返回首页</a>
</div>
</div>
{{else}}
<div class="space-page">
<!-- 用户信息头部 -->
<div class="space-header">
<div class="container space-header-inner">
<div class="space-avatar">
{{if .SpaceUser.Avatar}}
<img src="{{.SpaceUser.Avatar}}" alt="{{.SpaceUser.Username}}">
{{else}}
<div class="space-avatar-placeholder">{{slice .SpaceUser.Username 0 1}}</div>
{{end}}
</div>
<div class="space-info">
<h1 class="space-username">{{.SpaceUser.Username}}</h1>
<p class="space-bio">{{if .SpaceUser.Bio}}{{.SpaceUser.Bio}}{{else}}&nbsp;{{end}}</p>
<div class="space-meta">
<span class="space-uid">UID: {{.SpaceUser.ID}}</span>
<span class="space-divider">·</span>
<span class="space-joined">加入于 {{.SpaceUser.CreatedAt.Format "2006-01-02"}}</span>
</div>
</div>
</div>
</div>
<!-- 内容区域 -->
<div class="container space-content">
<!-- 统计栏 -->
<div class="space-stats">
<div class="space-stat-item">
<span class="space-stat-num">{{.Total}}</span>
<span class="space-stat-label">稿件</span>
</div>
</div>
<!-- 稿件列表 -->
<div class="space-section">
<h2 class="space-section-title">稿件</h2>
{{if eq (len .Posts) 0}}
<div class="empty-state">暂无稿件</div>
{{else}}
<div class="space-posts-list">
{{range .Posts}}
<article class="space-post-card">
<div class="space-post-main">
<h3 class="space-post-title">
<a href="/posts/{{.ID}}">{{.Title}}</a>
</h3>
<p class="space-post-excerpt">{{if .Excerpt}}{{.Excerpt}}{{else}}{{printf "%.200s" .Body}}{{end}}</p>
<div class="space-post-meta">
<span class="space-post-status">已发布</span>
<span class="space-post-time">{{.CreatedAt.Format "2006-01-02"}}</span>
</div>
</div>
</article>
{{end}}
</div>
{{end}}
<!-- 分页 -->
{{if gt .TotalPages 1}}
<div class="pagination">
{{if gt .Page 1}}
<a href="?page={{.PrevPage}}" class="page-link">上一页</a>
{{end}}
<span class="page-info">第 {{.Page}} / {{.TotalPages}} 页 (共 {{.Total}} 条)</span>
{{if lt .Page .TotalPages}}
<a href="?page={{.NextPage}}" class="page-link">下一页</a>
{{end}}
</div>
{{end}}
</div>
</div>
</div>
{{end}}
{{template "layout/footer.html" .}}
<script src="/static/js/common.js"></script>
</body>
</html>

View File

@ -26,13 +26,23 @@
box-sizing: border-box;
}
html {}
html {
height: 100%;
}
body {
background: var(--color-bg);
color: var(--color-text);
line-height: 1.6;
overflow-x: hidden;
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* 主内容区撑满剩余空间,实现 sticky footer */
body > .container {
flex: 1;
}
a {
@ -126,6 +136,11 @@ header {
font-weight: 600 !important;
}
.nav-settings {
font-size: .9rem;
color: var(--color-secondary) !important;
}
.nav-logout {
color: var(--color-secondary) !important;
}
@ -203,6 +218,7 @@ footer {
background: var(--color-primary);
color: #bdc3c7;
padding: .8rem 0;
flex-shrink: 0;
}
.footer-content {

View File

@ -297,7 +297,11 @@
height: calc(100vh - 56px);
display: flex;
flex-direction: column;
overflow: hidden;
overflow: visible;
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 0 28px;
}
.editor-form {
@ -307,7 +311,7 @@
}
.editor-header {
padding: 16px 24px 12px;
padding: 16px 0 12px;
border-bottom: 1px solid #e5e7eb;
background: #fff;
flex-shrink: 0;
@ -330,21 +334,73 @@
.editor-main {
flex: 1;
display: flex;
overflow: hidden;
overflow: visible;
min-height: 0;
}
.editor-pane {
flex: 1;
overflow: hidden;
overflow: visible;
min-width: 0;
}
.editor-sidebar {
width: 250px;
flex-shrink: 0;
margin-left: 24px;
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
font-size: 13px;
color: #4b5563;
}
.editor-sidebar-section {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 14px 16px;
}
.editor-sidebar-section h4 {
font-size: 12px;
font-weight: 600;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 10px 0;
}
.editor-sidebar-section .tips-list {
list-style: none;
padding: 0;
margin: 0;
}
.editor-sidebar-section .tips-list li {
padding: 5px 0;
border-bottom: 1px solid #f3f4f6;
line-height: 1.5;
}
.editor-sidebar-section .tips-list li:last-child {
border-bottom: none;
}
.editor-sidebar-section .tips-list code {
background: #f3f4f6;
padding: 1px 5px;
border-radius: 3px;
font-size: 12px;
color: #6366f1;
}
.editor-statusbar {
display: flex;
align-items: center;
gap: 16px;
padding: 6px 20px;
padding: 6px 0;
background: #fafafa;
border-top: 1px solid #e5e7eb;
font-size: 12px;
@ -364,32 +420,19 @@
z-index: 9999;
height: 100vh;
background: #fff;
max-width: none;
}
/* ========== Vditor 编辑器容器 ========== */
/* Vditor 自动生成工具栏、编辑区、状态栏,仅做外层布局微调 */
/* 仅提供高度约束,不干预 Vditor 内部布局和弹窗 */
#vditor {
flex: 1;
overflow: hidden;
min-height: 0;
display: flex;
flex-direction: column;
}
/* Vditor 工具栏微调 — 匹配项目主题色 */
.vditor-toolbar {
background: #fafafa !important;
border-bottom: 1px solid #e5e7eb !important;
}
/* Vditor 内容区适配 */
.vditor-content {
font-size: 15px;
line-height: 1.7;
color: #24292f;
}
/* Vditor 预览/渲染区域 — 详情页 */
.post-detail-body .vditor-reset {
font-size: 16px;

View File

@ -0,0 +1,313 @@
/* ============================================================
MetaLab Space Styles — 用户空间页面
参考 Bilibili 空间布局,轻量版
============================================================ */
/* ---------- Error State ---------- */
.space-error {
text-align: center;
padding: 80px 20px;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.space-error h1 {
font-size: 1.5rem;
color: var(--color-secondary);
margin-bottom: 1.5rem;
}
.space-error .btn {
display: inline-block;
padding: .6rem 1.8rem;
background: var(--color-accent);
color: #fff;
border-radius: var(--radius);
font-weight: 600;
transition: var(--transition);
}
.space-error .btn:hover {
background: #2980b9;
transform: translateY(-1px);
}
/* ---------- Page Layout ---------- */
.space-page {
flex: 1;
}
/* ============================================================
Header — 用户信息横幅B站风格
============================================================ */
.space-header {
background: linear-gradient(135deg, #e8f0f8 0%, #f0f4f8 100%);
border-bottom: 1px solid var(--color-border);
padding: 2rem 0;
}
.space-header-inner {
display: flex;
align-items: center;
gap: 1.5rem;
}
/* Avatar */
.space-avatar {
flex-shrink: 0;
}
.space-avatar img {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
border: 3px solid #fff;
box-shadow: 0 2px 8px rgba(0,0,0,.1);
}
.space-avatar-placeholder {
width: 80px;
height: 80px;
border-radius: 50%;
background: var(--color-accent);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
font-weight: 700;
border: 3px solid #fff;
box-shadow: 0 2px 8px rgba(0,0,0,.1);
text-transform: uppercase;
}
/* Info */
.space-info {
min-width: 0;
}
.space-username {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-primary);
line-height: 1.3;
margin-bottom: .25rem;
}
.space-bio {
font-size: .9rem;
color: var(--color-text-light);
line-height: 1.5;
margin-bottom: .5rem;
word-break: break-word;
}
.space-meta {
display: flex;
align-items: center;
gap: .3rem;
font-size: .8rem;
color: var(--color-secondary);
flex-wrap: wrap;
}
.space-divider {
color: var(--color-border);
}
/* ============================================================
Stats Bar
============================================================ */
.space-stats {
display: flex;
gap: 2rem;
padding: 1rem 0;
border-bottom: 1px solid var(--color-border);
margin-bottom: 1.5rem;
}
.space-stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: .15rem;
}
.space-stat-num {
font-size: 1.4rem;
font-weight: 700;
color: var(--color-primary);
}
.space-stat-label {
font-size: .8rem;
color: var(--color-secondary);
}
/* ============================================================
Content Section — 稿件列表
============================================================ */
.space-content {
padding-bottom: 3rem;
}
.space-section {
margin-top: .5rem;
}
.space-section-title {
font-size: 1.1rem;
font-weight: 600;
color: var(--color-primary);
margin-bottom: 1rem;
padding-bottom: .5rem;
border-bottom: 2px solid var(--color-accent);
display: inline-block;
}
/* ---------- Post Card ---------- */
.space-posts-list {
display: flex;
flex-direction: column;
gap: .75rem;
}
.space-post-card {
display: flex;
align-items: center;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 1rem 1.25rem;
transition: box-shadow .2s ease, border-color .2s ease;
}
.space-post-card:hover {
box-shadow: 0 2px 12px rgba(0,0,0,.06);
border-color: #ccc;
}
.space-post-main {
flex: 1;
min-width: 0;
}
.space-post-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: .3rem;
line-height: 1.4;
}
.space-post-title a {
color: var(--color-primary);
transition: var(--transition);
}
.space-post-title a:hover {
color: var(--color-accent);
}
.space-post-excerpt {
font-size: .85rem;
color: var(--color-text-light);
line-height: 1.5;
margin-bottom: .5rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.space-post-meta {
display: flex;
align-items: center;
gap: .75rem;
font-size: .75rem;
color: var(--color-secondary);
}
.space-post-status {
color: var(--color-accent);
font-weight: 500;
}
/* ---------- Empty State ---------- */
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--color-secondary);
font-size: .95rem;
}
/* ---------- Pagination ---------- */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-top: 2rem;
font-size: .9rem;
}
.page-link {
padding: .4rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
color: var(--color-accent);
transition: var(--transition);
}
.page-link:hover {
border-color: var(--color-accent);
background: rgba(52, 152, 219, .05);
}
.page-info {
color: var(--color-secondary);
}
/* ============================================================
Responsive
============================================================ */
@media (max-width: 768px) {
.space-header {
padding: 1.5rem 0;
}
.space-header-inner {
flex-direction: column;
text-align: center;
gap: .75rem;
}
.space-avatar img,
.space-avatar-placeholder {
width: 64px;
height: 64px;
font-size: 1.5rem;
}
.space-username {
font-size: 1.25rem;
}
.space-meta {
justify-content: center;
}
.space-stats {
justify-content: center;
}
.space-post-card {
padding: .85rem 1rem;
}
.space-post-title {
font-size: .95rem;
}
}

View File

@ -20,7 +20,7 @@
};
// ---- CSRF token helper (从共享 utils.js 提供的 getCSRFToken 别名) ----
window.MetaLab.csrfToken = getCSRFToken;
window.MetaLab.csrfToken = typeof getCSRFToken === 'function' ? getCSRFToken : function () { return ''; };
// ---- Auto-attach X-CSRF-Token to all state-changing requests ----
// Monkey-patch XMLHttpRequest and fetch to inject CSRF header
@ -31,7 +31,10 @@
var refreshPromise = null;
var pendingRetries = [];
// 优先从 cookie 读取 CSRF token与服务端验证源一致meta 标签作为后备
function resolveCSRFToken() {
var cookieMatch = document.cookie.match(/(?:^|;\s*)mlb_csrf=([^;]*)/);
if (cookieMatch && cookieMatch[1]) return cookieMatch[1];
var meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : '';
}
@ -92,7 +95,11 @@
}
// 401 interception for XHR
// 保存原始 onreadystatechange 后立即清除,防止原生属性重复触发
// 否则浏览器原生 onreadystatechange 和 addEventListener 监听器会各触发一次
// 导致 Vditor 图片上传等场景下图片被插入两次
var origOnReady = xhr.onreadystatechange;
xhr.onreadystatechange = null;
xhr.addEventListener('readystatechange', function () {
if (xhr.readyState === 4 && xhr.status === 401 &&
xhr._url !== '/api/auth/refresh' &&

View File

@ -17,9 +17,10 @@ let draftTimer = null
let submitting = false
// ---- CSRF Token ----
// 直接从 cookie 读取 mlb_csrfhttpOnly=false确保与服务端验证时读取的值一致
function getCsrfToken() {
const meta = document.querySelector('meta[name="csrf-token"]')
return meta ? meta.getAttribute('content') : ''
var match = document.cookie.match(/(?:^|;\s*)mlb_csrf=([^;]*)/)
return match ? match[1] : ''
}
// ---- 初始化 ----
@ -63,6 +64,10 @@ document.addEventListener('DOMContentLoaded', () => {
'outline', 'preview', 'devtools',
],
// Vditor 3.11.2 中 highlightToolbarWYSIWYG 直接调用此回调
// 不提供会导致 TypeError: not a function所有弹窗代码块语言等都不可用
customWysiwygToolbar(type, popover) {},
// 计数器
counter: {
enable: true,
@ -94,27 +99,38 @@ document.addEventListener('DOMContentLoaded', () => {
// 大纲
outline: {
enable: true,
enable: false,
position: 'right',
},
// 预览配置
// 注意WYSIWYG 编辑模式下代码块不做语法高亮Vditor 3.11.2 源码中
// highlightRender 会跳过 .vditor-wysiwyg__pre仅在预览模式/详情页渲染)
// langs 确保语言选择下拉始终可用,不受 hljs 异步加载时机影响
preview: {
theme: { current: 'light', path: '/static/vditor/dist/css/content-theme' },
hljs: { style: 'github-dark', enable: true },
hljs: {
style: 'github-dark',
enable: true,
langs: [
'javascript', 'typescript', 'python', 'java', 'go', 'rust',
'c', 'cpp', 'csharp', 'php', 'ruby', 'swift', 'kotlin',
'html', 'css', 'scss', 'xml', 'json', 'yaml', 'markdown',
'sql', 'bash', 'shell', 'powershell', 'dockerfile', 'nginx',
'diff', 'http', 'graphql', 'makefile',
],
},
markdown: { codeBlockPreview: true },
},
// 图片上传
// CSRF token 由 common.js 的全局 XHR monkey-patch 自动注入,此处无需重复设置
// 重复设置会导致浏览器将 header 合并为 "TOKEN, TOKEN" 从而验证失败
upload: {
url: '/api/posts/upload-image',
fieldName: 'file',
max: 5 * 1024 * 1024,
accept: 'image/jpg,image/jpeg,image/png,image/gif,image/webp',
// setHeaders 为函数,每次上传前重新读取 CSRF token
setHeaders() {
return { 'X-CSRF-Token': getCsrfToken() }
},
},
// 初始内容