feat: 帖子全局/板块置顶支持权重排序,优化列表与详情渲染

This commit is contained in:
2026-09-08 23:54:03 +08:00
parent de2f5e8be8
commit 711cd8b759
16 changed files with 362 additions and 64 deletions

View File

@@ -154,12 +154,12 @@ export const api = {
},
adminApprovePost: (id: number) =>
request<{ message: string; status: string }>(`/api/admin/posts/${id}/approve`, { method: 'POST' }),
adminPinPost: (id: number, pinned: boolean) =>
request<{ message: string; pinned: boolean }>(`/api/admin/posts/${id}/pin`, {
adminPinPost: (id: number, pinned: number) =>
request<{ message: string; pinned: number }>(`/api/admin/posts/${id}/pin`, {
method: 'POST', body: JSON.stringify({ pinned }),
}),
adminBoardPinPost: (id: number, boardPinned: boolean) =>
request<{ message: string; board_pinned: boolean }>(`/api/admin/posts/${id}/board-pin`, {
adminBoardPinPost: (id: number, boardPinned: number) =>
request<{ message: string; board_pinned: number }>(`/api/admin/posts/${id}/board-pin`, {
method: 'POST', body: JSON.stringify({ board_pinned: boardPinned }),
}),
adminFeaturePost: (id: number, featured: boolean) =>

View File

@@ -94,9 +94,9 @@ export interface PostItem {
bounty_comment_id?: number;
lottery_winner_count?: number;
lottery_status?: 'open' | 'drawn' | string;
pinned: boolean;
pinned: number;
/** 板块内置顶(仅板块列表抬升,首页不抬升) */
board_pinned?: boolean;
board_pinned?: number;
featured?: boolean;
edit_locked?: boolean;
/** 禁止新评论(结贴) */

View File

@@ -71,10 +71,10 @@ function PostListItem({ post, sort = 'latest', boardId = 0, onSelect }: Props) {
const titleRow = (
<div className="post-title-row">
{post.pinned && (
{post.pinned > 0 && (
<span className="post-pin-badge" title="全局置顶">全局置顶</span>
)}
{post.board_pinned && (
{(post.board_pinned ?? 0) > 0 && (
<span className="post-pin-badge post-pin-badge--board" title="板块置顶">板块置顶</span>
)}
{post.featured && (

View File

@@ -151,11 +151,11 @@ export default function PostManageMenu({
</DropdownMenuItem>
<DropdownMenuItem onSelect={onPin}>
<Pin size={14} aria-hidden />
{post.pinned ? '取消全局置顶' : '全局置顶'}
{post.pinned > 0 ? '取消全局置顶' : '全局置顶'}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onBoardPin}>
<Pin size={14} aria-hidden />
{post.board_pinned ? '取消板块置顶' : '板块置顶'}
{(post.board_pinned ?? 0) > 0 ? '取消板块置顶' : '板块置顶'}
</DropdownMenuItem>
<DropdownMenuSeparator />

View File

@@ -98,10 +98,10 @@ export default function StaticFeedList({ posts, sort, boardId }: Props) {
const titleRow = (
<div className="post-title-row">
{post.pinned ? (
{post.pinned > 0 ? (
<span className="post-pin-badge" title="全局置顶">全局置顶</span>
) : null}
{post.board_pinned ? (
{(post.board_pinned ?? 0) > 0 ? (
<span className="post-pin-badge post-pin-badge--board" title="板块置顶">板块置顶</span>
) : null}
{post.featured ? <span className="post-feature-badge" title="推荐">推荐</span> : null}

View File

@@ -806,7 +806,8 @@ export default function PostDetailPage() {
const handlePin = async () => {
if (!post) return;
try {
const r = await api.adminPinPost(postId, !post.pinned);
const next = post.pinned > 0 ? 0 : 1;
const r = await api.adminPinPost(postId, next);
setPost(p => p ? { ...p, pinned: r.pinned } : p);
clearAllFeedCache();
window.dispatchEvent(new Event('posts-refresh'));
@@ -819,7 +820,8 @@ export default function PostDetailPage() {
const handleBoardPin = async () => {
if (!post) return;
try {
const r = await api.adminBoardPinPost(postId, !post.board_pinned);
const next = (post.board_pinned ?? 0) > 0 ? 0 : 1;
const r = await api.adminBoardPinPost(postId, next);
setPost(p => p ? { ...p, board_pinned: r.board_pinned } : p);
clearAllFeedCache();
window.dispatchEvent(new Event('posts-refresh'));
@@ -995,10 +997,10 @@ export default function PostDetailPage() {
<div className="post-detail-head">
<h1 className="post-detail-title">
{post.pinned && (
{post.pinned > 0 && (
<span className="post-pin-badge post-pin-badge--detail" title="全局置顶">全局置顶</span>
)}
{post.board_pinned && (
{(post.board_pinned ?? 0) > 0 && (
<span className="post-pin-badge post-pin-badge--board post-pin-badge--detail" title="板块置顶">板块置顶</span>
)}
{post.featured && <FeaturedIcon className="mr-2" size={18} />}

View File

@@ -246,9 +246,9 @@ export default function AdminDashboardPage() {
</td>
<td className="space-x-1">
{p.featured ? <Badge variant="orange">推荐</Badge> : null}
{p.pinned ? <Badge variant="green">全局置顶</Badge> : null}
{p.board_pinned ? <Badge variant="green">板块置顶</Badge> : null}
{!p.featured && !p.pinned && !p.board_pinned ? '—' : null}
{p.pinned > 0 ? <Badge variant="green">全局置顶</Badge> : null}
{(p.board_pinned ?? 0) > 0 ? <Badge variant="green">板块置顶</Badge> : null}
{!p.featured && p.pinned <= 0 && (p.board_pinned ?? 0) <= 0 ? '—' : null}
</td>
<td>{new Date(p.created_at).toLocaleString('zh-CN')}</td>
</tr>

View File

@@ -141,9 +141,13 @@ export default function AdminPostsPage() {
setSearch('');
};
const togglePin = async (post: PostItem) => {
const togglePin = async (post: PostItem, nextValue?: number) => {
try {
const r = await api.adminPinPost(post.id, !post.pinned);
let value = nextValue;
if (value === undefined) {
value = post.pinned > 0 ? 0 : 1;
}
const r = await api.adminPinPost(post.id, value);
clearAllFeedCache();
window.dispatchEvent(new Event('posts-refresh'));
notify.success(r.message);
@@ -153,9 +157,13 @@ export default function AdminPostsPage() {
}
};
const toggleBoardPin = async (post: PostItem) => {
const toggleBoardPin = async (post: PostItem, nextValue?: number) => {
try {
const r = await api.adminBoardPinPost(post.id, !post.board_pinned);
let value = nextValue;
if (value === undefined) {
value = (post.board_pinned ?? 0) > 0 ? 0 : 1;
}
const r = await api.adminBoardPinPost(post.id, value);
clearAllFeedCache();
window.dispatchEvent(new Event('posts-refresh'));
notify.success(r.message);
@@ -417,8 +425,8 @@ export default function AdminPostsPage() {
<td className="max-w-[120px] truncate text-muted-foreground">{p.tags || '—'}</td>
<td>{p.comment_count ?? 0}</td>
<td>{p.featured ? <Badge variant="orange">是</Badge> : '—'}</td>
<td>{p.pinned ? <Badge variant="green">是</Badge> : '—'}</td>
<td>{p.board_pinned ? <Badge variant="green">是</Badge> : '—'}</td>
<td>{p.pinned > 0 ? <Badge variant="green">置顶 {p.pinned}</Badge> : '—'}</td>
<td>{(p.board_pinned ?? 0) > 0 ? <Badge variant="green">置顶 {p.board_pinned}</Badge> : '—'}</td>
<td>{p.edit_locked ? <Badge variant="destructive">是</Badge> : '—'}</td>
<td>{p.comments_locked ? <Badge variant="destructive">是</Badge> : '—'}</td>
<td>{p.like_count}</td>
@@ -444,11 +452,55 @@ export default function AdminPostsPage() {
<Button size="sm" variant="outline" onClick={() => toggleFeature(p)}>
{p.featured ? '取消推荐' : '推荐'}
</Button>
<Button size="sm" variant="outline" onClick={() => togglePin(p)}>
{p.pinned ? '取消全局置顶' : '全局置顶'}
<Button size="sm" variant="outline" onClick={() => {
const cur = p.pinned;
if (cur > 0) {
// 已置顶:弹出输入框让管理员调整顺序值
const input = window.prompt(`全局置顶顺序(当前: ${cur},输入 0 取消置顶)`, String(cur));
if (input == null) return;
const n = parseInt(input, 10);
if (Number.isNaN(n) || n < 0) {
notify.warning('请输入有效的非负整数');
return;
}
void togglePin(p, n);
} else {
// 未置顶:输入框让管理员设置初始值
const input = window.prompt('设置全局置顶顺序(输入 1 即置顶,0 表示不置顶)', '1');
if (input == null) return;
const n = parseInt(input, 10);
if (Number.isNaN(n) || n < 0) {
notify.warning('请输入有效的非负整数');
return;
}
void togglePin(p, n);
}
}}>
{p.pinned > 0 ? `全局置顶(${p.pinned})` : '全局置顶'}
</Button>
<Button size="sm" variant="outline" onClick={() => toggleBoardPin(p)}>
{p.board_pinned ? '取消板块置顶' : '板块置顶'}
<Button size="sm" variant="outline" onClick={() => {
const cur = p.board_pinned ?? 0;
if (cur > 0) {
const input = window.prompt(`板块置顶顺序(当前: ${cur},输入 0 取消置顶)`, String(cur));
if (input == null) return;
const n = parseInt(input, 10);
if (Number.isNaN(n) || n < 0) {
notify.warning('请输入有效的非负整数');
return;
}
void toggleBoardPin(p, n);
} else {
const input = window.prompt('设置板块置顶顺序(输入 1 即置顶,0 表示不置顶)', '1');
if (input == null) return;
const n = parseInt(input, 10);
if (Number.isNaN(n) || n < 0) {
notify.warning('请输入有效的非负整数');
return;
}
void toggleBoardPin(p, n);
}
}}>
{(p.board_pinned ?? 0) > 0 ? `板块置顶(${p.board_pinned})` : '板块置顶'}
</Button>
<Button size="sm" variant="outline" onClick={() => toggleLock(p)}>
{p.edit_locked ? <><LockOpen size={14} /> 解锁编辑</> : <><Lock size={14} /> 锁定编辑</>}

View File

@@ -5905,15 +5905,6 @@ 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; }
@@ -6194,7 +6185,7 @@ a.post-title:visited {
/* highlight.js token(亮色) */
.post-detail-content .hljs-comment,
.post-detail-content .hljs-quote { color: #6e7781; font-style: italic; }
.post-detail-content .hljs-quote { color: #6e7781; }
.post-detail-content .hljs-keyword,
.post-detail-content .hljs-selector-tag,
.post-detail-content .hljs-addition { color: #cf222e; }

View File

@@ -101,28 +101,22 @@ export function enhanceCodeBlocks(root: ParentNode): void {
const raw = code.textContent || '';
const display = readDisplayOptions(pre);
// 作者写了语言标签则以标注为准,绝不被自动识别覆盖
// 作者写了语言标签则以标注为准,绝不被自动识别覆盖;纯文本不做自动检测
const declaredLang = detectLang(code, pre);
let label = formatLangLabel(declaredLang || 'code');
let label = declaredLang ? formatLangLabel(declaredLang) : '';
try {
if (declaredLang && hljs.getLanguage(declaredLang)) {
// 作者标注且 hljs 收录 → 语法高亮
const result = hljs.highlight(raw, { language: declaredLang, ignoreIllegals: true });
code.innerHTML = wrapCodeLines(result.value, display.lineNumbers);
code.classList.add('hljs', `language-${declaredLang}`);
} else if (declaredLang) {
// 未收录语言:保留原文与标签,不做自动猜测
// 作者标注但 hljs 未收录 → 保留原文与标签,不做自动猜测
code.innerHTML = wrapCodeLines(escapeHtml(raw), display.lineNumbers);
code.classList.add('hljs', `language-${declaredLang}`);
} else if (raw.length >= 24) {
const result = hljs.highlightAuto(raw);
code.innerHTML = wrapCodeLines(result.value, display.lineNumbers);
code.classList.add('hljs');
if (result.language) {
label = formatLangLabel(result.language);
code.classList.add(`language-${result.language}`);
}
} else {
// 纯文本(无语言标注)→ 不做自动检测,保持纯文本显示
code.innerHTML = wrapCodeLines(escapeHtml(raw), display.lineNumbers);
code.classList.add('hljs');
}
@@ -146,7 +140,7 @@ export function enhanceCodeBlocks(root: ParentNode): void {
initiallyCollapsed ? 'md-codeblock--collapsed' : '',
initiallyCollapsed && lineCount <= CODE_FOLD_MIN_LINES ? 'md-codeblock--short' : '',
].filter(Boolean).join(' ');
wrap.setAttribute('data-lang', label);
if (label) wrap.setAttribute('data-lang', label);
if (display.lineNumbers) wrap.setAttribute('data-line-numbers', 'true');
if (display.collapsed) wrap.setAttribute('data-collapsed', 'true');
wrap.setAttribute('data-line-count', String(lineCount));
@@ -165,8 +159,9 @@ export function enhanceCodeBlocks(root: ParentNode): void {
}
actions.push('<button type="button" class="md-codeblock__copy" data-code-copy>复制</button>');
const langHtml = label ? `<span class="md-codeblock__lang">${escapeHtml(label)}</span>` : '';
head.innerHTML = `
<span class="md-codeblock__lang">${escapeHtml(label)}</span>
${langHtml}
<span class="md-codeblock__actions">${actions.join('')}</span>
`;