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:
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
313
templates/MetaLab-2026/static/css/space.css
Normal file
313
templates/MetaLab-2026/static/css/space.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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' &&
|
||||
|
||||
@ -17,9 +17,10 @@ let draftTimer = null
|
||||
let submitting = false
|
||||
|
||||
// ---- CSRF Token ----
|
||||
// 直接从 cookie 读取 mlb_csrf(httpOnly=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() }
|
||||
},
|
||||
},
|
||||
|
||||
// 初始内容
|
||||
|
||||
Reference in New Issue
Block a user