feat: 增加友链申请、独立页面、投票/悬赏/抽奖帖与侧栏签到,并统一开发数据目录

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-27 06:23:54 +08:00
parent df19752a1e
commit 2208af7070
80 changed files with 9620 additions and 641 deletions

View File

@@ -1,13 +1,14 @@
import { memo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Eye, Image as ImageIcon, MessageCircle, ThumbsUp } from 'lucide-react';
import BoardBadge from '@/components/BoardBadge';
import FeaturedIcon from '@/components/FeaturedIcon';
import UserLink from '@/components/UserLink';
import type { PostItem } from '../api/types';
import type { FeedSort } from './FeedSortBar';
import { useForumLimits } from '../hooks/useForumLimits';
import { formatTime } from '../utils/content';
import { postPath } from '../utils/permalink';
import { toPostImageThumbSrc } from '../utils/postContent';
import { excerptFromHTML, firstImageFromHTML } from '../utils/seoText';
import { parseTags } from './TagInput';
@@ -19,6 +20,11 @@ interface Props {
function PostListItem({ post, sort = 'latest', onSelect }: Props) {
const nav = useNavigate();
const { limits } = useForumLimits();
const feedStyle = limits.feed_list_style ?? 'title';
const showExcerpt = feedStyle === 'excerpt' || feedStyle === 'thumbnail';
const showThumb = feedStyle === 'thumbnail';
const initial = post.user?.nickname?.[0] || '?';
const timeLabel = sort === 'reply'
? (post.last_reply_at
@@ -29,8 +35,10 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
const likeCount = post.like_count ?? 0;
const viewCount = post.view_count ?? 0;
const href = postPath(post.id);
const excerpt = excerptFromHTML(post.content || '', 72);
const hasImage = !!firstImageFromHTML(post.content || '');
const firstImage = firstImageFromHTML(post.content || '');
const thumbSrc = showThumb && firstImage ? toPostImageThumbSrc(firstImage) : null;
const excerpt = showExcerpt ? excerptFromHTML(post.content || '', 60) : '';
const showImageIcon = !!firstImage && !thumbSrc;
const tagList = parseTags(post.tags || '').slice(0, 3);
const openPost = () => onSelect(post.id);
@@ -41,7 +49,6 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
}
};
const onTitleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
// 修饰键 / 非左键:交给浏览器(新标签等)
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
e.stopPropagation();
return;
@@ -51,9 +58,107 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
openPost();
};
const titleRow = (
<div className="post-title-row">
{post.pinned && (
<span className="post-pin-badge" title="全局置顶"></span>
)}
{post.board_pinned && (
<span className="post-pin-badge post-pin-badge--board" title="板块置顶"></span>
)}
{post.featured && (
<span className="post-feature-badge" title="精华">
<FeaturedIcon size={12} />
</span>
)}
{post.status === 'pending' && (
<span className="post-status-badge post-status-badge--pending" title="审核中"></span>
)}
{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>
)}
{post.post_type === 'poll' && (
<span className="post-type-badge post-type-badge--poll" title="投票"></span>
)}
{post.post_type === 'bounty' && post.bounty_status === 'open' && (post.bounty_points ?? 0) > 0 && (
<span className="post-bounty-badge post-bounty-badge--open" title="悬赏"> {post.bounty_points}</span>
)}
{post.post_type === 'bounty' && post.bounty_status === 'awarded' && (
<span className="post-bounty-badge post-bounty-badge--awarded" title="已采纳"></span>
)}
{post.post_type === 'lottery' && (
<span className="post-type-badge post-type-badge--lottery" title="抽奖">
{post.lottery_status === 'drawn' ? '已开奖' : '抽奖'}
</span>
)}
<a href={href} className="post-title" onClick={onTitleClick}>
{post.title}
</a>
</div>
);
const metaLeft = (
<div className="post-meta-left">
<UserLink user={post.user} stopPropagation className="post-meta-author" showBadges={false} />
<span className="post-meta-sep" aria-hidden>·</span>
<span className="post-meta-time">{timeLabel}</span>
{post.board && (
<>
<span className="post-meta-sep" aria-hidden>·</span>
<span className="post-meta-board">{post.board.name}</span>
</>
)}
{tagList.map(t => (
<button
key={t}
type="button"
className="post-list-tag"
title={`筛选标签:${t}`}
onClick={(e) => {
e.stopPropagation();
nav(`/?tag=${encodeURIComponent(t)}`);
}}
>
#{t}
</button>
))}
</div>
);
const stats = (
<div className="post-stats">
{showImageIcon && (
<span className="post-stat post-stat--media" title="含图片">
<ImageIcon aria-hidden />
</span>
)}
<span className={`post-stat${commentCount === 0 ? ' post-stat--zero' : ''}`} title="评论">
<MessageCircle aria-hidden />
{commentCount}
</span>
<span className={`post-stat${likeCount === 0 ? ' post-stat--zero' : ''}`} title="点赞">
<ThumbsUp aria-hidden />
{likeCount}
</span>
<span className={`post-stat${viewCount === 0 ? ' post-stat--zero' : ''}`} title="浏览">
<Eye aria-hidden />
{viewCount}
</span>
</div>
);
return (
<div
className="post-row"
className={`post-row post-row--v2${thumbSrc ? ' post-row--has-thumb' : ''}`}
role="link"
tabIndex={0}
onClick={openPost}
@@ -71,88 +176,32 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
: initial}
</UserLink>
<div className="post-body">
<div className="post-head">
<div className="post-head-meta">
<UserLink user={post.user} stopPropagation className="post-author" showBadges />
<span className="post-head-dot" aria-hidden>·</span>
<span className="post-time">{timeLabel}</span>
{thumbSrc ? (
<div className="post-main post-main--with-thumb">
<div className="post-content">
{titleRow}
{excerpt && <p className="post-excerpt">{excerpt}</p>}
<div className="post-meta post-meta--inline">{metaLeft}</div>
</div>
<div className="post-aside">
<div className="post-thumb" aria-hidden>
<img src={thumbSrc} alt="" loading="lazy" decoding="async" />
</div>
{stats}
</div>
</div>
<div className="post-title-row">
{post.pinned && (
<span className="post-pin-badge" title="全局置顶"></span>
)}
{post.board_pinned && (
<span className="post-pin-badge post-pin-badge--board" title="板块置顶"></span>
)}
{post.featured && (
<span className="post-feature-badge" title="精华">
<FeaturedIcon size={12} />
</span>
)}
{post.status === 'pending' && (
<span className="post-status-badge post-status-badge--pending" title="审核中"></span>
)}
{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>
)}
<a href={href} className="post-title" onClick={onTitleClick}>
{post.title}
</a>
</div>
{excerpt && <p className="post-excerpt">{excerpt}</p>}
<div className="post-foot">
<div className="post-foot-left">
{post.board && <BoardBadge board={post.board} />}
{tagList.map(t => (
<button
key={t}
type="button"
className="post-list-tag"
title={`筛选标签:${t}`}
onClick={(e) => {
e.stopPropagation();
nav(`/?tag=${encodeURIComponent(t)}`);
}}
>
{t}
</button>
))}
) : (
<div className="post-main">
<div className="post-text">
{titleRow}
{excerpt && <p className="post-excerpt">{excerpt}</p>}
</div>
<div className="post-stats">
{hasImage && (
<span className="post-stat post-stat--media" title="含图片">
<ImageIcon aria-hidden />
</span>
)}
<span className={`post-stat${commentCount === 0 ? ' post-stat--zero' : ''}`} title="评论">
<MessageCircle aria-hidden />
{commentCount}
</span>
<span className={`post-stat${likeCount === 0 ? ' post-stat--zero' : ''}`} title="点赞">
<ThumbsUp aria-hidden />
{likeCount}
</span>
<span className={`post-stat${viewCount === 0 ? ' post-stat--zero' : ''}`} title="浏览">
<Eye aria-hidden />
{viewCount}
</span>
<div className="post-meta">
{metaLeft}
{stats}
</div>
</div>
</div>
)}
</div>
);
}