From b24f6c23ea1ba393881049c985141396b4460ddb Mon Sep 17 00:00:00 2001 From: freefire Date: Tue, 4 Aug 2026 00:19:32 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=B8=96=E5=AD=90=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E4=B8=8E=E5=88=97=E8=A1=A8=E5=B1=95=E7=A4=BA=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E6=96=B0=E5=A2=9E=E9=97=AE=E7=AD=94=E5=B8=96=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E4=B8=8E=E5=B7=B2=E8=A7=A3=E5=86=B3=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一标题锚点定位、编辑底栏固定与操作栏分层,列表徽章前置并区分置顶/问答配色。 Co-authored-by: Cursor --- frontend/src/api/client.ts | 17 +- frontend/src/api/types.ts | 4 + frontend/src/components/FeedHeader.tsx | 15 +- frontend/src/components/FeedPageSkeleton.tsx | 3 +- frontend/src/components/PostContent.tsx | 14 + frontend/src/components/PostListItem.tsx | 55 +-- frontend/src/pages/ComposePage.tsx | 38 +- frontend/src/pages/PostDetailPage.tsx | 288 +++++++++------ frontend/src/styles/global.css | 369 +++++++++++++++---- frontend/src/utils/postContent.ts | 12 + frontend/src/utils/postHeadings.ts | 24 +- handler/handlers.go | 20 +- model/db.go | 1 + model/models.go | 8 + router/router.go | 1 + service/post.go | 66 +++- 16 files changed, 696 insertions(+), 239 deletions(-) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3f2da28..4d417d0 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -271,15 +271,16 @@ export const api = { fd.append('image', file); return request<{ url: string }>('/api/uploads/image', { method: 'POST', body: fd, headers: {} }); }, - createPost: (data: { board_id: string; title: string; content: string; tags?: string }) => { + createPost: (data: { board_id: string; title: string; content: string; tags?: string; post_type?: string }) => { const fd = new FormData(); fd.append('board_id', data.board_id); fd.append('title', data.title); fd.append('content', data.content); fd.append('tags', data.tags || ''); + fd.append('post_type', data.post_type || 'normal'); return request<{ post_id: number; message?: string; status?: string }>('/api/posts', { method: 'POST', body: fd, headers: {} }); }, - updatePost: (id: number, data: { title: string; content: string; tags?: string; board_id?: string | number }) => { + updatePost: (id: number, data: { title: string; content: string; tags?: string; board_id?: string | number; post_type?: string }) => { const fd = new FormData(); fd.append('title', data.title); fd.append('content', data.content); @@ -287,8 +288,20 @@ export const api = { if (data.board_id != null && data.board_id !== '') { fd.append('board_id', String(data.board_id)); } + if (data.post_type) { + fd.append('post_type', data.post_type); + } return request<{ message: string }>(`/api/posts/${id}`, { method: 'PUT', body: fd, headers: {} }); }, + setQuestionResolved: (id: number, resolved: boolean) => { + const fd = new FormData(); + fd.append('resolved', resolved ? '1' : '0'); + return request<{ message: string; question_resolved: boolean }>(`/api/posts/${id}/resolve`, { + method: 'POST', + body: fd, + headers: {}, + }); + }, deletePost: (id: number) => request<{ message: string }>(`/api/posts/${id}`, { method: 'DELETE' }), login: (username: string, password: string) => { const fd = new FormData(); diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 1abe451..530659e 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -64,6 +64,10 @@ export interface PostItem { title: string; content?: string; tags: string; + /** normal=讨论 | question=问答 */ + post_type?: 'normal' | 'question' | string; + /** 仅问答帖有意义 */ + question_resolved?: boolean; pinned: boolean; featured?: boolean; edit_locked?: boolean; diff --git a/frontend/src/components/FeedHeader.tsx b/frontend/src/components/FeedHeader.tsx index d0c33c9..191e5d9 100644 --- a/frontend/src/components/FeedHeader.tsx +++ b/frontend/src/components/FeedHeader.tsx @@ -8,7 +8,7 @@ interface Props { boards: Board[]; stats: ForumStats | null; postTotal: number; - /** 首页「全部帖子」用 h2,板块/搜索页用 h1 */ + /** 搜索页用 h1;首页/板块页中间栏不再展示标题 */ titleAs?: 'h1' | 'h2'; } @@ -16,18 +16,15 @@ export default function FeedHeader({ boardId, keyword, boards, stats, postTotal, const nav = useNavigate(); const board = boards.find(b => b.id === boardId); - const title = keyword - ? `搜索:${keyword}` - : (boardId && board ? board.name : '全部帖子'); - - const boardHint = boardId && board ? (board.description || '') : ''; - const TitleTag = titleAs; const inBoard = !keyword && boardId > 0 && !!board; + /** 侧栏已有「全部帖子 / 板块名」,中间栏不再重复;仅搜索保留标题 */ + const title = keyword ? `搜索:${keyword}` : ''; + const TitleTag = titleAs; return ( -
+
- {title} + {title ? {title} : null} {!keyword && inBoard && (
diff --git a/frontend/src/components/FeedPageSkeleton.tsx b/frontend/src/components/FeedPageSkeleton.tsx index 4183e87..2b58b1f 100644 --- a/frontend/src/components/FeedPageSkeleton.tsx +++ b/frontend/src/components/FeedPageSkeleton.tsx @@ -8,9 +8,8 @@ export default function FeedPageSkeleton() {
-
+
-
diff --git a/frontend/src/components/PostContent.tsx b/frontend/src/components/PostContent.tsx index 96416d7..cd8f808 100644 --- a/frontend/src/components/PostContent.tsx +++ b/frontend/src/components/PostContent.tsx @@ -66,6 +66,20 @@ export default function PostContent({ openLightbox(zoomImg); return; } + const headingCopy = target.closest('[data-heading-copy]'); + if (headingCopy) { + e.preventDefault(); + const id = headingCopy.getAttribute('data-heading-copy') || ''; + if (!id) return; + const url = `${window.location.origin}${window.location.pathname}${window.location.search}#${id}`; + try { + await navigator.clipboard.writeText(url); + notify.success('已复制本节链接'); + } catch { + notify.error('复制失败'); + } + return; + } const copyBtn = target.closest('[data-code-copy]'); if (copyBtn) { e.preventDefault(); diff --git a/frontend/src/components/PostListItem.tsx b/frontend/src/components/PostListItem.tsx index 93d5d13..69c32b7 100644 --- a/frontend/src/components/PostListItem.tsx +++ b/frontend/src/components/PostListItem.tsx @@ -75,33 +75,38 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) { · {timeLabel}
- {(post.featured || post.pinned || post.status === 'pending' || post.status === 'rejected') && ( -
- {post.status === 'pending' && ( - 审核中 - )} - {post.status === 'rejected' && ( - 未通过 - )} - {post.featured && ( - - - 精华 - - )} - {post.pinned && ( - - - 置顶 - - )} -
- )}
- - {post.title} - +
+ {post.pinned && ( + + + + )} + {post.featured && ( + + + 精华 + + )} + {post.status === 'pending' && ( + 审核中 + )} + {post.status === 'rejected' && ( + 未通过 + )} + {post.post_type === 'question' && ( + + {post.question_resolved ? '已解决' : '未解决'} + + )} + + {post.title} + +
{excerpt &&

{excerpt}

} diff --git a/frontend/src/pages/ComposePage.tsx b/frontend/src/pages/ComposePage.tsx index 3c66c9d..dc7c3af 100644 --- a/frontend/src/pages/ComposePage.tsx +++ b/frontend/src/pages/ComposePage.tsx @@ -23,6 +23,7 @@ interface ComposeBaseline { tags: string; content: string; boardId: string; + postType: 'normal' | 'question'; } function resolveBoards(ctxBoards?: Board[]): Board[] { @@ -63,6 +64,7 @@ export default function ComposePage() { const [title, setTitle] = useState(''); const [tags, setTags] = useState(''); const [content, setContent] = useState(''); + const [postType, setPostType] = useState<'normal' | 'question'>('normal'); const [publishing, setPublishing] = useState(false); const [loading, setLoading] = useState(isEdit); /** 新建帖:板块列表是否已就绪(避免请求中误显空态) */ @@ -102,17 +104,20 @@ export default function ComposePage() { return; } const loadedBoardId = String(post.board_id); + const loadedType = post.post_type === 'question' ? 'question' : 'normal'; const serverBaseline: ComposeBaseline = { title: post.title, tags: post.tags ?? '', content: post.content ?? '', boardId: loadedBoardId, + postType: loadedType, }; setBoardId(loadedBoardId); setBaseline(serverBaseline); setTitle(serverBaseline.title); setTags(serverBaseline.tags); setContent(serverBaseline.content); + setPostType(loadedType); const windowHours = postData.post_edit_window_hours ?? 0; if (user.role !== 'admin' && windowHours > 0) { @@ -139,6 +144,7 @@ export default function ComposePage() { tags: '', content: '', boardId: boardForBaseline, + postType: 'normal', }); }; @@ -169,8 +175,9 @@ export default function ComposePage() { || serializeTags(parseTags(tags)) !== serializeTags(parseTags(baseline.tags)) || content !== baseline.content || boardId !== baseline.boardId + || postType !== baseline.postType ); - }, [baseline, title, tags, content, boardId, isEdit, boards.length]); + }, [baseline, title, tags, content, boardId, postType, isEdit, boards.length]); const { dialogOpen, @@ -247,6 +254,7 @@ export default function ComposePage() { content: content.trim(), tags: serializeTags(parseTags(tags)), board_id: boardId, + post_type: postType, }; if (isEdit) { await api.updatePost(editId!, payload); @@ -305,6 +313,32 @@ export default function ComposePage() {
+
+ 类型 +
+ + +
+ {postType === 'question' && ( + 可标记未解决 / 已解决 + )} +
板块
@@ -337,7 +371,7 @@ export default function ComposePage() { setTitle(e.target.value)} maxLength={limits.post_title_max > 0 ? limits.post_title_max : undefined} diff --git a/frontend/src/pages/PostDetailPage.tsx b/frontend/src/pages/PostDetailPage.tsx index 48842f0..31d0727 100644 --- a/frontend/src/pages/PostDetailPage.tsx +++ b/frontend/src/pages/PostDetailPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react'; import { useParams, useNavigate, useOutletContext, useLocation } from 'react-router-dom'; -import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock, MessageSquare, Trash2, Sparkles, Flag, Ban } from 'lucide-react'; +import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock, MessageSquare, Trash2, Sparkles, Flag, Ban, CircleCheck, CircleHelp } from 'lucide-react'; import FeaturedIcon from '@/components/FeaturedIcon'; import PinnedIcon from '@/components/PinnedIcon'; import { Button } from '@/components/ui/button'; @@ -216,6 +216,26 @@ export default function PostDetailPage() { highlightTimer.current = setTimeout(() => setHighlightFloor(null), 2000); }, []); + /** 正文标题锚点:滚动容器是 pageRef,原生 hash 定位无效,需手动滚 */ + const jumpToHeadingHash = useCallback((hash: string, smooth = false) => { + const id = decodeURIComponent((hash || '').replace(/^#/, '')).trim(); + if (!id || /^floor-\d+$/.test(id)) return false; + const el = document.getElementById(id); + if (!el) return false; + + const root = pageRef.current; + const behavior: ScrollBehavior = smooth ? 'smooth' : 'auto'; + if (root) { + const rootRect = root.getBoundingClientRect(); + const elRect = el.getBoundingClientRect(); + const top = root.scrollTop + (elRect.top - rootRect.top) - 12; + root.scrollTo({ top: Math.max(0, top), behavior }); + } else { + el.scrollIntoView({ behavior, block: 'start' }); + } + return true; + }, []); + // 从 #floor-N 定位到对应评论(右栏最新评论等入口) useEffect(() => { if (loading || !post) return; @@ -227,6 +247,32 @@ export default function PostDetailPage() { return () => clearTimeout(t); }, [loading, post, comments, location.hash, jumpToFloor]); + // 从 #heading-N(或任意标题 id)定位;等正文进 DOM 后重试,避免首屏 hash 失效 + useEffect(() => { + if (loading || !post) return; + const hash = location.hash; + if (!hash || /^#floor-\d+$/.test(hash)) return; + + let cancelled = false; + let attempts = 0; + let timer = 0; + + const tryJump = () => { + if (cancelled) return; + if (jumpToHeadingHash(hash, false)) return; + attempts += 1; + if (attempts < 30) { + timer = window.setTimeout(tryJump, 50); + } + }; + + timer = window.setTimeout(tryJump, 0); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [loading, post, location.hash, jumpToHeadingHash, headings]); + const requireLogin = (actionLabel: string) => { notify.warning(`登录后即可${actionLabel}`); nav(loginPath(detailPath)); @@ -416,6 +462,20 @@ export default function PostDetailPage() { } }; + const handleToggleResolved = async () => { + if (!post || post.post_type !== 'question') return; + const next = !post.question_resolved; + try { + const r = await api.setQuestionResolved(postId, next); + setPost(p => p ? { ...p, question_resolved: r.question_resolved } : p); + clearAllFeedCache(); + window.dispatchEvent(new Event('posts-refresh')); + notify.success(r.message); + } catch (e: unknown) { + notify.error(e instanceof Error ? e.message : '操作失败'); + } + }; + const handleApprove = async () => { if (!post) return; try { @@ -488,10 +548,6 @@ export default function PostDetailPage() { } }; - const jumpToComments = () => { - commentSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - }; - return (
@@ -518,10 +574,21 @@ export default function PostDetailPage() {

+ {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 && ( - - )} - {!user && ( - - )} - {canEdit && ( - - )} - {isOwnerOrAdmin && isEdited && ( - - )} - {isAdmin && ( - - - - - - - 确定删除该帖子? - - 帖子与评论将移入回收站,可在后台恢复或永久删除。普通用户不可自行删除内容。 - - - - 取消 - 删除 - - - - )} - {editRemaining && ( - {editRemaining} - )} - {isOwnerOrAdmin && !canEdit && editBlockReason && ( - - {editBlockReason} - - )} - {isAdmin && ( - <> - {(post.status === 'pending' || post.status === 'rejected') && ( - + )} + {!user && ( + + )} +
+ + {(isOwnerOrAdmin || canEdit || isAdmin) && ( +
+ {isOwnerOrAdmin && post.post_type === 'question' && ( + )} - - - - {post.status !== 'rejected' && ( - )} - + {isOwnerOrAdmin && isEdited && ( + + )} + {isAdmin && ( + + + + + + + 确定删除该帖子? + + 帖子与评论将移入回收站,可在后台恢复或永久删除。普通用户不可自行删除内容。 + + + + 取消 + 删除 + + + + )} + {editRemaining && ( + {editRemaining} + )} + {isOwnerOrAdmin && !canEdit && editBlockReason && ( + + {editBlockReason} + + )} + {isAdmin && ( + <> + {(post.status === 'pending' || post.status === 'rejected') && ( + + )} + + + + {post.status !== 'rejected' && ( + + )} + + )} +
)}
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)