+ {post.pinned && (
+
+
+
+ )}
+ {post.featured && }
{post.status === 'pending' && 审核中}
{post.status === 'rejected' && 未通过}
- {post.featured && }
- {post.pinned && }
+ {post.post_type === 'question' && (
+
+ {post.question_resolved ? '已解决' : '未解决'}
+
+ )}
{post.title}
@@ -577,110 +644,121 @@ export default function PostDetailPage() {
/>
-
-
-
- {user && user.id !== post.user_id && (
-
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index 293f91c..063d114 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -1078,10 +1078,11 @@ img.site-brand-logo-img {
}
.article-outline-item {
+ position: relative;
display: block;
width: 100%;
margin: 0;
- padding: 7px 10px;
+ padding: 7px 10px 7px 12px;
border: none;
border-radius: 6px;
background: transparent;
@@ -1093,8 +1094,7 @@ img.site-brand-logo-img {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
- border-left: 2px solid transparent;
- transition: background 0.12s, color 0.12s, border-color 0.12s;
+ transition: background 0.12s, color 0.12s;
}
.article-outline-item:hover {
@@ -1106,14 +1106,26 @@ img.site-brand-logo-img {
background: var(--j13-green-bg);
color: var(--j13-green);
font-weight: 600;
- border-left-color: var(--j13-green);
}
-.article-outline-item--l2 { padding-left: 18px; font-size: 12.5px; }
-.article-outline-item--l3 { padding-left: 26px; font-size: 12px; color: var(--color-text-3); }
+/* 当前节:左侧短竖条,呼应正文标题绿横线 */
+.article-outline-item.active::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 3px;
+ height: 14px;
+ background: var(--j13-green);
+ border-radius: 1px;
+}
+
+.article-outline-item--l2 { padding-left: 20px; font-size: 12.5px; }
+.article-outline-item--l3 { padding-left: 28px; font-size: 12px; color: var(--color-text-3); }
.article-outline-item--l4,
.article-outline-item--l5,
-.article-outline-item--l6 { padding-left: 34px; font-size: 12px; color: var(--color-text-3); }
+.article-outline-item--l6 { padding-left: 36px; font-size: 12px; color: var(--color-text-3); }
.article-outline-item--l3.active,
.article-outline-item--l4.active,
@@ -1126,6 +1138,33 @@ img.site-brand-logo-img {
scroll-margin-top: 16px;
}
+/* 标题旁 #:hover 显现,点击复制锚点链接 */
+.post-detail-content .post-heading-anchor-link {
+ margin-left: 0.4em;
+ font-weight: 500;
+ font-size: 0.82em;
+ line-height: 1;
+ color: var(--j13-green);
+ text-decoration: none !important;
+ opacity: 0;
+ transition: opacity 0.2s ease;
+ vertical-align: 0.05em;
+}
+
+.post-detail-content h1:hover .post-heading-anchor-link,
+.post-detail-content h2:hover .post-heading-anchor-link,
+.post-detail-content h3:hover .post-heading-anchor-link,
+.post-detail-content h4:hover .post-heading-anchor-link,
+.post-detail-content h5:hover .post-heading-anchor-link,
+.post-detail-content h6:hover .post-heading-anchor-link,
+.post-detail-content .post-heading-anchor-link:focus-visible {
+ opacity: 0.75;
+}
+
+.post-detail-content .post-heading-anchor-link:hover {
+ opacity: 1 !important;
+}
+
.post-detail-toc-mobile {
margin: 0 0 16px;
padding: 10px 12px;
@@ -1827,6 +1866,10 @@ img.site-brand-logo-img {
padding-bottom: 8px;
}
+.feed-head--stats-only .feed-head__title {
+ align-items: center;
+}
+
.feed-head__title {
display: flex;
align-items: baseline;
@@ -2221,9 +2264,28 @@ img.site-brand-logo-img {
}
.post-pin-badge {
- color: var(--j13-green);
- background: var(--j13-green-bg);
- border: 1px solid color-mix(in srgb, var(--j13-green) 16%, transparent);
+ color: #dc2626;
+ background: rgba(220, 38, 38, 0.1);
+ border: 1px solid rgba(220, 38, 38, 0.22);
+}
+
+.post-pin-badge--icon {
+ width: 20px;
+ padding: 0;
+ justify-content: center;
+}
+
+.post-pin-badge--detail {
+ width: 24px;
+ height: 24px;
+ margin-right: 8px;
+ vertical-align: -3px;
+}
+
+.dark .post-pin-badge {
+ color: #f87171;
+ background: rgba(248, 113, 113, 0.14);
+ border-color: rgba(248, 113, 113, 0.28);
}
.post-status-badge {
@@ -2249,6 +2311,39 @@ img.site-brand-logo-img {
border: 1px solid rgba(244, 63, 94, 0.2);
}
+/* 问答帖:未解决 / 已解决 */
+.post-qa-badge {
+ display: inline-flex;
+ align-items: center;
+ height: 20px;
+ padding: 0 7px;
+ border-radius: 4px;
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 1;
+ vertical-align: middle;
+}
+
+.post-qa-badge--detail {
+ margin-right: 8px;
+ height: 22px;
+ padding: 0 8px;
+ font-size: 12px;
+}
+
+.post-qa-badge--open {
+ color: #c2410c;
+ background: rgba(234, 88, 12, 0.1);
+ border: 1px solid rgba(234, 88, 12, 0.22);
+}
+
+/* 已解决:绿色 */
+.post-qa-badge--resolved {
+ color: var(--j13-green);
+ background: var(--j13-green-bg);
+ border: 1px solid color-mix(in srgb, var(--j13-green) 20%, transparent);
+}
+
.post-moderation-banner {
margin: 0 0 12px;
padding: 10px 14px;
@@ -2298,18 +2393,34 @@ img.site-brand-logo-img {
vertical-align: 0;
}
+.post-title-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+}
+
+.post-title-row .post-qa-badge,
+.post-title-row .post-pin-badge,
+.post-title-row .post-feature-badge,
+.post-title-row .post-status-badge {
+ flex-shrink: 0;
+}
+
.post-title {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
- font-size: 14px;
+ font-size: 15px;
font-weight: 500;
- line-height: 1.45;
+ line-height: 1.4;
font-family: inherit;
color: var(--color-text-1);
text-decoration: none;
transition: color 0.15s;
+ min-width: 0;
+ flex: 1;
}
a.post-title:visited {
@@ -3588,8 +3699,8 @@ a.post-title:visited {
.post-detail-actions {
display: flex;
- flex-wrap: wrap;
- justify-content: center;
+ flex-direction: column;
+ align-items: center;
gap: 12px;
margin-top: 28px;
padding-top: 20px;
@@ -3599,6 +3710,21 @@ a.post-title:visited {
position: relative;
}
+.post-detail-actions-primary,
+.post-detail-actions-manage {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ align-items: center;
+ gap: 8px;
+}
+
+.post-detail-actions-manage {
+ width: 100%;
+ padding-top: 12px;
+ border-top: 1px dashed var(--j13-border-light);
+}
+
.post-detail-content {
font-size: 15.5px;
line-height: 1.8;
@@ -3622,6 +3748,26 @@ a.post-title:visited {
margin: 1.55em 0 0.65em;
}
+/*
+ * 标题下方绿色短横线:用文档流 block 而非 absolute+bottom,
+ * 避免空标题 / 有内容时行盒高度不同导致「忽高忽矮」;
+ * 高度一律整数 px,避免桌面非整数 DPR 下亚像素取整忽粗忽细。
+ */
+.post-detail-content h1::after,
+.post-detail-content h2::after,
+.post-detail-content h3::after,
+.post-detail-content h4::after,
+.post-detail-content h5::after,
+.post-detail-content h6::after {
+ content: '';
+ display: block;
+ box-sizing: border-box;
+ margin-top: 0.38em;
+ background: var(--j13-green);
+ border-radius: 1px;
+ transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
.post-detail-content > :first-child,
.post-detail-content h1:first-child,
.post-detail-content h2:first-child,
@@ -3634,22 +3780,35 @@ a.post-title:visited {
.post-detail-content h1 {
font-size: 1.7em;
- padding-bottom: 0.35em;
- border-bottom: 1px solid var(--j13-border-light);
}
+.post-detail-content h1::after { width: 48px; height: 3px; }
+.post-detail-content h1:hover::after { width: 72px; }
+
.post-detail-content h2 {
font-size: 1.4em;
- padding-bottom: 0.28em;
- border-bottom: 1px solid var(--j13-border-light);
}
+.post-detail-content h2::after { width: 40px; height: 2px; }
+.post-detail-content h2:hover::after { width: 60px; }
+
.post-detail-content h3 { font-size: 1.22em; }
+.post-detail-content h3::after { width: 32px; height: 2px; }
+.post-detail-content h3:hover::after { width: 48px; }
+
.post-detail-content h4 { font-size: 1.1em; }
+.post-detail-content h4::after { width: 26px; height: 2px; }
+.post-detail-content h4:hover::after { width: 40px; }
+
.post-detail-content h5 { font-size: 1em; }
+.post-detail-content h5::after { width: 20px; height: 1px; }
+.post-detail-content h5:hover::after { width: 32px; }
+
.post-detail-content h6 {
font-size: 0.92em;
color: var(--color-text-2);
font-weight: 600;
}
+.post-detail-content h6::after { width: 16px; height: 1px; }
+.post-detail-content h6:hover::after { width: 26px; }
.post-detail-content ul,
.post-detail-content ol {
@@ -4035,6 +4194,15 @@ a.post-title:visited {
.post-detail-content p { margin: 0 0 1em; }
.post-detail-content p:last-child { margin-bottom: 0; }
+
+/* 正文首段轻微强调(仅当首个子节点就是段落时) */
+.post-detail-content > p:first-child {
+ font-size: 1.06em;
+ line-height: 1.75;
+ letter-spacing: 0.015em;
+ color: var(--color-text-1);
+}
+
.post-detail-content a {
color: var(--j13-green);
text-decoration: underline;
@@ -4051,19 +4219,49 @@ a.post-title:visited {
background: color-mix(in srgb, var(--j13-green) 6%, var(--j13-bg-block-muted));
color: var(--color-text-2);
border-radius: 0 10px 10px 0;
+ transition:
+ border-left-width 0.28s cubic-bezier(0.4, 0, 0.2, 1),
+ background 0.28s ease,
+ padding-left 0.28s cubic-bezier(0.4, 0, 0.2, 1);
+}
+.post-detail-content blockquote:hover {
+ border-left-width: 5px;
+ padding-left: calc(1em - 2px);
+ background: color-mix(in srgb, var(--j13-green) 11%, var(--j13-bg-block-muted));
}
.post-detail-content blockquote p:last-child { margin-bottom: 0; }
+/* 表格:按内容收缩,边框与表头背景同宽;宽表由外包层横向滚动 */
+.post-detail-content .md-table-wrap {
+ display: block;
+ width: fit-content;
+ max-width: 100%;
+ margin: 1.1em 0;
+ overflow-x: auto;
+ border: 1px solid var(--j13-border-light);
+ border-radius: 10px;
+ background: var(--j13-bg-surface);
+}
+
.post-detail-content table {
- width: 100%;
+ width: auto;
+ max-width: 100%;
margin: 1.1em 0;
border-collapse: collapse;
font-size: 14px;
- overflow: hidden;
+ display: table;
border: 1px solid var(--j13-border-light);
border-radius: 10px;
- display: block;
- overflow-x: auto;
+ overflow: hidden;
+ background: var(--j13-bg-surface);
+}
+
+.post-detail-content .md-table-wrap table {
+ margin: 0;
+ max-width: none;
+ border: none;
+ border-radius: 0;
+ overflow: visible;
}
.post-detail-content th,
.post-detail-content td {
@@ -4816,10 +5014,6 @@ a.waline-comment-author:hover { color: var(--j13-green); }
margin-top: 8px;
}
-.post-action-guest {
- opacity: 0.78;
-}
-
@media (max-width: 768px) {
.comment-box-wrap { padding: 12px 14px; }
.comment-box-guest-fields { grid-template-columns: 1fr; }
@@ -4859,6 +5053,8 @@ a.waline-comment-author:hover { color: var(--j13-green); }
.post-detail-header { padding: 12px 14px 16px; }
.post-detail-title { font-size: 17px; }
.post-detail-actions { margin-top: 20px; padding-top: 16px; gap: 10px; }
+ .post-detail-actions-primary,
+ .post-detail-actions-manage { gap: 8px; }
.comment-section-bar { padding: 12px 14px 10px; }
.comment-section-bar::after { left: 14px; width: 32px; }
.comment-section:hover .comment-section-bar::after { width: 46px; }
@@ -5852,20 +6048,19 @@ button.profile-stat:hover strong {
}
.main-content--compose {
- overflow-y: auto;
- overflow-x: hidden;
+ /* 编辑页占满主栏高度;滚动交给编辑区内层,底栏可始终贴底 */
+ overflow: hidden;
background: var(--j13-bg-workspace);
}
.compose-page {
- /* 与 .compose-header 实际高度对齐,供编辑器工具栏 sticky 偏移 */
+ /* 与 .compose-header 实际高度对齐(全屏等场景仍可能用到) */
--compose-header-sticky-h: 57px;
- /* 随正文增高,避免白底被视口高度裁切、粘性顶栏失效 */
- flex: 1 0 auto;
+ flex: 1;
+ min-height: 0;
display: flex;
flex-direction: column;
- min-height: 100%;
- overflow: visible;
+ overflow: hidden;
background: var(--j13-bg-workspace);
}
@@ -5932,16 +6127,17 @@ button.profile-stat:hover strong {
}
.compose-canvas {
- flex: 1 0 auto;
+ flex: 1;
+ min-height: 0;
display: flex;
flex-direction: column;
- min-height: 100%;
/* 富文本:接近正文阅读宽度,避免宽行/表格撑出右侧;源码双栏另加宽 */
max-width: calc(var(--j13-article-read-w) + 64px);
width: 100%;
margin: 0 auto;
- padding: 16px 20px 24px;
+ padding: 16px 20px 16px;
box-sizing: border-box;
+ overflow: hidden;
transition: max-width 0.2s ease;
}
@@ -5950,11 +6146,10 @@ button.profile-stat:hover strong {
}
.compose-shell {
- flex: 1 0 auto;
+ flex: 1;
+ min-height: 0;
display: flex;
flex-direction: column;
- /* 短文填满可视区;长文随内容增高(勿用 min-height:0,否则白底被裁切) */
- min-height: 100%;
min-width: 0;
max-width: 100%;
width: 100%;
@@ -5962,24 +6157,23 @@ button.profile-stat:hover strong {
border: 1px solid var(--j13-border-light);
border-radius: 12px;
box-shadow: var(--j13-shadow-card);
- /* 顶栏 sticky 需 visible;横向溢出由 .compose-shell-body 承接 */
- overflow: visible;
+ overflow: hidden;
}
-/* 正文区单独限宽,避免表格/长词撑出白底 */
+/* 正文区:限宽 + 纵向填满,横向溢出由内层编辑区承接 */
.compose-shell-body {
- flex: 1 0 auto;
+ flex: 1;
+ min-height: 0;
display: flex;
flex-direction: column;
min-width: 0;
max-width: 100%;
- overflow-x: auto;
+ overflow: hidden;
border-radius: 0 0 12px 12px;
}
.compose-header {
- position: sticky;
- top: 0;
+ position: relative;
z-index: 40;
display: flex;
align-items: center;
@@ -5990,7 +6184,6 @@ button.profile-stat:hover strong {
background: var(--j13-bg-surface);
border-bottom: 1px solid var(--j13-border-light);
border-radius: 12px 12px 0 0;
- /* 长文下滚时顶栏与正文分层更清晰 */
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.03);
}
@@ -6083,6 +6276,7 @@ button.profile-stat:hover strong {
padding: 14px 20px;
border-bottom: 1px solid var(--j13-border-light);
background: var(--j13-bg-surface);
+ flex-shrink: 0;
}
.compose-context-row {
@@ -6117,7 +6311,21 @@ button.profile-stat:hover strong {
min-width: 0;
}
-.compose-board-pill {
+.compose-type-pills {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ min-width: 0;
+}
+
+.compose-type-hint {
+ font-size: 12px;
+ color: var(--color-text-3);
+ align-self: center;
+}
+
+.compose-board-pill,
+.compose-type-pill {
padding: 5px 12px;
border: 1px solid var(--j13-border);
border-radius: 999px;
@@ -6129,13 +6337,15 @@ button.profile-stat:hover strong {
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
-.compose-board-pill:hover {
+.compose-board-pill:hover,
+.compose-type-pill:hover {
border-color: var(--j13-green);
color: var(--j13-green);
background: var(--j13-green-bg);
}
-.compose-board-pill.active {
+.compose-board-pill.active,
+.compose-type-pill.active {
background: var(--j13-green);
border-color: var(--j13-green);
color: #fff;
@@ -6270,12 +6480,14 @@ button.profile-stat:hover strong {
}
.compose-document {
- flex: 1 0 auto;
+ flex: 1;
+ min-height: 0;
display: flex;
flex-direction: column;
min-width: 0;
max-width: 100%;
padding: 0;
+ overflow: hidden;
}
.compose-title {
@@ -6290,6 +6502,7 @@ button.profile-stat:hover strong {
letter-spacing: -0.01em;
color: var(--color-text-1);
outline: none;
+ flex-shrink: 0;
}
.compose-title::placeholder {
@@ -6310,18 +6523,18 @@ button.profile-stat:hover strong {
/* 文章编辑器 */
.article-editor {
- flex: 1 0 auto;
+ flex: 1;
+ min-height: 0;
display: flex;
flex-direction: column;
min-width: 0;
max-width: 100%;
border-top: 1px solid var(--j13-border-light);
+ overflow: hidden;
}
.article-editor-bar {
- position: sticky;
- /* 停在发布栏下方,避免与 .compose-header 重叠 */
- top: var(--compose-header-sticky-h, 0px);
+ position: relative;
z-index: 30;
display: flex;
align-items: center;
@@ -6481,31 +6694,31 @@ button.profile-stat:hover strong {
}
.article-editor-body {
- flex: 1 0 auto;
+ flex: 1;
+ min-height: 0;
display: flex;
flex-direction: column;
min-width: 0;
max-width: 100%;
- min-height: 280px;
+ overflow: hidden;
}
/* 编辑面板 */
.article-editor-pane {
display: flex;
- flex: 1 0 auto;
+ flex: 1;
min-width: 0;
max-width: 100%;
- min-height: 320px;
+ min-height: 0;
border: none;
border-radius: 0;
- /* 普通模式随正文增高,由外层 .main-content--compose 滚动 */
- overflow: visible;
+ overflow: hidden;
background: transparent;
box-shadow: none;
}
.article-editor-pane--source {
- min-height: 320px;
+ min-height: 0;
border: 1px solid var(--j13-border-light);
border-radius: 12px;
background: var(--j13-bg-block);
@@ -6536,11 +6749,11 @@ button.profile-stat:hover strong {
}
.article-editor-scroll {
- flex: 1 0 auto;
+ flex: 1;
min-width: 0;
+ min-height: 0;
max-width: 100%;
- overflow-x: auto;
- overflow-y: visible;
+ overflow: auto;
background: transparent;
}
@@ -6550,6 +6763,7 @@ button.profile-stat:hover strong {
.article-editor-content {
flex: 1;
+ min-height: 0;
display: flex;
flex-direction: column;
}
@@ -6559,7 +6773,7 @@ button.profile-stat:hover strong {
flex: 1;
min-width: 0;
max-width: 100%;
- min-height: 360px;
+ min-height: 200px;
padding: 16px 24px 32px;
outline: none;
font-size: 15.5px;
@@ -6587,11 +6801,11 @@ button.profile-stat:hover strong {
/* 源码双栏:充分利用加宽画布 */
.article-editor--markdown .article-editor-body {
- min-height: 480px;
+ min-height: 0;
}
.compose-page:has(.article-editor--markdown) .article-editor-markdown {
- min-height: min(70vh, 720px);
+ min-height: 0;
}
.article-prosemirror p.is-editor-empty:first-child::before {
@@ -6894,8 +7108,7 @@ button.profile-stat:hover strong {
}
.article-editor-status {
- position: sticky;
- bottom: 0;
+ position: relative;
z-index: 30;
display: flex;
align-items: center;
@@ -6905,7 +7118,7 @@ button.profile-stat:hover strong {
padding: 10px 20px;
border-top: 1px solid var(--j13-border-light);
background: var(--j13-bg-surface);
- box-shadow: none;
+ box-shadow: 0 -4px 12px rgba(15, 23, 42, 0.04);
font-size: 12px;
color: var(--color-text-4);
flex-shrink: 0;
@@ -7062,18 +7275,21 @@ button.profile-stat:hover strong {
.article-editor-markdown {
flex: 1;
+ min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 0;
- min-height: 420px;
padding: 0;
border-top: none;
+ overflow: hidden;
}
.article-editor--markdown .article-editor-pane--source {
border: none;
border-radius: 0;
border-right: 1px solid var(--j13-border-light);
+ min-height: 0;
+ height: 100%;
}
.article-editor--markdown .article-editor-markdown-preview {
@@ -7099,7 +7315,8 @@ button.profile-stat:hover strong {
.article-editor-markdown-preview {
display: flex;
flex-direction: column;
- min-height: 320px;
+ min-height: 0;
+ height: 100%;
background: var(--j13-bg-surface);
border: 1px solid var(--j13-border);
border-radius: 10px;
@@ -7186,7 +7403,7 @@ button.profile-stat:hover strong {
.article-editor-markdown {
grid-template-columns: 1fr;
- grid-template-rows: minmax(240px, 1fr) minmax(240px, 1fr);
+ grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
}
.article-editor--markdown .article-editor-pane--source {
@@ -7195,7 +7412,7 @@ button.profile-stat:hover strong {
}
.article-editor-pane--source {
- min-height: 240px;
+ min-height: 0;
}
.article-editor--fullscreen.article-editor--rich .article-editor-pane {
@@ -7209,7 +7426,7 @@ button.profile-stat:hover strong {
.article-editor-content .tiptap,
.article-prosemirror {
- min-height: 280px;
+ min-height: 160px;
padding: 12px 14px 20px;
}
diff --git a/frontend/src/utils/postContent.ts b/frontend/src/utils/postContent.ts
index cf65530..72fea82 100644
--- a/frontend/src/utils/postContent.ts
+++ b/frontend/src/utils/postContent.ts
@@ -132,10 +132,22 @@ export function renderPostContentHtml(
enhanceHeadingAnchors(doc.body);
enhanceCodeBlocks(doc.body);
+ wrapContentTables(doc.body);
return doc.body.innerHTML;
}
+/** 表格外包一层,避免 display:block 时边框撑满而单元格背景偏窄 */
+function wrapContentTables(root: ParentNode): void {
+ root.querySelectorAll('table').forEach(table => {
+ if (table.parentElement?.classList.contains('md-table-wrap')) return;
+ const wrap = table.ownerDocument.createElement('div');
+ wrap.className = 'md-table-wrap';
+ table.replaceWith(wrap);
+ wrap.appendChild(table);
+ });
+}
+
function isFloatDisplayImage(el: Element): boolean {
if (el.tagName !== 'IMG') return false;
const display = el.getAttribute('data-display') || '';
diff --git a/frontend/src/utils/postHeadings.ts b/frontend/src/utils/postHeadings.ts
index 73335cb..a002ebc 100644
--- a/frontend/src/utils/postHeadings.ts
+++ b/frontend/src/utils/postHeadings.ts
@@ -5,13 +5,21 @@ export interface PostHeading {
text: string;
}
+/** 去掉标题内的锚点复制链,避免目录文案带上 # */
+function headingPlainText(el: Element): string {
+ const clone = el.cloneNode(true) as HTMLElement;
+ clone.querySelectorAll('.post-heading-anchor-link').forEach(n => n.remove());
+ return (clone.textContent || '').replace(/\s+/g, ' ').trim();
+}
+
/** 为标题补全锚点 id,并返回目录树数据 */
export function enhanceHeadingAnchors(root: ParentNode): PostHeading[] {
const headings: PostHeading[] = [];
const used = new Map
();
+ const doc = root.ownerDocument ?? (typeof document !== 'undefined' ? document : null);
root.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach((el, index) => {
- const text = (el.textContent || '').replace(/\s+/g, ' ').trim();
+ const text = headingPlainText(el);
if (!text) return;
const level = Number(el.tagName.slice(1)) || 2;
@@ -25,6 +33,18 @@ export function enhanceHeadingAnchors(root: ParentNode): PostHeading[] {
el.setAttribute('id', id);
el.classList.add('post-heading-anchor');
+ // hover 显示 #,点击复制带 hash 的链接
+ if (doc && !el.querySelector('.post-heading-anchor-link')) {
+ const link = doc.createElement('a');
+ link.className = 'post-heading-anchor-link';
+ link.href = `#${id}`;
+ link.setAttribute('aria-label', '复制本节链接');
+ link.setAttribute('data-heading-copy', id);
+ link.setAttribute('tabindex', '-1');
+ link.textContent = '#';
+ el.appendChild(link);
+ }
+
headings.push({ id, level, text });
});
@@ -38,7 +58,7 @@ export function extractHeadingsFromHtml(html: string): PostHeading[] {
const headings: PostHeading[] = [];
doc.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach(el => {
const id = el.getAttribute('id')?.trim();
- const text = (el.textContent || '').replace(/\s+/g, ' ').trim();
+ const text = headingPlainText(el);
if (!id || !text) return;
headings.push({
id,
diff --git a/handler/handlers.go b/handler/handlers.go
index b417050..b883f03 100644
--- a/handler/handlers.go
+++ b/handler/handlers.go
@@ -313,7 +313,8 @@ func (h *Handlers) APICreatePost(c *gin.Context) {
title := c.PostForm("title")
content := c.PostForm("content")
tags := c.PostForm("tags")
- post, err := h.Post.Create(h.currentUserID(c), uint(boardID), title, content, tags, h.isAdmin(c))
+ postType := c.PostForm("post_type")
+ post, err := h.Post.Create(h.currentUserID(c), uint(boardID), title, content, tags, postType, h.isAdmin(c))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -329,7 +330,7 @@ func (h *Handlers) APIUpdatePost(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
boardID, _ := strconv.ParseUint(c.PostForm("board_id"), 10, 64)
err := h.Post.Update(h.currentUserID(c), uint(id), h.isAdmin(c),
- c.PostForm("title"), c.PostForm("content"), c.PostForm("tags"), uint(boardID))
+ c.PostForm("title"), c.PostForm("content"), c.PostForm("tags"), c.PostForm("post_type"), uint(boardID))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -368,6 +369,21 @@ func (h *Handlers) APIToggleFavorite(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"favorited": faved})
}
+// APISetQuestionResolved 标记问答帖已解决 / 未解决
+func (h *Handlers) APISetQuestionResolved(c *gin.Context) {
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ resolved := c.PostForm("resolved") == "1" || c.PostForm("resolved") == "true"
+ if err := h.Post.SetQuestionResolved(h.currentUserID(c), uint(id), h.isAdmin(c), resolved); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ msg := "已标记为未解决"
+ if resolved {
+ msg = "已标记为已解决"
+ }
+ c.JSON(http.StatusOK, gin.H{"message": msg, "question_resolved": resolved})
+}
+
func (h *Handlers) APICreateComment(c *gin.Context) {
postID, _ := strconv.ParseUint(c.Param("id"), 10, 64)
content := c.PostForm("content")
diff --git a/model/db.go b/model/db.go
index 65a3b99..a70f277 100644
--- a/model/db.go
+++ b/model/db.go
@@ -47,6 +47,7 @@ func InitDB(dbPath string) error {
// 存量数据默认视为已公开,避免升级后内容全部进入待审
_ = db.Model(&Post{}).Where("status = '' OR status IS NULL").Update("status", ContentStatusPublished).Error
_ = db.Model(&Comment{}).Where("status = '' OR status IS NULL").Update("status", ContentStatusPublished).Error
+ _ = db.Model(&Post{}).Where("post_type = '' OR post_type IS NULL").Update("post_type", PostTypeNormal).Error
DB = db
log.Println("[model] SQLite 数据库初始化完成:", dbPath)
diff --git a/model/models.go b/model/models.go
index 25bb5b4..0d6138b 100644
--- a/model/models.go
+++ b/model/models.go
@@ -21,6 +21,12 @@ const (
ContentStatusRejected = "rejected" // 未通过(仅作者与管理员可见)
)
+// 帖子类型
+const (
+ PostTypeNormal = "normal" // 普通讨论
+ PostTypeQuestion = "question" // 问答(未解决 / 已解决)
+)
+
// User 用户表
// Email / Password / LastLogin* 默认不随帖子等嵌套 User 序列化;
// 个人中心与后台列表请用 UserSelf / UserAdmin。
@@ -64,6 +70,8 @@ type Post struct {
Content string `gorm:"type:text;not null" json:"content"`
ContentPlain string `gorm:"type:text" json:"-"` // 正文纯文本,供搜索索引
Tags string `gorm:"size:256" json:"tags"`
+ PostType string `gorm:"size:16;default:normal;index" json:"post_type"` // normal|question
+ QuestionResolved bool `gorm:"default:false;index" json:"question_resolved"` // 仅 question 有意义
Pinned bool `gorm:"default:false" json:"pinned"`
Featured bool `gorm:"default:false;index" json:"featured"` // 精华帖
EditLocked bool `gorm:"default:false" json:"edit_locked"`
diff --git a/router/router.go b/router/router.go
index 74efda4..0e04f8f 100644
--- a/router/router.go
+++ b/router/router.go
@@ -146,6 +146,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
api.GET("/posts/:id/revisions/:revId", h.APIPostRevisionDetail)
api.POST("/posts/:id/like", h.APIToggleLike)
api.POST("/posts/:id/favorite", h.APIToggleFavorite)
+ api.POST("/posts/:id/resolve", h.APISetQuestionResolved)
api.POST("/posts/:id/report", middleware.RateLimitMiddleware(limiter, "report"), h.APICreatePostReport)
api.GET("/messages/unread-count", h.APIMessageUnreadCount)
api.GET("/messages/conversations", h.APIMessageConversations)
diff --git a/service/post.go b/service/post.go
index c2352da..b47cd21 100644
--- a/service/post.go
+++ b/service/post.go
@@ -19,6 +19,15 @@ func NewPostService(filter *SensitiveFilter, settings *ForumSettingsService) *Po
return &PostService{filter: filter, settings: settings}
}
+func normalizePostType(raw string) string {
+ switch strings.TrimSpace(raw) {
+ case model.PostTypeQuestion:
+ return model.PostTypeQuestion
+ default:
+ return model.PostTypeNormal
+ }
+}
+
type PostListQuery struct {
BoardID uint
UserID uint // >0 时仅返回该用户的帖子
@@ -321,10 +330,11 @@ func (s *PostService) GetByID(id uint) (*model.Post, error) {
return post, nil
}
-func (s *PostService) Create(userID, boardID uint, title, content, tags string, isAdmin bool) (*model.Post, error) {
+func (s *PostService) Create(userID, boardID uint, title, content, tags, postType string, isAdmin bool) (*model.Post, error) {
title = s.filter.Filter(strings.TrimSpace(title))
content = s.filter.Filter(content)
tags = s.filter.Filter(strings.TrimSpace(tags))
+ postType = normalizePostType(postType)
if title == "" || content == "" {
return nil, errors.New("标题和内容不能为空")
}
@@ -345,19 +355,22 @@ func (s *PostService) Create(userID, boardID uint, title, content, tags string,
status = model.ContentStatusPublished
}
post := &model.Post{
- BoardID: boardID,
- UserID: userID,
- Title: title,
- Content: content,
- ContentPlain: StripHTMLForSearch(content),
- Tags: tags,
- Status: status,
+ BoardID: boardID,
+ UserID: userID,
+ Title: title,
+ Content: content,
+ ContentPlain: StripHTMLForSearch(content),
+ Tags: tags,
+ PostType: postType,
+ QuestionResolved: false,
+ Status: status,
}
return post, model.DB.Create(post).Error
}
// Update 更新帖子。boardID>0 时可改板块;为 0 时保持原板块。
-func (s *PostService) Update(userID, postID uint, isAdmin bool, title, content, tags string, boardID uint) error {
+// postType 为空时保持原类型;改为非 question 时清除已解决标记。
+func (s *PostService) Update(userID, postID uint, isAdmin bool, title, content, tags, postType string, boardID uint) error {
var post model.Post
if err := model.DB.First(&post, postID).Error; err != nil {
return ErrPostNotFound
@@ -387,6 +400,14 @@ func (s *PostService) Update(userID, postID uint, isAdmin bool, title, content,
}
nextBoardID = boardID
}
+ nextType := post.PostType
+ if strings.TrimSpace(postType) != "" {
+ nextType = normalizePostType(postType)
+ }
+ nextResolved := post.QuestionResolved
+ if nextType != model.PostTypeQuestion {
+ nextResolved = false
+ }
return model.DB.Transaction(func(tx *gorm.DB) error {
rev := model.PostRevision{
PostID: postID, EditorID: userID,
@@ -396,11 +417,13 @@ func (s *PostService) Update(userID, postID uint, isAdmin bool, title, content,
return err
}
updates := map[string]interface{}{
- "board_id": nextBoardID,
- "title": title,
- "content": content,
- "content_plain": StripHTMLForSearch(content),
- "tags": tags,
+ "board_id": nextBoardID,
+ "title": title,
+ "content": content,
+ "content_plain": StripHTMLForSearch(content),
+ "tags": tags,
+ "post_type": nextType,
+ "question_resolved": nextResolved,
}
// 普通用户修改后重新进入审核
if !isAdmin {
@@ -659,6 +682,21 @@ func (s *PostService) SetFeatured(postID uint, featured bool) error {
return model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("featured", featured).Error
}
+// SetQuestionResolved 标记问答帖已解决 / 未解决(作者或管理员)
+func (s *PostService) SetQuestionResolved(userID, postID uint, isAdmin bool, resolved bool) error {
+ var post model.Post
+ if err := model.DB.First(&post, postID).Error; err != nil {
+ return ErrPostNotFound
+ }
+ if !isAdmin && post.UserID != userID {
+ return ErrPermissionDenied
+ }
+ if post.PostType != model.PostTypeQuestion {
+ return errors.New("仅问答帖可标记解决状态")
+ }
+ return model.DB.Model(&post).Update("question_resolved", resolved).Error
+}
+
func (s *PostService) ToggleLike(userID, postID uint) (liked bool, err error) {
var like model.PostLike
result := model.DB.Where("post_id = ? AND user_id = ?", postID, userID).Limit(1).Find(&like)