优化帖子详情与列表展示,并新增问答帖类型与已解决状态。
统一标题锚点定位、编辑底栏固定与操作栏分层,列表徽章前置并区分置顶/问答配色。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -271,15 +271,16 @@ export const api = {
|
|||||||
fd.append('image', file);
|
fd.append('image', file);
|
||||||
return request<{ url: string }>('/api/uploads/image', { method: 'POST', body: fd, headers: {} });
|
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();
|
const fd = new FormData();
|
||||||
fd.append('board_id', data.board_id);
|
fd.append('board_id', data.board_id);
|
||||||
fd.append('title', data.title);
|
fd.append('title', data.title);
|
||||||
fd.append('content', data.content);
|
fd.append('content', data.content);
|
||||||
fd.append('tags', data.tags || '');
|
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: {} });
|
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();
|
const fd = new FormData();
|
||||||
fd.append('title', data.title);
|
fd.append('title', data.title);
|
||||||
fd.append('content', data.content);
|
fd.append('content', data.content);
|
||||||
@@ -287,8 +288,20 @@ export const api = {
|
|||||||
if (data.board_id != null && data.board_id !== '') {
|
if (data.board_id != null && data.board_id !== '') {
|
||||||
fd.append('board_id', String(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: {} });
|
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' }),
|
deletePost: (id: number) => request<{ message: string }>(`/api/posts/${id}`, { method: 'DELETE' }),
|
||||||
login: (username: string, password: string) => {
|
login: (username: string, password: string) => {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ export interface PostItem {
|
|||||||
title: string;
|
title: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
tags: string;
|
tags: string;
|
||||||
|
/** normal=讨论 | question=问答 */
|
||||||
|
post_type?: 'normal' | 'question' | string;
|
||||||
|
/** 仅问答帖有意义 */
|
||||||
|
question_resolved?: boolean;
|
||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
featured?: boolean;
|
featured?: boolean;
|
||||||
edit_locked?: boolean;
|
edit_locked?: boolean;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface Props {
|
|||||||
boards: Board[];
|
boards: Board[];
|
||||||
stats: ForumStats | null;
|
stats: ForumStats | null;
|
||||||
postTotal: number;
|
postTotal: number;
|
||||||
/** 首页「全部帖子」用 h2,板块/搜索页用 h1 */
|
/** 搜索页用 h1;首页/板块页中间栏不再展示标题 */
|
||||||
titleAs?: 'h1' | 'h2';
|
titleAs?: 'h1' | 'h2';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,18 +16,15 @@ export default function FeedHeader({ boardId, keyword, boards, stats, postTotal,
|
|||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const board = boards.find(b => b.id === boardId);
|
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 inBoard = !keyword && boardId > 0 && !!board;
|
||||||
|
/** 侧栏已有「全部帖子 / 板块名」,中间栏不再重复;仅搜索保留标题 */
|
||||||
|
const title = keyword ? `搜索:${keyword}` : '';
|
||||||
|
const TitleTag = titleAs;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`feed-head${keyword ? ' feed-head--solo' : ''}`}>
|
<div className={`feed-head${keyword ? ' feed-head--solo' : ' feed-head--stats-only'}`}>
|
||||||
<div className="feed-head__title">
|
<div className="feed-head__title">
|
||||||
<TitleTag title={boardHint || undefined}>{title}</TitleTag>
|
{title ? <TitleTag>{title}</TitleTag> : null}
|
||||||
{!keyword && inBoard && (
|
{!keyword && inBoard && (
|
||||||
<div className="feed-head__stats">
|
<div className="feed-head__stats">
|
||||||
<span className="feed-stat-chip">
|
<span className="feed-stat-chip">
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ export default function FeedPageSkeleton() {
|
|||||||
<div className="feed-panel">
|
<div className="feed-panel">
|
||||||
<div className="feed-top">
|
<div className="feed-top">
|
||||||
<div className="feed-top__bar">
|
<div className="feed-top__bar">
|
||||||
<div className="feed-head">
|
<div className="feed-head feed-head--stats-only">
|
||||||
<div className="feed-head__title">
|
<div className="feed-head__title">
|
||||||
<Skeleton className="skeleton--feed-title" />
|
|
||||||
<div className="feed-head__stats">
|
<div className="feed-head__stats">
|
||||||
<Skeleton className="skeleton--stat-chip" />
|
<Skeleton className="skeleton--stat-chip" />
|
||||||
<Skeleton className="skeleton--stat-chip" />
|
<Skeleton className="skeleton--stat-chip" />
|
||||||
|
|||||||
@@ -66,6 +66,20 @@ export default function PostContent({
|
|||||||
openLightbox(zoomImg);
|
openLightbox(zoomImg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const headingCopy = target.closest<HTMLElement>('[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<HTMLElement>('[data-code-copy]');
|
const copyBtn = target.closest<HTMLElement>('[data-code-copy]');
|
||||||
if (copyBtn) {
|
if (copyBtn) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -75,13 +75,13 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
|
|||||||
<span className="post-head-dot" aria-hidden>·</span>
|
<span className="post-head-dot" aria-hidden>·</span>
|
||||||
<span className="post-time">{timeLabel}</span>
|
<span className="post-time">{timeLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
{(post.featured || post.pinned || post.status === 'pending' || post.status === 'rejected') && (
|
</div>
|
||||||
<div className="post-head-badges">
|
|
||||||
{post.status === 'pending' && (
|
<div className="post-title-row">
|
||||||
<span className="post-status-badge post-status-badge--pending" title="审核中">审核中</span>
|
{post.pinned && (
|
||||||
)}
|
<span className="post-pin-badge post-pin-badge--icon" title="置顶">
|
||||||
{post.status === 'rejected' && (
|
<PinnedIcon size={13} />
|
||||||
<span className="post-status-badge post-status-badge--rejected" title="未通过">未通过</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{post.featured && (
|
{post.featured && (
|
||||||
<span className="post-feature-badge" title="精华">
|
<span className="post-feature-badge" title="精华">
|
||||||
@@ -89,19 +89,24 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
|
|||||||
精华
|
精华
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{post.pinned && (
|
{post.status === 'pending' && (
|
||||||
<span className="post-pin-badge" title="置顶">
|
<span className="post-status-badge post-status-badge--pending" title="审核中">审核中</span>
|
||||||
<PinnedIcon size={12} />
|
)}
|
||||||
置顶
|
{post.status === 'rejected' && (
|
||||||
|
<span className="post-status-badge post-status-badge--rejected" title="未通过">未通过</span>
|
||||||
|
)}
|
||||||
|
{post.post_type === 'question' && (
|
||||||
|
<span
|
||||||
|
className={`post-qa-badge${post.question_resolved ? ' post-qa-badge--resolved' : ' post-qa-badge--open'}`}
|
||||||
|
title={post.question_resolved ? '已解决' : '未解决'}
|
||||||
|
>
|
||||||
|
{post.question_resolved ? '已解决' : '未解决'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a href={href} className="post-title" onClick={onTitleClick}>
|
<a href={href} className="post-title" onClick={onTitleClick}>
|
||||||
{post.title}
|
{post.title}
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
{excerpt && <p className="post-excerpt">{excerpt}</p>}
|
{excerpt && <p className="post-excerpt">{excerpt}</p>}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface ComposeBaseline {
|
|||||||
tags: string;
|
tags: string;
|
||||||
content: string;
|
content: string;
|
||||||
boardId: string;
|
boardId: string;
|
||||||
|
postType: 'normal' | 'question';
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveBoards(ctxBoards?: Board[]): Board[] {
|
function resolveBoards(ctxBoards?: Board[]): Board[] {
|
||||||
@@ -63,6 +64,7 @@ export default function ComposePage() {
|
|||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [tags, setTags] = useState('');
|
const [tags, setTags] = useState('');
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
|
const [postType, setPostType] = useState<'normal' | 'question'>('normal');
|
||||||
const [publishing, setPublishing] = useState(false);
|
const [publishing, setPublishing] = useState(false);
|
||||||
const [loading, setLoading] = useState(isEdit);
|
const [loading, setLoading] = useState(isEdit);
|
||||||
/** 新建帖:板块列表是否已就绪(避免请求中误显空态) */
|
/** 新建帖:板块列表是否已就绪(避免请求中误显空态) */
|
||||||
@@ -102,17 +104,20 @@ export default function ComposePage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const loadedBoardId = String(post.board_id);
|
const loadedBoardId = String(post.board_id);
|
||||||
|
const loadedType = post.post_type === 'question' ? 'question' : 'normal';
|
||||||
const serverBaseline: ComposeBaseline = {
|
const serverBaseline: ComposeBaseline = {
|
||||||
title: post.title,
|
title: post.title,
|
||||||
tags: post.tags ?? '',
|
tags: post.tags ?? '',
|
||||||
content: post.content ?? '',
|
content: post.content ?? '',
|
||||||
boardId: loadedBoardId,
|
boardId: loadedBoardId,
|
||||||
|
postType: loadedType,
|
||||||
};
|
};
|
||||||
setBoardId(loadedBoardId);
|
setBoardId(loadedBoardId);
|
||||||
setBaseline(serverBaseline);
|
setBaseline(serverBaseline);
|
||||||
setTitle(serverBaseline.title);
|
setTitle(serverBaseline.title);
|
||||||
setTags(serverBaseline.tags);
|
setTags(serverBaseline.tags);
|
||||||
setContent(serverBaseline.content);
|
setContent(serverBaseline.content);
|
||||||
|
setPostType(loadedType);
|
||||||
|
|
||||||
const windowHours = postData.post_edit_window_hours ?? 0;
|
const windowHours = postData.post_edit_window_hours ?? 0;
|
||||||
if (user.role !== 'admin' && windowHours > 0) {
|
if (user.role !== 'admin' && windowHours > 0) {
|
||||||
@@ -139,6 +144,7 @@ export default function ComposePage() {
|
|||||||
tags: '',
|
tags: '',
|
||||||
content: '',
|
content: '',
|
||||||
boardId: boardForBaseline,
|
boardId: boardForBaseline,
|
||||||
|
postType: 'normal',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -169,8 +175,9 @@ export default function ComposePage() {
|
|||||||
|| serializeTags(parseTags(tags)) !== serializeTags(parseTags(baseline.tags))
|
|| serializeTags(parseTags(tags)) !== serializeTags(parseTags(baseline.tags))
|
||||||
|| content !== baseline.content
|
|| content !== baseline.content
|
||||||
|| boardId !== baseline.boardId
|
|| boardId !== baseline.boardId
|
||||||
|
|| postType !== baseline.postType
|
||||||
);
|
);
|
||||||
}, [baseline, title, tags, content, boardId, isEdit, boards.length]);
|
}, [baseline, title, tags, content, boardId, postType, isEdit, boards.length]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
dialogOpen,
|
dialogOpen,
|
||||||
@@ -247,6 +254,7 @@ export default function ComposePage() {
|
|||||||
content: content.trim(),
|
content: content.trim(),
|
||||||
tags: serializeTags(parseTags(tags)),
|
tags: serializeTags(parseTags(tags)),
|
||||||
board_id: boardId,
|
board_id: boardId,
|
||||||
|
post_type: postType,
|
||||||
};
|
};
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
await api.updatePost(editId!, payload);
|
await api.updatePost(editId!, payload);
|
||||||
@@ -305,6 +313,32 @@ export default function ComposePage() {
|
|||||||
|
|
||||||
<div className="compose-shell-body">
|
<div className="compose-shell-body">
|
||||||
<section className="compose-context" aria-label="发布设置">
|
<section className="compose-context" aria-label="发布设置">
|
||||||
|
<div className="compose-context-row">
|
||||||
|
<span className="compose-context-label">类型</span>
|
||||||
|
<div className="compose-type-pills" role="radiogroup" aria-label="帖子类型">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={postType === 'normal'}
|
||||||
|
className={`compose-type-pill${postType === 'normal' ? ' active' : ''}`}
|
||||||
|
onClick={() => setPostType('normal')}
|
||||||
|
>
|
||||||
|
讨论
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={postType === 'question'}
|
||||||
|
className={`compose-type-pill${postType === 'question' ? ' active' : ''}`}
|
||||||
|
onClick={() => setPostType('question')}
|
||||||
|
>
|
||||||
|
问答
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{postType === 'question' && (
|
||||||
|
<span className="compose-type-hint">可标记未解决 / 已解决</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="compose-context-row">
|
<div className="compose-context-row">
|
||||||
<span className="compose-context-label">板块</span>
|
<span className="compose-context-label">板块</span>
|
||||||
<div className="compose-board-pills" role="listbox" aria-label={isEdit ? '修改板块' : '选择板块'}>
|
<div className="compose-board-pills" role="listbox" aria-label={isEdit ? '修改板块' : '选择板块'}>
|
||||||
@@ -337,7 +371,7 @@ export default function ComposePage() {
|
|||||||
<input
|
<input
|
||||||
className="compose-title"
|
className="compose-title"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="输入文章标题…"
|
placeholder={postType === 'question' ? '用一句话描述你的问题…' : '输入文章标题…'}
|
||||||
value={title}
|
value={title}
|
||||||
onChange={e => setTitle(e.target.value)}
|
onChange={e => setTitle(e.target.value)}
|
||||||
maxLength={limits.post_title_max > 0 ? limits.post_title_max : undefined}
|
maxLength={limits.post_title_max > 0 ? limits.post_title_max : undefined}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
||||||
import { useParams, useNavigate, useOutletContext, useLocation } from 'react-router-dom';
|
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 FeaturedIcon from '@/components/FeaturedIcon';
|
||||||
import PinnedIcon from '@/components/PinnedIcon';
|
import PinnedIcon from '@/components/PinnedIcon';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -216,6 +216,26 @@ export default function PostDetailPage() {
|
|||||||
highlightTimer.current = setTimeout(() => setHighlightFloor(null), 2000);
|
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 定位到对应评论(右栏最新评论等入口)
|
// 从 #floor-N 定位到对应评论(右栏最新评论等入口)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading || !post) return;
|
if (loading || !post) return;
|
||||||
@@ -227,6 +247,32 @@ export default function PostDetailPage() {
|
|||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [loading, post, comments, location.hash, jumpToFloor]);
|
}, [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) => {
|
const requireLogin = (actionLabel: string) => {
|
||||||
notify.warning(`登录后即可${actionLabel}`);
|
notify.warning(`登录后即可${actionLabel}`);
|
||||||
nav(loginPath(detailPath));
|
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 () => {
|
const handleApprove = async () => {
|
||||||
if (!post) return;
|
if (!post) return;
|
||||||
try {
|
try {
|
||||||
@@ -488,10 +548,6 @@ export default function PostDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const jumpToComments = () => {
|
|
||||||
commentSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="page-wrap post-detail-page" ref={pageRef}>
|
<article className="page-wrap post-detail-page" ref={pageRef}>
|
||||||
<div className="post-detail-header">
|
<div className="post-detail-header">
|
||||||
@@ -518,10 +574,21 @@ export default function PostDetailPage() {
|
|||||||
|
|
||||||
<div className="post-detail-head">
|
<div className="post-detail-head">
|
||||||
<h1 className="post-detail-title">
|
<h1 className="post-detail-title">
|
||||||
|
{post.pinned && (
|
||||||
|
<span className="post-pin-badge post-pin-badge--icon post-pin-badge--detail" title="置顶">
|
||||||
|
<PinnedIcon size={16} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{post.featured && <FeaturedIcon className="mr-2" size={18} />}
|
||||||
{post.status === 'pending' && <Badge variant="orange" className="mr-2 align-middle">审核中</Badge>}
|
{post.status === 'pending' && <Badge variant="orange" className="mr-2 align-middle">审核中</Badge>}
|
||||||
{post.status === 'rejected' && <Badge variant="destructive" className="mr-2 align-middle">未通过</Badge>}
|
{post.status === 'rejected' && <Badge variant="destructive" className="mr-2 align-middle">未通过</Badge>}
|
||||||
{post.featured && <FeaturedIcon className="mr-2" size={18} />}
|
{post.post_type === 'question' && (
|
||||||
{post.pinned && <PinnedIcon className="mr-2" size={18} />}
|
<span
|
||||||
|
className={`post-qa-badge post-qa-badge--detail${post.question_resolved ? ' post-qa-badge--resolved' : ' post-qa-badge--open'}`}
|
||||||
|
>
|
||||||
|
{post.question_resolved ? '已解决' : '未解决'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{post.title}
|
{post.title}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="post-detail-author-row">
|
<div className="post-detail-author-row">
|
||||||
@@ -577,29 +644,24 @@ export default function PostDetailPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="post-detail-actions">
|
<div className="post-detail-actions">
|
||||||
|
<div className="post-detail-actions-primary">
|
||||||
<Button
|
<Button
|
||||||
variant={liked ? 'default' : 'outline'}
|
variant={liked ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleLike}
|
onClick={handleLike}
|
||||||
title={!user ? '登录后即可点赞' : undefined}
|
title={!user ? '登录后即可点赞' : undefined}
|
||||||
className={!user ? 'post-action-guest' : undefined}
|
|
||||||
>
|
>
|
||||||
<ThumbsUp />
|
<ThumbsUp />
|
||||||
{!user ? '登录后点赞' : `点赞 ${post.like_count}`}
|
点赞 {post.like_count}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={favorited ? 'default' : 'outline'}
|
variant={favorited ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleFavorite}
|
onClick={handleFavorite}
|
||||||
title={!user ? '登录后即可收藏' : undefined}
|
title={!user ? '登录后即可收藏' : undefined}
|
||||||
className={!user ? 'post-action-guest' : undefined}
|
|
||||||
>
|
>
|
||||||
<Star />
|
<Star />
|
||||||
{!user ? '登录后收藏' : (favorited ? '已收藏' : '收藏')}
|
{favorited ? '已收藏' : '收藏'}
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" onClick={jumpToComments}>
|
|
||||||
<MessageSquare />
|
|
||||||
评论 {comments.length}
|
|
||||||
</Button>
|
</Button>
|
||||||
{user && user.id !== post.user_id && (
|
{user && user.id !== post.user_id && (
|
||||||
<Button variant="outline" size="sm" onClick={() => setReportOpen(true)}>
|
<Button variant="outline" size="sm" onClick={() => setReportOpen(true)}>
|
||||||
@@ -613,6 +675,20 @@ export default function PostDetailPage() {
|
|||||||
举报
|
举报
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(isOwnerOrAdmin || canEdit || isAdmin) && (
|
||||||
|
<div className="post-detail-actions-manage">
|
||||||
|
{isOwnerOrAdmin && post.post_type === 'question' && (
|
||||||
|
<Button
|
||||||
|
variant={post.question_resolved ? 'outline' : 'default'}
|
||||||
|
size="sm"
|
||||||
|
onClick={handleToggleResolved}
|
||||||
|
>
|
||||||
|
{post.question_resolved ? <CircleHelp /> : <CircleCheck />}
|
||||||
|
{post.question_resolved ? '标为未解决' : '标为已解决'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<Button variant="outline" size="sm" onClick={() => nav(`/post/${postId}/edit`)}>
|
<Button variant="outline" size="sm" onClick={() => nav(`/post/${postId}/edit`)}>
|
||||||
<Pencil />
|
<Pencil />
|
||||||
@@ -683,6 +759,8 @@ export default function PostDetailPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={reportOpen} onOpenChange={setReportOpen}>
|
<Dialog open={reportOpen} onOpenChange={setReportOpen}>
|
||||||
|
|||||||
@@ -1078,10 +1078,11 @@ img.site-brand-logo-img {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.article-outline-item {
|
.article-outline-item {
|
||||||
|
position: relative;
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 7px 10px;
|
padding: 7px 10px 7px 12px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -1093,8 +1094,7 @@ img.site-brand-logo-img {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border-left: 2px solid transparent;
|
transition: background 0.12s, color 0.12s;
|
||||||
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-outline-item:hover {
|
.article-outline-item:hover {
|
||||||
@@ -1106,14 +1106,26 @@ img.site-brand-logo-img {
|
|||||||
background: var(--j13-green-bg);
|
background: var(--j13-green-bg);
|
||||||
color: var(--j13-green);
|
color: var(--j13-green);
|
||||||
font-weight: 600;
|
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--l4,
|
||||||
.article-outline-item--l5,
|
.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--l3.active,
|
||||||
.article-outline-item--l4.active,
|
.article-outline-item--l4.active,
|
||||||
@@ -1126,6 +1138,33 @@ img.site-brand-logo-img {
|
|||||||
scroll-margin-top: 16px;
|
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 {
|
.post-detail-toc-mobile {
|
||||||
margin: 0 0 16px;
|
margin: 0 0 16px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
@@ -1827,6 +1866,10 @@ img.site-brand-logo-img {
|
|||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.feed-head--stats-only .feed-head__title {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.feed-head__title {
|
.feed-head__title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
@@ -2221,9 +2264,28 @@ img.site-brand-logo-img {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.post-pin-badge {
|
.post-pin-badge {
|
||||||
color: var(--j13-green);
|
color: #dc2626;
|
||||||
background: var(--j13-green-bg);
|
background: rgba(220, 38, 38, 0.1);
|
||||||
border: 1px solid color-mix(in srgb, var(--j13-green) 16%, transparent);
|
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 {
|
.post-status-badge {
|
||||||
@@ -2249,6 +2311,39 @@ img.site-brand-logo-img {
|
|||||||
border: 1px solid rgba(244, 63, 94, 0.2);
|
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 {
|
.post-moderation-banner {
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
@@ -2298,18 +2393,34 @@ img.site-brand-logo-img {
|
|||||||
vertical-align: 0;
|
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 {
|
.post-title {
|
||||||
display: block;
|
display: block;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1.45;
|
line-height: 1.4;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
color: var(--color-text-1);
|
color: var(--color-text-1);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: color 0.15s;
|
transition: color 0.15s;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
a.post-title:visited {
|
a.post-title:visited {
|
||||||
@@ -3588,8 +3699,8 @@ a.post-title:visited {
|
|||||||
|
|
||||||
.post-detail-actions {
|
.post-detail-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-direction: column;
|
||||||
justify-content: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-top: 28px;
|
margin-top: 28px;
|
||||||
padding-top: 20px;
|
padding-top: 20px;
|
||||||
@@ -3599,6 +3710,21 @@ a.post-title:visited {
|
|||||||
position: relative;
|
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 {
|
.post-detail-content {
|
||||||
font-size: 15.5px;
|
font-size: 15.5px;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
@@ -3622,6 +3748,26 @@ a.post-title:visited {
|
|||||||
margin: 1.55em 0 0.65em;
|
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 > :first-child,
|
||||||
.post-detail-content h1:first-child,
|
.post-detail-content h1:first-child,
|
||||||
.post-detail-content h2:first-child,
|
.post-detail-content h2:first-child,
|
||||||
@@ -3634,22 +3780,35 @@ a.post-title:visited {
|
|||||||
|
|
||||||
.post-detail-content h1 {
|
.post-detail-content h1 {
|
||||||
font-size: 1.7em;
|
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 {
|
.post-detail-content h2 {
|
||||||
font-size: 1.4em;
|
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 { 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 { 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 { font-size: 1em; }
|
||||||
|
.post-detail-content h5::after { width: 20px; height: 1px; }
|
||||||
|
.post-detail-content h5:hover::after { width: 32px; }
|
||||||
|
|
||||||
.post-detail-content h6 {
|
.post-detail-content h6 {
|
||||||
font-size: 0.92em;
|
font-size: 0.92em;
|
||||||
color: var(--color-text-2);
|
color: var(--color-text-2);
|
||||||
font-weight: 600;
|
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 ul,
|
||||||
.post-detail-content ol {
|
.post-detail-content ol {
|
||||||
@@ -4035,6 +4194,15 @@ a.post-title:visited {
|
|||||||
|
|
||||||
.post-detail-content p { margin: 0 0 1em; }
|
.post-detail-content p { margin: 0 0 1em; }
|
||||||
.post-detail-content p:last-child { margin-bottom: 0; }
|
.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 {
|
.post-detail-content a {
|
||||||
color: var(--j13-green);
|
color: var(--j13-green);
|
||||||
text-decoration: underline;
|
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));
|
background: color-mix(in srgb, var(--j13-green) 6%, var(--j13-bg-block-muted));
|
||||||
color: var(--color-text-2);
|
color: var(--color-text-2);
|
||||||
border-radius: 0 10px 10px 0;
|
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 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 {
|
.post-detail-content table {
|
||||||
width: 100%;
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
margin: 1.1em 0;
|
margin: 1.1em 0;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
overflow: hidden;
|
display: table;
|
||||||
border: 1px solid var(--j13-border-light);
|
border: 1px solid var(--j13-border-light);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
display: block;
|
overflow: hidden;
|
||||||
overflow-x: auto;
|
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 th,
|
||||||
.post-detail-content td {
|
.post-detail-content td {
|
||||||
@@ -4816,10 +5014,6 @@ a.waline-comment-author:hover { color: var(--j13-green); }
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-action-guest {
|
|
||||||
opacity: 0.78;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.comment-box-wrap { padding: 12px 14px; }
|
.comment-box-wrap { padding: 12px 14px; }
|
||||||
.comment-box-guest-fields { grid-template-columns: 1fr; }
|
.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-header { padding: 12px 14px 16px; }
|
||||||
.post-detail-title { font-size: 17px; }
|
.post-detail-title { font-size: 17px; }
|
||||||
.post-detail-actions { margin-top: 20px; padding-top: 16px; gap: 10px; }
|
.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 { padding: 12px 14px 10px; }
|
||||||
.comment-section-bar::after { left: 14px; width: 32px; }
|
.comment-section-bar::after { left: 14px; width: 32px; }
|
||||||
.comment-section:hover .comment-section-bar::after { width: 46px; }
|
.comment-section:hover .comment-section-bar::after { width: 46px; }
|
||||||
@@ -5852,20 +6048,19 @@ button.profile-stat:hover strong {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.main-content--compose {
|
.main-content--compose {
|
||||||
overflow-y: auto;
|
/* 编辑页占满主栏高度;滚动交给编辑区内层,底栏可始终贴底 */
|
||||||
overflow-x: hidden;
|
overflow: hidden;
|
||||||
background: var(--j13-bg-workspace);
|
background: var(--j13-bg-workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-page {
|
.compose-page {
|
||||||
/* 与 .compose-header 实际高度对齐,供编辑器工具栏 sticky 偏移 */
|
/* 与 .compose-header 实际高度对齐(全屏等场景仍可能用到) */
|
||||||
--compose-header-sticky-h: 57px;
|
--compose-header-sticky-h: 57px;
|
||||||
/* 随正文增高,避免白底被视口高度裁切、粘性顶栏失效 */
|
flex: 1;
|
||||||
flex: 1 0 auto;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100%;
|
overflow: hidden;
|
||||||
overflow: visible;
|
|
||||||
background: var(--j13-bg-workspace);
|
background: var(--j13-bg-workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5932,16 +6127,17 @@ button.profile-stat:hover strong {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.compose-canvas {
|
.compose-canvas {
|
||||||
flex: 1 0 auto;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100%;
|
|
||||||
/* 富文本:接近正文阅读宽度,避免宽行/表格撑出右侧;源码双栏另加宽 */
|
/* 富文本:接近正文阅读宽度,避免宽行/表格撑出右侧;源码双栏另加宽 */
|
||||||
max-width: calc(var(--j13-article-read-w) + 64px);
|
max-width: calc(var(--j13-article-read-w) + 64px);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 16px 20px 24px;
|
padding: 16px 20px 16px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
transition: max-width 0.2s ease;
|
transition: max-width 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5950,11 +6146,10 @@ button.profile-stat:hover strong {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.compose-shell {
|
.compose-shell {
|
||||||
flex: 1 0 auto;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
/* 短文填满可视区;长文随内容增高(勿用 min-height:0,否则白底被裁切) */
|
|
||||||
min-height: 100%;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -5962,24 +6157,23 @@ button.profile-stat:hover strong {
|
|||||||
border: 1px solid var(--j13-border-light);
|
border: 1px solid var(--j13-border-light);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: var(--j13-shadow-card);
|
box-shadow: var(--j13-shadow-card);
|
||||||
/* 顶栏 sticky 需 visible;横向溢出由 .compose-shell-body 承接 */
|
overflow: hidden;
|
||||||
overflow: visible;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 正文区单独限宽,避免表格/长词撑出白底 */
|
/* 正文区:限宽 + 纵向填满,横向溢出由内层编辑区承接 */
|
||||||
.compose-shell-body {
|
.compose-shell-body {
|
||||||
flex: 1 0 auto;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow-x: auto;
|
overflow: hidden;
|
||||||
border-radius: 0 0 12px 12px;
|
border-radius: 0 0 12px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-header {
|
.compose-header {
|
||||||
position: sticky;
|
position: relative;
|
||||||
top: 0;
|
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -5990,7 +6184,6 @@ button.profile-stat:hover strong {
|
|||||||
background: var(--j13-bg-surface);
|
background: var(--j13-bg-surface);
|
||||||
border-bottom: 1px solid var(--j13-border-light);
|
border-bottom: 1px solid var(--j13-border-light);
|
||||||
border-radius: 12px 12px 0 0;
|
border-radius: 12px 12px 0 0;
|
||||||
/* 长文下滚时顶栏与正文分层更清晰 */
|
|
||||||
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.03);
|
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6083,6 +6276,7 @@ button.profile-stat:hover strong {
|
|||||||
padding: 14px 20px;
|
padding: 14px 20px;
|
||||||
border-bottom: 1px solid var(--j13-border-light);
|
border-bottom: 1px solid var(--j13-border-light);
|
||||||
background: var(--j13-bg-surface);
|
background: var(--j13-bg-surface);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-context-row {
|
.compose-context-row {
|
||||||
@@ -6117,7 +6311,21 @@ button.profile-stat:hover strong {
|
|||||||
min-width: 0;
|
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;
|
padding: 5px 12px;
|
||||||
border: 1px solid var(--j13-border);
|
border: 1px solid var(--j13-border);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -6129,13 +6337,15 @@ button.profile-stat:hover strong {
|
|||||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
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);
|
border-color: var(--j13-green);
|
||||||
color: var(--j13-green);
|
color: var(--j13-green);
|
||||||
background: var(--j13-green-bg);
|
background: var(--j13-green-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-board-pill.active {
|
.compose-board-pill.active,
|
||||||
|
.compose-type-pill.active {
|
||||||
background: var(--j13-green);
|
background: var(--j13-green);
|
||||||
border-color: var(--j13-green);
|
border-color: var(--j13-green);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -6270,12 +6480,14 @@ button.profile-stat:hover strong {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.compose-document {
|
.compose-document {
|
||||||
flex: 1 0 auto;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-title {
|
.compose-title {
|
||||||
@@ -6290,6 +6502,7 @@ button.profile-stat:hover strong {
|
|||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
color: var(--color-text-1);
|
color: var(--color-text-1);
|
||||||
outline: none;
|
outline: none;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-title::placeholder {
|
.compose-title::placeholder {
|
||||||
@@ -6310,18 +6523,18 @@ button.profile-stat:hover strong {
|
|||||||
|
|
||||||
/* 文章编辑器 */
|
/* 文章编辑器 */
|
||||||
.article-editor {
|
.article-editor {
|
||||||
flex: 1 0 auto;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
border-top: 1px solid var(--j13-border-light);
|
border-top: 1px solid var(--j13-border-light);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-editor-bar {
|
.article-editor-bar {
|
||||||
position: sticky;
|
position: relative;
|
||||||
/* 停在发布栏下方,避免与 .compose-header 重叠 */
|
|
||||||
top: var(--compose-header-sticky-h, 0px);
|
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -6481,31 +6694,31 @@ button.profile-stat:hover strong {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.article-editor-body {
|
.article-editor-body {
|
||||||
flex: 1 0 auto;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
min-height: 280px;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 编辑面板 */
|
/* 编辑面板 */
|
||||||
.article-editor-pane {
|
.article-editor-pane {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1 0 auto;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
min-height: 320px;
|
min-height: 0;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
/* 普通模式随正文增高,由外层 .main-content--compose 滚动 */
|
overflow: hidden;
|
||||||
overflow: visible;
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-editor-pane--source {
|
.article-editor-pane--source {
|
||||||
min-height: 320px;
|
min-height: 0;
|
||||||
border: 1px solid var(--j13-border-light);
|
border: 1px solid var(--j13-border-light);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: var(--j13-bg-block);
|
background: var(--j13-bg-block);
|
||||||
@@ -6536,11 +6749,11 @@ button.profile-stat:hover strong {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.article-editor-scroll {
|
.article-editor-scroll {
|
||||||
flex: 1 0 auto;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow-x: auto;
|
overflow: auto;
|
||||||
overflow-y: visible;
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6550,6 +6763,7 @@ button.profile-stat:hover strong {
|
|||||||
|
|
||||||
.article-editor-content {
|
.article-editor-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -6559,7 +6773,7 @@ button.profile-stat:hover strong {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
min-height: 360px;
|
min-height: 200px;
|
||||||
padding: 16px 24px 32px;
|
padding: 16px 24px 32px;
|
||||||
outline: none;
|
outline: none;
|
||||||
font-size: 15.5px;
|
font-size: 15.5px;
|
||||||
@@ -6587,11 +6801,11 @@ button.profile-stat:hover strong {
|
|||||||
|
|
||||||
/* 源码双栏:充分利用加宽画布 */
|
/* 源码双栏:充分利用加宽画布 */
|
||||||
.article-editor--markdown .article-editor-body {
|
.article-editor--markdown .article-editor-body {
|
||||||
min-height: 480px;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-page:has(.article-editor--markdown) .article-editor-markdown {
|
.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 {
|
.article-prosemirror p.is-editor-empty:first-child::before {
|
||||||
@@ -6894,8 +7108,7 @@ button.profile-stat:hover strong {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.article-editor-status {
|
.article-editor-status {
|
||||||
position: sticky;
|
position: relative;
|
||||||
bottom: 0;
|
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -6905,7 +7118,7 @@ button.profile-stat:hover strong {
|
|||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
border-top: 1px solid var(--j13-border-light);
|
border-top: 1px solid var(--j13-border-light);
|
||||||
background: var(--j13-bg-surface);
|
background: var(--j13-bg-surface);
|
||||||
box-shadow: none;
|
box-shadow: 0 -4px 12px rgba(15, 23, 42, 0.04);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--color-text-4);
|
color: var(--color-text-4);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -7062,18 +7275,21 @@ button.profile-stat:hover strong {
|
|||||||
|
|
||||||
.article-editor-markdown {
|
.article-editor-markdown {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
gap: 0;
|
gap: 0;
|
||||||
min-height: 420px;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-top: none;
|
border-top: none;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-editor--markdown .article-editor-pane--source {
|
.article-editor--markdown .article-editor-pane--source {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
border-right: 1px solid var(--j13-border-light);
|
border-right: 1px solid var(--j13-border-light);
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-editor--markdown .article-editor-markdown-preview {
|
.article-editor--markdown .article-editor-markdown-preview {
|
||||||
@@ -7099,7 +7315,8 @@ button.profile-stat:hover strong {
|
|||||||
.article-editor-markdown-preview {
|
.article-editor-markdown-preview {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 320px;
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
background: var(--j13-bg-surface);
|
background: var(--j13-bg-surface);
|
||||||
border: 1px solid var(--j13-border);
|
border: 1px solid var(--j13-border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -7186,7 +7403,7 @@ button.profile-stat:hover strong {
|
|||||||
|
|
||||||
.article-editor-markdown {
|
.article-editor-markdown {
|
||||||
grid-template-columns: 1fr;
|
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 {
|
.article-editor--markdown .article-editor-pane--source {
|
||||||
@@ -7195,7 +7412,7 @@ button.profile-stat:hover strong {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.article-editor-pane--source {
|
.article-editor-pane--source {
|
||||||
min-height: 240px;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-editor--fullscreen.article-editor--rich .article-editor-pane {
|
.article-editor--fullscreen.article-editor--rich .article-editor-pane {
|
||||||
@@ -7209,7 +7426,7 @@ button.profile-stat:hover strong {
|
|||||||
|
|
||||||
.article-editor-content .tiptap,
|
.article-editor-content .tiptap,
|
||||||
.article-prosemirror {
|
.article-prosemirror {
|
||||||
min-height: 280px;
|
min-height: 160px;
|
||||||
padding: 12px 14px 20px;
|
padding: 12px 14px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -132,10 +132,22 @@ export function renderPostContentHtml(
|
|||||||
|
|
||||||
enhanceHeadingAnchors(doc.body);
|
enhanceHeadingAnchors(doc.body);
|
||||||
enhanceCodeBlocks(doc.body);
|
enhanceCodeBlocks(doc.body);
|
||||||
|
wrapContentTables(doc.body);
|
||||||
|
|
||||||
return doc.body.innerHTML;
|
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 {
|
function isFloatDisplayImage(el: Element): boolean {
|
||||||
if (el.tagName !== 'IMG') return false;
|
if (el.tagName !== 'IMG') return false;
|
||||||
const display = el.getAttribute('data-display') || '';
|
const display = el.getAttribute('data-display') || '';
|
||||||
|
|||||||
@@ -5,13 +5,21 @@ export interface PostHeading {
|
|||||||
text: string;
|
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,并返回目录树数据 */
|
/** 为标题补全锚点 id,并返回目录树数据 */
|
||||||
export function enhanceHeadingAnchors(root: ParentNode): PostHeading[] {
|
export function enhanceHeadingAnchors(root: ParentNode): PostHeading[] {
|
||||||
const headings: PostHeading[] = [];
|
const headings: PostHeading[] = [];
|
||||||
const used = new Map<string, number>();
|
const used = new Map<string, number>();
|
||||||
|
const doc = root.ownerDocument ?? (typeof document !== 'undefined' ? document : null);
|
||||||
|
|
||||||
root.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach((el, index) => {
|
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;
|
if (!text) return;
|
||||||
|
|
||||||
const level = Number(el.tagName.slice(1)) || 2;
|
const level = Number(el.tagName.slice(1)) || 2;
|
||||||
@@ -25,6 +33,18 @@ export function enhanceHeadingAnchors(root: ParentNode): PostHeading[] {
|
|||||||
el.setAttribute('id', id);
|
el.setAttribute('id', id);
|
||||||
el.classList.add('post-heading-anchor');
|
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 });
|
headings.push({ id, level, text });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,7 +58,7 @@ export function extractHeadingsFromHtml(html: string): PostHeading[] {
|
|||||||
const headings: PostHeading[] = [];
|
const headings: PostHeading[] = [];
|
||||||
doc.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach(el => {
|
doc.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach(el => {
|
||||||
const id = el.getAttribute('id')?.trim();
|
const id = el.getAttribute('id')?.trim();
|
||||||
const text = (el.textContent || '').replace(/\s+/g, ' ').trim();
|
const text = headingPlainText(el);
|
||||||
if (!id || !text) return;
|
if (!id || !text) return;
|
||||||
headings.push({
|
headings.push({
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -313,7 +313,8 @@ func (h *Handlers) APICreatePost(c *gin.Context) {
|
|||||||
title := c.PostForm("title")
|
title := c.PostForm("title")
|
||||||
content := c.PostForm("content")
|
content := c.PostForm("content")
|
||||||
tags := c.PostForm("tags")
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -329,7 +330,7 @@ func (h *Handlers) APIUpdatePost(c *gin.Context) {
|
|||||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
boardID, _ := strconv.ParseUint(c.PostForm("board_id"), 10, 64)
|
boardID, _ := strconv.ParseUint(c.PostForm("board_id"), 10, 64)
|
||||||
err := h.Post.Update(h.currentUserID(c), uint(id), h.isAdmin(c),
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -368,6 +369,21 @@ func (h *Handlers) APIToggleFavorite(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"favorited": faved})
|
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) {
|
func (h *Handlers) APICreateComment(c *gin.Context) {
|
||||||
postID, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
postID, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
content := c.PostForm("content")
|
content := c.PostForm("content")
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ func InitDB(dbPath string) error {
|
|||||||
// 存量数据默认视为已公开,避免升级后内容全部进入待审
|
// 存量数据默认视为已公开,避免升级后内容全部进入待审
|
||||||
_ = db.Model(&Post{}).Where("status = '' OR status IS NULL").Update("status", ContentStatusPublished).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(&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
|
DB = db
|
||||||
log.Println("[model] SQLite 数据库初始化完成:", dbPath)
|
log.Println("[model] SQLite 数据库初始化完成:", dbPath)
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ const (
|
|||||||
ContentStatusRejected = "rejected" // 未通过(仅作者与管理员可见)
|
ContentStatusRejected = "rejected" // 未通过(仅作者与管理员可见)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 帖子类型
|
||||||
|
const (
|
||||||
|
PostTypeNormal = "normal" // 普通讨论
|
||||||
|
PostTypeQuestion = "question" // 问答(未解决 / 已解决)
|
||||||
|
)
|
||||||
|
|
||||||
// User 用户表
|
// User 用户表
|
||||||
// Email / Password / LastLogin* 默认不随帖子等嵌套 User 序列化;
|
// Email / Password / LastLogin* 默认不随帖子等嵌套 User 序列化;
|
||||||
// 个人中心与后台列表请用 UserSelf / UserAdmin。
|
// 个人中心与后台列表请用 UserSelf / UserAdmin。
|
||||||
@@ -64,6 +70,8 @@ type Post struct {
|
|||||||
Content string `gorm:"type:text;not null" json:"content"`
|
Content string `gorm:"type:text;not null" json:"content"`
|
||||||
ContentPlain string `gorm:"type:text" json:"-"` // 正文纯文本,供搜索索引
|
ContentPlain string `gorm:"type:text" json:"-"` // 正文纯文本,供搜索索引
|
||||||
Tags string `gorm:"size:256" json:"tags"`
|
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"`
|
Pinned bool `gorm:"default:false" json:"pinned"`
|
||||||
Featured bool `gorm:"default:false;index" json:"featured"` // 精华帖
|
Featured bool `gorm:"default:false;index" json:"featured"` // 精华帖
|
||||||
EditLocked bool `gorm:"default:false" json:"edit_locked"`
|
EditLocked bool `gorm:"default:false" json:"edit_locked"`
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
api.GET("/posts/:id/revisions/:revId", h.APIPostRevisionDetail)
|
api.GET("/posts/:id/revisions/:revId", h.APIPostRevisionDetail)
|
||||||
api.POST("/posts/:id/like", h.APIToggleLike)
|
api.POST("/posts/:id/like", h.APIToggleLike)
|
||||||
api.POST("/posts/:id/favorite", h.APIToggleFavorite)
|
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.POST("/posts/:id/report", middleware.RateLimitMiddleware(limiter, "report"), h.APICreatePostReport)
|
||||||
api.GET("/messages/unread-count", h.APIMessageUnreadCount)
|
api.GET("/messages/unread-count", h.APIMessageUnreadCount)
|
||||||
api.GET("/messages/conversations", h.APIMessageConversations)
|
api.GET("/messages/conversations", h.APIMessageConversations)
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ func NewPostService(filter *SensitiveFilter, settings *ForumSettingsService) *Po
|
|||||||
return &PostService{filter: filter, settings: settings}
|
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 {
|
type PostListQuery struct {
|
||||||
BoardID uint
|
BoardID uint
|
||||||
UserID uint // >0 时仅返回该用户的帖子
|
UserID uint // >0 时仅返回该用户的帖子
|
||||||
@@ -321,10 +330,11 @@ func (s *PostService) GetByID(id uint) (*model.Post, error) {
|
|||||||
return post, nil
|
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))
|
title = s.filter.Filter(strings.TrimSpace(title))
|
||||||
content = s.filter.Filter(content)
|
content = s.filter.Filter(content)
|
||||||
tags = s.filter.Filter(strings.TrimSpace(tags))
|
tags = s.filter.Filter(strings.TrimSpace(tags))
|
||||||
|
postType = normalizePostType(postType)
|
||||||
if title == "" || content == "" {
|
if title == "" || content == "" {
|
||||||
return nil, errors.New("标题和内容不能为空")
|
return nil, errors.New("标题和内容不能为空")
|
||||||
}
|
}
|
||||||
@@ -351,13 +361,16 @@ func (s *PostService) Create(userID, boardID uint, title, content, tags string,
|
|||||||
Content: content,
|
Content: content,
|
||||||
ContentPlain: StripHTMLForSearch(content),
|
ContentPlain: StripHTMLForSearch(content),
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
|
PostType: postType,
|
||||||
|
QuestionResolved: false,
|
||||||
Status: status,
|
Status: status,
|
||||||
}
|
}
|
||||||
return post, model.DB.Create(post).Error
|
return post, model.DB.Create(post).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update 更新帖子。boardID>0 时可改板块;为 0 时保持原板块。
|
// 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
|
var post model.Post
|
||||||
if err := model.DB.First(&post, postID).Error; err != nil {
|
if err := model.DB.First(&post, postID).Error; err != nil {
|
||||||
return ErrPostNotFound
|
return ErrPostNotFound
|
||||||
@@ -387,6 +400,14 @@ func (s *PostService) Update(userID, postID uint, isAdmin bool, title, content,
|
|||||||
}
|
}
|
||||||
nextBoardID = boardID
|
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 {
|
return model.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
rev := model.PostRevision{
|
rev := model.PostRevision{
|
||||||
PostID: postID, EditorID: userID,
|
PostID: postID, EditorID: userID,
|
||||||
@@ -401,6 +422,8 @@ func (s *PostService) Update(userID, postID uint, isAdmin bool, title, content,
|
|||||||
"content": content,
|
"content": content,
|
||||||
"content_plain": StripHTMLForSearch(content),
|
"content_plain": StripHTMLForSearch(content),
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
|
"post_type": nextType,
|
||||||
|
"question_resolved": nextResolved,
|
||||||
}
|
}
|
||||||
// 普通用户修改后重新进入审核
|
// 普通用户修改后重新进入审核
|
||||||
if !isAdmin {
|
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
|
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) {
|
func (s *PostService) ToggleLike(userID, postID uint) (liked bool, err error) {
|
||||||
var like model.PostLike
|
var like model.PostLike
|
||||||
result := model.DB.Where("post_id = ? AND user_id = ?", postID, userID).Limit(1).Find(&like)
|
result := model.DB.Where("post_id = ? AND user_id = ?", postID, userID).Limit(1).Find(&like)
|
||||||
|
|||||||
Reference in New Issue
Block a user