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

@@ -0,0 +1,104 @@
import { CalendarCheck, Check, Gift, Loader2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Skeleton } from '@/components/ui/skeleton';
import { useCheckIn } from '../hooks/useCheckIn';
import { useAuth } from '../hooks/useAuth';
import { loginPath } from '../utils/authRedirect';
/** 右侧栏首块底部:每日签到 */
export default function AsideCheckInStrip() {
const nav = useNavigate();
const { user } = useAuth();
const { status, loading, busy, doCheckIn } = useCheckIn();
if (!user) {
return (
<div className="widget-checkin">
<div className="widget-checkin-panel widget-checkin-panel--guest">
<div className="widget-checkin-main">
<div className="widget-checkin-icon" aria-hidden>
<CalendarCheck size={18} strokeWidth={2.25} />
</div>
<div className="widget-checkin-info">
<span className="widget-checkin-title"></span>
<span className="widget-checkin-meta"> 515 </span>
</div>
</div>
<button
type="button"
className="widget-checkin-action"
onClick={() => nav(loginPath())}
>
<Gift size={15} aria-hidden />
</button>
</div>
</div>
);
}
if (loading && !status) {
return (
<div className="widget-checkin" aria-busy="true" aria-label="签到加载中">
<Skeleton className="widget-checkin-skeleton" />
</div>
);
}
const checkedIn = !!status?.checked_in;
const streak = status?.streak ?? 0;
const todayPoints = status?.today_points ?? 5;
const meta = checkedIn
? (streak > 0 ? `连续 ${streak} 天 · 今日已获得 ${todayPoints} 积分` : `今日已获得 ${todayPoints} 积分`)
: (streak > 0 ? `连续 ${streak} 天 · 今日可得 ${todayPoints} 积分` : `今日签到可得 ${todayPoints} 积分`);
return (
<div className="widget-checkin">
<div
className={`widget-checkin-panel${checkedIn ? ' widget-checkin-panel--done' : ''}`}
>
<div className="widget-checkin-main">
<div className="widget-checkin-icon" aria-hidden>
{checkedIn ? (
<Check size={18} strokeWidth={2.5} />
) : (
<CalendarCheck size={18} strokeWidth={2.25} />
)}
</div>
<div className="widget-checkin-info">
<span className="widget-checkin-title">
{checkedIn ? '今日已签到' : '每日签到'}
</span>
<span className="widget-checkin-meta">{meta}</span>
</div>
{!checkedIn && (
<span className="widget-checkin-reward" aria-hidden>
{todayPoints}
</span>
)}
</div>
{!checkedIn && (
<button
type="button"
className="widget-checkin-action"
disabled={busy}
onClick={doCheckIn}
>
{busy ? (
<>
<Loader2 size={15} className="widget-checkin-action-spinner animate-spin" aria-hidden />
</>
) : (
<>
<Gift size={15} aria-hidden />
</>
)}
</button>
)}
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import {
Check, Clock, History, MessageSquare, X, Pencil, Trash2,
Check, Award, Clock, History, MessageSquare, X, Pencil, Trash2,
ThumbsUp, MoreHorizontal, Flag,
} from 'lucide-react';
import type { ReactNode } from 'react';
@@ -51,6 +51,7 @@ import { isHtmlEmpty } from '../utils/postContent';
import { Tooltip } from './ui/Tooltip';
import UserLink from './UserLink';
import { cn } from '@/lib/utils';
import { pinAwardedCommentTree } from '../utils/bounty';
function isCommentAuthor(c: Comment, user?: User | null): boolean {
return !!user && c.user_id > 0 && c.user_id === user.id;
@@ -83,6 +84,13 @@ interface ItemProps {
onRequireLogin?: (actionLabel: string) => void;
onLikeUpdate?: (commentId: number, liked: boolean, likeCount: number) => void;
renderReplyBox?: (comment: Comment) => ReactNode;
bountyAward?: {
open: boolean;
awardedCommentId?: number;
postAuthorId: number;
canAward: boolean;
onAward: (commentId: number) => void;
};
}
/** 单条评论(支持嵌套子回复 + 内联回复框 + 编辑/删除) */
@@ -103,12 +111,14 @@ function CommentItem({
onRequireLogin,
onLikeUpdate,
renderReplyBox,
bountyAward,
}: ItemProps) {
const { limits } = useForumLimits();
const c = node.comment;
const nick = commentNick(c);
const guest = isGuestComment(c);
const isHighlighted = highlightFloor === c.floor;
const isBountyAwarded = bountyAward?.awardedCommentId === c.id;
const hidden = !!c.content_hidden;
const isReplying = replyToId === c.id;
const isEditing = editingId === c.id;
@@ -215,7 +225,12 @@ function CommentItem({
return (
<div
id={`floor-${c.floor}`}
className={`waline-comment ${nested ? 'nested' : ''} ${isHighlighted ? 'highlight' : ''}`}
className={cn(
'waline-comment',
nested && 'nested',
isHighlighted && 'highlight',
isBountyAwarded && 'waline-comment--bounty-awarded',
)}
>
{!guest && c.user_id ? (
<UserLink
@@ -255,6 +270,12 @@ function CommentItem({
) : (
<span className="waline-comment-author">{nick}</span>
)}
{isBountyAwarded && (
<span className="waline-comment-bounty-badge" title="悬赏已采纳">
<Check size={12} aria-hidden />
</span>
)}
{!hidden && (
<button
type="button"
@@ -351,6 +372,17 @@ function CommentItem({
</button>
)}
{bountyAward?.open && bountyAward.canAward && c.user_id !== bountyAward.postAuthorId
&& c.status === 'published' && !hidden && (
<button
type="button"
className="bounty-award-btn"
onClick={() => bountyAward.onAward(c.id)}
>
<Award size={14} aria-hidden />
</button>
)}
{!hidden && !isEditing && isAdmin && showEdited && (
<button type="button" className="waline-comment-reply-btn" onClick={() => setRevOpen(true)}>
<History size={14} />
@@ -481,6 +513,7 @@ function CommentItem({
onRequireLogin={onRequireLogin}
onLikeUpdate={onLikeUpdate}
renderReplyBox={renderReplyBox}
bountyAward={bountyAward}
/>
))}
</div>
@@ -506,6 +539,7 @@ interface Props {
onRequireLogin?: (actionLabel: string) => void;
onLikeUpdate?: (commentId: number, liked: boolean, likeCount: number) => void;
renderReplyBox?: (comment: Comment) => ReactNode;
bountyAward?: ItemProps['bountyAward'];
}
/** Waline 嵌套楼层评论列表 */
@@ -525,8 +559,12 @@ export default function CommentThreadList({
onRequireLogin,
onLikeUpdate,
renderReplyBox,
bountyAward,
}: Props) {
const tree = buildCommentTree(comments);
const tree = pinAwardedCommentTree(
buildCommentTree(comments),
bountyAward?.awardedCommentId,
);
return (
<div className="comment-thread-list">
@@ -548,6 +586,7 @@ export default function CommentThreadList({
onRequireLogin={onRequireLogin}
onLikeUpdate={onLikeUpdate}
renderReplyBox={renderReplyBox}
bountyAward={bountyAward}
/>
))}
</div>

View File

@@ -1,6 +1,7 @@
import { Users, FileText, LayoutGrid } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import type { Board, ForumStats } from '../api/types';
import { navigateFeed } from '../utils/feedCache';
interface Props {
boardId: number;
@@ -81,7 +82,7 @@ export default function FeedHeader({
<button
type="button"
className="feed-head__clear"
onClick={() => nav('/')}
onClick={() => navigateFeed(nav, '/')}
>
{tag ? '清除标签' : '清除搜索'}
</button>

View File

@@ -1,8 +1,11 @@
import { Skeleton } from '@/components/ui/skeleton';
import PostListSkeleton from './PostListSkeleton';
import { useForumLimits } from '../hooks/useForumLimits';
/** 首页 Feed 初始骨架(标题区 + 排序栏 + 列表) */
export default function FeedPageSkeleton() {
const { limits } = useForumLimits();
return (
<div className="page-wrap page-wrap--feed" aria-busy="true" aria-label="内容加载中">
<div className="feed-panel">
@@ -27,7 +30,7 @@ export default function FeedPageSkeleton() {
</div>
</div>
<div className="post-list-scroll">
<PostListSkeleton />
<PostListSkeleton listStyle={limits.feed_list_style ?? 'title'} />
</div>
</div>
</div>

View File

@@ -1,4 +1,6 @@
import { useRef } from 'react';
import { boardPath, type PermalinkOpts } from '../utils/permalink';
import { getCachedForumLimits } from '../hooks/useForumLimits';
import { Clock, MessageCircle, Flame } from 'lucide-react';
import { cn } from '@/lib/utils';
import { moveTabIndex } from '../hooks/useOverlayA11y';
@@ -30,10 +32,9 @@ export function parseFeedSort(raw: string | null): FeedSort {
export function buildHomeUrl(
boardId: number,
sort: FeedSort = 'latest',
opts?: { keyword?: string; tag?: string; author?: string; titleOnly?: boolean },
opts?: { keyword?: string; tag?: string; author?: string; titleOnly?: boolean; permalink?: PermalinkOpts },
) {
const p = new URLSearchParams();
if (boardId) p.set('board', String(boardId));
const tag = opts?.tag?.trim();
const keyword = opts?.keyword?.trim();
const author = opts?.author?.trim();
@@ -48,6 +49,11 @@ export function buildHomeUrl(
}
if (sort !== 'latest') p.set('sort', sort);
const qs = p.toString();
if (boardId) {
const base = boardPath(boardId, opts?.permalink ?? getCachedForumLimits());
return qs ? `${base}?${qs}` : base;
}
return qs ? `/?${qs}` : '/';
}

View File

@@ -0,0 +1,276 @@
import { useEffect, useRef, useState } from 'react';
import { Link2, Upload } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { cn } from '@/lib/utils';
import { api } from '../api/client';
import type { FriendLinkApply } from '../api/types';
import { getCachedSiteBranding } from '../hooks/useSiteBranding';
import { resolveFriendLinkLogo } from '../utils/friendLink';
import FriendLinkSiteInfo from './FriendLinkSiteInfo';
type LinkPlacement = 'homepage' | 'custom';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
editApply?: FriendLinkApply | null;
onSubmitted?: () => void;
}
/** 友链申请 / 修改弹窗 */
export default function FriendLinkApplyDialog({ open, onOpenChange, editApply, onSubmitted }: Props) {
const [name, setName] = useState('');
const [url, setUrl] = useState('');
const [logo, setLogo] = useState('');
const [linkPlacement, setLinkPlacement] = useState<LinkPlacement>('homepage');
const [reciprocalPageURL, setReciprocalPageURL] = useState('');
const [submitting, setSubmitting] = useState(false);
const [uploadingLogo, setUploadingLogo] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const isEdit = !!editApply?.id;
const reset = () => {
setName('');
setUrl('');
setLogo('');
setLinkPlacement('homepage');
setReciprocalPageURL('');
};
useEffect(() => {
if (!open) return;
if (editApply) {
setName(editApply.name ?? '');
setUrl(editApply.url ?? '');
setLogo(editApply.logo ?? '');
const onHomepage = editApply.link_on_homepage !== false;
setLinkPlacement(onHomepage ? 'homepage' : 'custom');
setReciprocalPageURL(onHomepage ? '' : (editApply.reciprocal_page_url ?? ''));
} else {
reset();
}
}, [open, editApply]);
const handleOpenChange = (next: boolean) => {
if (!next) reset();
onOpenChange(next);
};
const uploadLogo = async (file: File | undefined) => {
if (!file) return;
setUploadingLogo(true);
try {
const r = await api.uploadFriendLinkLogo(file);
setLogo(r.url);
notify.success('LOGO 已上传');
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '上传失败');
} finally {
setUploadingLogo(false);
if (fileRef.current) fileRef.current.value = '';
}
};
const submit = async () => {
const trimmedName = name.trim();
const trimmedURL = url.trim();
const trimmedLogo = logo.trim();
const trimmedReciprocal = reciprocalPageURL.trim();
if (!trimmedName || !trimmedURL) {
notify.warning('请填写网站名称与网站链接');
return;
}
if (!/^https?:\/\//i.test(trimmedURL)) {
notify.warning('网站链接需以 http:// 或 https:// 开头');
return;
}
if (!trimmedLogo) {
notify.warning('请填写或上传网站 LOGO');
return;
}
if (linkPlacement === 'custom') {
if (!trimmedReciprocal) {
notify.warning('请填写添加本站链接的页面地址');
return;
}
if (!/^https?:\/\//i.test(trimmedReciprocal)) {
notify.warning('回链页地址需以 http:// 或 https:// 开头');
return;
}
}
const linkOnHomepage = linkPlacement === 'homepage';
const body = {
name: trimmedName,
url: trimmedURL,
logo: trimmedLogo,
link_on_homepage: linkOnHomepage,
reciprocal_page_url: linkOnHomepage ? trimmedURL : trimmedReciprocal,
};
setSubmitting(true);
try {
if (isEdit) {
const r = await api.updateFriendLinkApply(editApply!.id, body);
notify.success(
editApply?.status === 'approved'
? `已更新并重新提交审核,友链已暂时从列表移除${r.message.includes('回链检测') ? ',回链检测将在后台进行' : ''}`
: r.message,
);
} else {
const r = await api.applyFriendLink(body);
notify.success(r.message);
}
onSubmitted?.();
handleOpenChange(false);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '提交失败');
} finally {
setSubmitting(false);
}
};
const logoPreview = resolveFriendLinkLogo(logo.trim(), getCachedSiteBranding().site_url);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="friend-link-apply-dialog sm:max-w-[560px]">
<DialogHeader className="friend-link-apply-dialog__header">
<DialogTitle>{isEdit ? '修改友链申请' : '申请本页友情链接'}</DialogTitle>
</DialogHeader>
<FriendLinkSiteInfo />
<div className="friend-link-apply-form">
<div className="friend-link-apply-field">
<Label htmlFor="friend-link-name"></Label>
<Input
id="friend-link-name"
value={name}
onChange={e => setName(e.target.value)}
placeholder="请输入您的网站名称"
maxLength={32}
/>
</div>
<div className="friend-link-apply-field">
<Label htmlFor="friend-link-url"></Label>
<Input
id="friend-link-url"
value={url}
onChange={e => setUrl(e.target.value)}
placeholder="请输入您的网站地址(以 http 开头)"
maxLength={512}
/>
</div>
<div className="friend-link-apply-field">
<Label></Label>
<div className="friend-link-apply-placement" role="radiogroup" aria-label="本站链接放置位置">
<button
type="button"
role="radio"
aria-checked={linkPlacement === 'homepage'}
className={cn(
'friend-link-apply-placement__option',
linkPlacement === 'homepage' && 'friend-link-apply-placement__option--active',
)}
onClick={() => setLinkPlacement('homepage')}
>
</button>
<button
type="button"
role="radio"
aria-checked={linkPlacement === 'custom'}
className={cn(
'friend-link-apply-placement__option',
linkPlacement === 'custom' && 'friend-link-apply-placement__option--active',
)}
onClick={() => setLinkPlacement('custom')}
>
</button>
</div>
</div>
{linkPlacement === 'custom' && (
<div className="friend-link-apply-field">
<Label htmlFor="friend-link-reciprocal"></Label>
<Input
id="friend-link-reciprocal"
value={reciprocalPageURL}
onChange={e => setReciprocalPageURL(e.target.value)}
placeholder="如https://您的域名/link.htm"
maxLength={512}
/>
<p className="friend-link-apply-field__hint">
</p>
</div>
)}
<div className="friend-link-apply-field">
<Label htmlFor="friend-link-logo"> LOGO</Label>
<div className="friend-link-apply-logo-row">
<div className="friend-link-apply-logo-preview" aria-label="LOGO 预览">
{logoPreview ? (
<img src={logoPreview} alt="" loading="lazy" decoding="async" />
) : (
<span></span>
)}
</div>
<Input
id="friend-link-logo"
className="friend-link-apply-logo-input"
value={logo}
onChange={e => setLogo(e.target.value)}
placeholder="LOGO 图片地址"
maxLength={512}
/>
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
className="sr-only"
onChange={e => uploadLogo(e.target.files?.[0])}
/>
<Button
type="button"
variant="outline"
className="friend-link-apply-logo-upload"
disabled={uploadingLogo}
onClick={() => fileRef.current?.click()}
>
{uploadingLogo ? <Spinner size="sm" /> : <Upload size={15} aria-hidden />}
</Button>
</div>
</div>
</div>
<DialogFooter className="friend-link-apply-dialog__footer">
<p className="friend-link-apply-dialog__note">
<Link2 size={14} aria-hidden />
</p>
<div className="friend-link-apply-dialog__actions">
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
</Button>
<Button type="button" disabled={submitting || uploadingLogo} onClick={submit}>
{submitting ? '提交中…' : isEdit ? '保存并提交' : '提交申请'}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,56 @@
import { useSiteBranding } from '../hooks/useSiteBranding';
function resolveSiteURL(siteURL?: string): string {
const fromApi = siteURL?.trim();
if (fromApi) return fromApi;
if (typeof window !== 'undefined') return window.location.origin;
return '';
}
function resolveSiteLogoURL(logo?: string, favicon?: string, siteURL?: string): string {
const raw = logo?.trim() || favicon?.trim() || '';
if (!raw) return '';
if (/^https?:\/\//i.test(raw)) return raw;
const base = resolveSiteURL(siteURL);
if (!base) return raw;
return raw.startsWith('/') ? `${base}${raw}` : `${base}/${raw}`;
}
/** 申请弹窗内:本站友链信息(名称 / 地址 / LOGO 链接) */
export default function FriendLinkSiteInfo() {
const { branding } = useSiteBranding();
const siteURL = resolveSiteURL(branding.site_url);
const siteLogoURL = resolveSiteLogoURL(branding.logo, branding.favicon, branding.site_url);
return (
<section className="friend-link-site-info" aria-label="本站友链信息">
<h3 className="friend-link-site-info__title"></h3>
<dl className="friend-link-site-info__list">
<div className="friend-link-site-info__item">
<dt></dt>
<dd>{branding.name}</dd>
</div>
<div className="friend-link-site-info__item">
<dt></dt>
<dd>
{siteURL ? (
<a href={siteURL} target="_blank" rel="noopener noreferrer">{siteURL}</a>
) : (
'—'
)}
</dd>
</div>
<div className="friend-link-site-info__item">
<dt>LOGO</dt>
<dd>
{siteLogoURL ? (
<a href={siteLogoURL} target="_blank" rel="noopener noreferrer">{siteLogoURL}</a>
) : (
'—'
)}
</dd>
</div>
</dl>
</section>
);
}

View File

@@ -0,0 +1,182 @@
import { useState } from 'react';
import { CheckCircle2, Coins } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { PostItem } from '../api/types';
interface Props {
post: PostItem;
isOwnerOrAdmin: boolean;
isAdmin?: boolean;
/** 是否可取消悬赏(楼主有他人回复时为 false管理员为 true */
canRefund?: boolean;
refundBlockReason?: string;
eligibleReplyCount?: number;
onUpdate?: () => void;
/** 跳转到被采纳的评论楼层 */
onJumpToAwarded?: () => void;
/** 被采纳评论是否仍在当前评论列表中 */
canJumpToAwarded?: boolean;
}
/** 悬赏帖状态卡 */
export default function PostBountyBanner({
post,
isOwnerOrAdmin,
isAdmin = false,
canRefund = true,
refundBlockReason,
eligibleReplyCount = 0,
onUpdate,
onJumpToAwarded,
canJumpToAwarded,
}: Props) {
const [refundOpen, setRefundOpen] = useState(false);
const [refunding, setRefunding] = useState(false);
if (post.post_type !== 'bounty') return null;
const points = post.bounty_points ?? 0;
const open = post.bounty_status === 'open' && points > 0;
const awarded = post.bounty_status === 'awarded';
const refunded = post.bounty_status === 'refunded';
if (refunded && !isOwnerOrAdmin) return null;
const showRefundButton = isOwnerOrAdmin && canRefund;
const ownerRefundBlocked = isOwnerOrAdmin && !isAdmin && !canRefund;
const confirmRefund = async () => {
setRefunding(true);
try {
await api.bountyRefund(post.id);
notify.success('悬赏已退回');
setRefundOpen(false);
onUpdate?.();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
} finally {
setRefunding(false);
}
};
const refundDialogDescription = isAdmin && eligibleReplyCount > 0
? `将强制取消悬赏并把 ${points} 积分退回楼主账户。当前已有 ${eligibleReplyCount} 条他人回复,请确认已审慎处理。`
: `确定取消悬赏并将 ${points} 积分退回你的账户?取消后他人将无法再参与本帖悬赏。`;
if (open) {
let ownerHint = '在评论上点击「采纳」发放积分';
if (ownerRefundBlocked) {
ownerHint = refundBlockReason || '已有用户回复,请从评论中采纳发放积分';
}
return (
<>
<section className="post-bounty post-bounty--open" aria-label="悬赏">
<div className="post-bounty__head">
<div className="post-bounty__lead">
<Coins size={18} className="post-bounty__icon" aria-hidden />
<div className="post-bounty__titles">
<strong className="post-bounty__title"></strong>
<span className="post-bounty__points">{points} </span>
</div>
</div>
{showRefundButton && (
<div className="post-bounty__actions">
<Button type="button" variant="outline" size="sm" onClick={() => setRefundOpen(true)}>
</Button>
</div>
)}
</div>
<p className="post-bounty__hint">
{isOwnerOrAdmin
? ownerHint
: `回复本帖,优质回答可获得这 ${points} 积分`}
</p>
</section>
{showRefundButton && (
<AlertDialog
open={refundOpen}
onOpenChange={(next) => { if (!next && !refunding) setRefundOpen(false); }}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{isAdmin && eligibleReplyCount > 0 ? '强制取消悬赏?' : '取消悬赏?'}</AlertDialogTitle>
<AlertDialogDescription>
{refundDialogDescription}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={refunding}></AlertDialogCancel>
<AlertDialogAction
disabled={refunding}
onClick={(e) => {
e.preventDefault();
void confirmRefund();
}}
>
{refunding ? '退回中…' : '确认取消'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</>
);
}
if (awarded) {
return (
<section className="post-bounty post-bounty--awarded" aria-label="悬赏已采纳">
<div className="post-bounty__head">
<div className="post-bounty__lead">
<CheckCircle2 size={18} className="post-bounty__icon" aria-hidden />
<div className="post-bounty__titles">
<strong className="post-bounty__title"></strong>
<span className="post-bounty__subtitle">
{points}
</span>
</div>
</div>
</div>
{canJumpToAwarded && onJumpToAwarded && (
<div className="post-bounty__actions post-bounty__actions--inline">
<button type="button" className="post-bounty__jump" onClick={onJumpToAwarded}>
</button>
</div>
)}
</section>
);
}
if (refunded) {
return (
<section className="post-bounty post-bounty--refunded" aria-label="悬赏已退回">
<div className="post-bounty__head">
<div className="post-bounty__lead">
<Coins size={18} className="post-bounty__icon" aria-hidden />
<div className="post-bounty__titles">
<strong className="post-bounty__title"></strong>
<span className="post-bounty__subtitle">退</span>
</div>
</div>
</div>
</section>
);
}
return null;
}

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>
);
}

View File

@@ -1,37 +1,73 @@
import { Skeleton } from '@/components/ui/skeleton';
import type { ForumLimitsPublic } from '../api/types';
export type FeedListStyle = ForumLimitsPublic['feed_list_style'];
/** 虚拟列表行高预估值 */
export function feedListRowEstimate(style: FeedListStyle): number {
switch (style) {
case 'excerpt': return 68;
case 'thumbnail': return 72;
default: return 52;
}
}
interface Props {
count?: number;
listStyle?: FeedListStyle;
}
/** 帖子列表加载骨架屏(对齐卡片式列表) */
export default function PostListSkeleton({ count = 8 }: Props) {
/** 帖子列表加载骨架屏(对齐 v2 紧凑列表) */
export default function PostListSkeleton({ count = 8, listStyle = 'title' }: Props) {
const showExcerpt = listStyle === 'excerpt' || listStyle === 'thumbnail';
const showThumb = listStyle === 'thumbnail';
return (
<div className="post-list-skeleton" aria-busy="true" aria-label="加载中">
{Array.from({ length: count }, (_, i) => (
<div key={i} className="post-row post-row--skeleton">
<Skeleton className="skeleton--avatar" />
<div className="post-body">
<div className="post-head">
<div className="skeleton-meta-row">
<Skeleton className="skeleton--meta" />
<Skeleton className="skeleton--meta skeleton--meta-short" />
{Array.from({ length: count }, (_, i) => {
const hasThumb = showThumb && i % 3 === 0;
return (
<div key={i} className={`post-row post-row--v2 post-row--skeleton${hasThumb ? ' post-row--has-thumb' : ''}`}>
<Skeleton className="skeleton--avatar skeleton--avatar-v2" />
{hasThumb ? (
<div className="post-main post-main--with-thumb">
<div className="post-content">
<Skeleton className="skeleton--title skeleton--title-v2" style={{ width: `${58 + (i % 4) * 9}%` }} />
{showExcerpt && (
<Skeleton className="skeleton--excerpt skeleton--excerpt-v2" style={{ width: `${72 + (i % 3) * 8}%` }} />
)}
<Skeleton className="skeleton--meta-line" style={{ width: `${45 + (i % 3) * 10}%` }} />
</div>
<div className="post-aside">
<Skeleton className="skeleton--thumb skeleton--thumb-tall" />
<div className="post-stats">
<Skeleton className="skeleton--stat" />
<Skeleton className="skeleton--stat" />
<Skeleton className="skeleton--stat" />
</div>
</div>
</div>
{i % 4 === 0 && <Skeleton className="skeleton--badge" />}
</div>
<Skeleton className="skeleton--title" style={{ width: `${58 + (i % 4) * 9}%` }} />
<Skeleton className="skeleton--excerpt" style={{ width: `${72 + (i % 3) * 8}%` }} />
<div className="post-foot">
<Skeleton className="skeleton--badge" />
<div className="post-stats">
<Skeleton className="skeleton--stat" />
<Skeleton className="skeleton--stat" />
<Skeleton className="skeleton--stat" />
) : (
<div className="post-main">
<div className="post-text">
<Skeleton className="skeleton--title skeleton--title-v2" style={{ width: `${58 + (i % 4) * 9}%` }} />
{showExcerpt && (
<Skeleton className="skeleton--excerpt skeleton--excerpt-v2" style={{ width: `${72 + (i % 3) * 8}%` }} />
)}
</div>
<div className="post-meta">
<Skeleton className="skeleton--meta-line" style={{ width: `${45 + (i % 3) * 10}%` }} />
<div className="post-stats">
<Skeleton className="skeleton--stat" />
<Skeleton className="skeleton--stat" />
<Skeleton className="skeleton--stat" />
</div>
</div>
</div>
</div>
)}
</div>
</div>
))}
);
})}
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { useState } from 'react';
import { Gift } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { PostLotteryView } from '../api/types';
import { userPath } from '../utils/userPath';
interface Props {
postId: number;
lottery: PostLotteryView;
isOwnerOrAdmin: boolean;
onUpdate: (lottery: PostLotteryView) => void;
}
/** 抽奖帖信息卡 */
export default function PostLotteryCard({ postId, lottery, isOwnerOrAdmin, onUpdate }: Props) {
const [drawOpen, setDrawOpen] = useState(false);
const [drawing, setDrawing] = useState(false);
const drawn = lottery.status === 'drawn';
const canDraw = !drawn && isOwnerOrAdmin && lottery.participant_count >= lottery.winner_count;
const confirmDraw = async () => {
setDrawing(true);
try {
const r = await api.postLotteryDraw(postId);
onUpdate(r.lottery);
notify.success('开奖完成');
setDrawOpen(false);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '开奖失败');
} finally {
setDrawing(false);
}
};
return (
<>
<section className="post-lottery" aria-label="抽奖">
<div className="post-lottery__head">
<Gift size={18} aria-hidden />
<strong>{drawn ? '已开奖' : '抽奖进行中'}</strong>
<span> {lottery.winner_count} · {lottery.participant_count} </span>
</div>
{!drawn && (
<p className="post-lottery__hint"></p>
)}
{drawn && lottery.winners && lottery.winners.length > 0 && (
<ul className="post-lottery__winners">
{lottery.winners.map(w => (
<li key={`${w.user_id}-${w.comment_id}`}>
<a href={userPath(w.user_id)}>{w.nickname || w.username}</a>
</li>
))}
</ul>
)}
{canDraw && (
<Button type="button" size="sm" onClick={() => setDrawOpen(true)}></Button>
)}
</section>
<AlertDialog
open={drawOpen}
onOpenChange={(next) => { if (!next && !drawing) setDrawOpen(false); }}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{lottery.participant_count} {lottery.winner_count}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={drawing}></AlertDialogCancel>
<AlertDialogAction
disabled={drawing}
onClick={(e) => {
e.preventDefault();
void confirmDraw();
}}
>
{drawing ? '开奖中…' : '确认开奖'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,175 @@
import { useState } from 'react';
import { BarChart3 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { PollView } from '../api/types';
import { useAuth } from '../hooks/useAuth';
import { formatDateTime } from '../utils/content';
interface Props {
postId: number;
poll: PollView;
isOwnerOrAdmin: boolean;
onUpdate: (poll: PollView) => void;
}
/** 投票帖投票卡 */
export default function PostPollCard({ postId, poll, isOwnerOrAdmin, onUpdate }: Props) {
const { user } = useAuth();
const [selected, setSelected] = useState<number[]>(poll.my_option_ids ?? []);
const [busy, setBusy] = useState(false);
const voted = (poll.my_option_ids?.length ?? 0) > 0;
const showResults = poll.closed || voted;
const canVote = !voted && !poll.closed;
const headTitle = poll.closed
? '投票已结束'
: poll.multi
? `多选(最多 ${poll.max_choices} 项)`
: '单选投票';
const endsAtMs = poll.ends_at ? new Date(poll.ends_at).getTime() : NaN;
const expiredByDeadline = poll.closed && poll.ends_at && !Number.isNaN(endsAtMs) && endsAtMs <= Date.now();
const deadlineHint = poll.ends_at && !Number.isNaN(endsAtMs)
? poll.closed
? (expiredByDeadline ? `已于 ${formatDateTime(poll.ends_at)} 截止` : undefined)
: `截止于 ${formatDateTime(poll.ends_at)}`
: undefined;
const toggle = (id: number) => {
if (!canVote) return;
if (!user) {
notify.warning('请先登录');
return;
}
if (poll.multi) {
setSelected(prev => (
prev.includes(id)
? prev.filter(x => x !== id)
: [...prev, id].slice(0, poll.max_choices)
));
} else {
setSelected([id]);
}
};
const submit = async () => {
if (!user) {
notify.warning('请先登录');
return;
}
if (selected.length === 0) {
notify.warning('请选择选项');
return;
}
setBusy(true);
try {
const r = await api.pollVote(postId, selected);
onUpdate(r.poll);
notify.success('投票成功');
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '投票失败');
} finally {
setBusy(false);
}
};
const closePoll = async () => {
setBusy(true);
try {
const r = await api.pollClose(postId);
onUpdate(r.poll);
notify.success('投票已结束');
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
} finally {
setBusy(false);
}
};
return (
<section className="post-poll" aria-label="投票">
<div className="post-poll__head">
<BarChart3 size={18} aria-hidden />
<strong>{headTitle}</strong>
{deadlineHint && (
<span className="post-poll__deadline">{deadlineHint}</span>
)}
<span className="post-poll__meta">{poll.total_votes} </span>
</div>
<ul
className="post-poll__options"
role={poll.multi ? 'group' : 'radiogroup'}
aria-label="投票选项"
>
{poll.options.map(opt => {
const isActive = selected.includes(opt.id);
const isMine = poll.my_option_ids?.includes(opt.id);
return (
<li key={opt.id}>
<button
type="button"
className={[
'post-poll__option',
poll.multi ? 'post-poll__option--multi' : 'post-poll__option--single',
isActive ? 'active' : '',
showResults ? 'results' : '',
isMine ? 'mine' : '',
].filter(Boolean).join(' ')}
disabled={!canVote}
onClick={() => toggle(opt.id)}
role={poll.multi ? 'checkbox' : 'radio'}
aria-checked={isActive}
>
<span className="post-poll__option-main">
<span className="post-poll__option-indicator" aria-hidden />
<span className="post-poll__option-text">{opt.text}</span>
{showResults && (
<span className="post-poll__option-stat">
{opt.percent ?? 0}% · {opt.vote_count}
</span>
)}
</span>
{showResults && (
<span className="post-poll__option-bar" aria-hidden>
<span
className="post-poll__option-fill"
style={{ width: `${opt.percent ?? 0}%` }}
/>
</span>
)}
</button>
</li>
);
})}
</ul>
<div className="post-poll__actions">
{canVote && user && (
<Button
type="button"
size="sm"
disabled={busy || selected.length === 0}
onClick={submit}
>
</Button>
)}
{canVote && user && selected.length === 0 && (
<p className="post-poll__select-hint">
{poll.multi ? '请选择后提交' : '请选择一项后提交'}
</p>
)}
{!user && canVote && (
<p className="post-poll__login-hint"></p>
)}
{isOwnerOrAdmin && !poll.closed && (
<Button type="button" variant="outline" size="sm" disabled={busy} onClick={closePoll}>
</Button>
)}
</div>
</section>
);
}

View File

@@ -1,14 +1,18 @@
import { ListTree, MessageCircle, MessagesSquare, Tags, Sparkles } from 'lucide-react';
import { useLocation, useSearchParams } from 'react-router-dom';
import { useMemo } from 'react';
import { ListTree, MessageCircle, Tags, Link2 } from 'lucide-react';
import { useLocation, useSearchParams, useNavigate } from 'react-router-dom';
import { Skeleton } from '@/components/ui/skeleton';
import type { PostItem, RecentComment, TagCount, User } from '../api/types';
import { Button } from '@/components/ui/button';
import type { AsideWidget, RecentComment, TagCount, User, ForumStats, FriendLink } from '../api/types';
import type { PostHeading } from '../utils/postHeadings';
import { useSiteBranding } from '../hooks/useSiteBranding';
import { formatShortDateTime, formatTime } from '../utils/content';
import { resolveAsideWidgets } from '../utils/asideWidgets';
import TagCloud from './TagCloud';
import UserLink from './UserLink';
import ArticleOutline from './ArticleOutline';
import PostAuthorCard from './PostAuthorCard';
import AsideCheckInStrip from './AsideCheckInStrip';
export type PostDetailAside = {
author?: User | null;
@@ -20,38 +24,29 @@ export type PostDetailAside = {
};
interface Props {
hot: PostItem[];
recentComments: RecentComment[];
tags?: TagCount[];
tagsLoading?: boolean;
stats?: ForumStats | null;
onPostClick: (id: number, opts?: { floor?: number }) => void;
/** 首次拉取中,显示骨架避免空态闪烁 */
loading?: boolean;
/** 右侧栏可选组件顺序与开关 */
asideWidgets: AsideWidget[];
/** 帖子详情:右侧顶部展示作者与目录 */
postDetail?: PostDetailAside | null;
}
function ActiveSkeleton() {
return (
<div className="widget-skeleton" aria-busy="true" aria-label="正在聊加载中">
{Array.from({ length: 6 }, (_, i) => (
<div key={i} className="widget-item widget-item--active widget-item--skeleton">
<Skeleton className="skeleton--widget-title" style={{ width: `${62 + (i % 4) * 8}%` }} />
<Skeleton className="skeleton--widget-time" />
</div>
))}
</div>
);
}
function CommentSkeleton() {
return (
<div className="widget-skeleton" aria-busy="true" aria-label="评论加载中">
{Array.from({ length: 5 }, (_, i) => (
<div key={i} className="widget-item widget-item--comment widget-item--skeleton">
<Skeleton className="skeleton--widget-avatar" />
<Skeleton className="skeleton--widget-title" style={{ width: `${55 + (i % 3) * 12}%` }} />
<Skeleton className="skeleton--widget-time" />
<div className="widget-item-comment-main">
<Skeleton className="skeleton--widget-title" style={{ width: `${72 + (i % 3) * 8}%` }} />
<Skeleton className="skeleton--widget-meta" />
</div>
</div>
))}
</div>
@@ -59,21 +54,24 @@ function CommentSkeleton() {
}
export default function RightPanel({
hot,
recentComments,
tags = [],
tagsLoading = false,
stats = null,
onPostClick,
loading = false,
asideWidgets,
postDetail = null,
}: Props) {
const { branding } = useSiteBranding();
const nav = useNavigate();
const loc = useLocation();
const [params] = useSearchParams();
const activeTag = params.get('tag') || '';
const hotList = hot?.slice(0, 8) ?? [];
const commentList = recentComments?.slice(0, 6) ?? [];
// 站点首页:右侧品牌块承担唯一 h1板块/搜索等页面由 Feed 标题作 h1
const friendLinks = (branding.friend_links ?? []).filter(
(l: FriendLink) => l.name?.trim() && l.url?.trim(),
);
const isSiteHome = loc.pathname === '/'
&& !params.get('board')
&& !params.get('keyword')
@@ -81,13 +79,144 @@ export default function RightPanel({
&& !params.get('author');
const description = branding.description?.trim() || '';
const slogan = branding.slogan?.trim() || '';
// 有独立简介时展示简介;否则用欢迎语,避免与页脚 slogan 三连重复
const aboutText = description || '欢迎参与讨论,发帖、评论,一起把小圈子聊热。';
// 有近期讨论则展示「正在聊」;否则显示欢迎引导
const showActive = loading || hotList.length > 0;
const showWelcome = !loading && hotList.length === 0;
const introText = description || slogan;
const isPostDetail = !!postDetail;
const enabledWidgets = useMemo(
() => resolveAsideWidgets({
aside_widgets: asideWidgets,
aside_show_tag_cloud: false,
aside_show_recent_comments: false,
aside_show_friend_links: false,
}).filter(w => w.enabled),
[asideWidgets],
);
const handleApplyClick = () => {
nav('/links?apply=1');
};
const renderWidget = (widget: AsideWidget) => {
switch (widget.id) {
case 'friend_links':
return (
<div key="friend_links" className="widget-card widget-card--friend-links">
<div className="widget-card-head widget-card-head--split">
<span className="widget-card-head-main">
<Link2 className="widget-card-icon widget-card-icon--links" aria-hidden />
<button type="button" className="widget-friend-links-title" onClick={() => nav('/links')}>
</button>
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="widget-friend-links-apply"
onClick={handleApplyClick}
>
</Button>
</div>
<div className="widget-card-body">
{friendLinks.length === 0 ? (
<div className="widget-empty"></div>
) : (
<>
<ul className="widget-friend-links-list">
{friendLinks.slice(0, 8).map((link: FriendLink) => (
<li key={`${link.name}-${link.url}`}>
<a href={link.url} target="_blank" rel="noopener noreferrer">{link.name}</a>
</li>
))}
</ul>
{friendLinks.length > 8 && (
<button type="button" className="widget-friend-links-more" onClick={() => nav('/links')}>
{friendLinks.length}
</button>
)}
</>
)}
</div>
</div>
);
case 'tag_cloud':
return (
<div key="tag_cloud" className="widget-card widget-card--tags">
<div className="widget-card-head">
<Tags className="widget-card-icon widget-card-icon--tags" aria-hidden />
</div>
<div className="widget-card-body widget-card-body--tags">
<TagCloud tags={tags} loading={tagsLoading} activeTag={activeTag} />
</div>
</div>
);
case 'recent_comments':
return (
<div key="recent_comments" className="widget-card">
<div className="widget-card-head">
<MessageCircle className="widget-card-icon widget-card-icon--notice" aria-hidden />
</div>
<div className="widget-card-body">
{loading && commentList.length === 0 ? (
<CommentSkeleton />
) : commentList.length === 0 ? (
<div className="widget-empty"></div>
) : commentList.map(item => (
<div
key={item.id}
className="widget-item widget-item--comment"
title={item.post_title ? `${item.author} · ${item.post_title}` : item.author}
>
{item.user_id ? (
<UserLink
user={{ id: item.user_id, nickname: item.author, avatar: item.avatar }}
showAvatar={false}
showName={false}
stopPropagation
className="widget-item-avatar user-link--avatar-only"
>
{item.avatar
? <img src={item.avatar} alt="" loading="lazy" decoding="async" />
: (item.author?.[0] || '?')}
</UserLink>
) : (
<span className="widget-item-avatar" aria-hidden>
{item.avatar
? <img src={item.avatar} alt="" loading="lazy" decoding="async" />
: (item.author?.[0] || '?')}
</span>
)}
<button
type="button"
className="widget-item-comment-main"
onClick={() => onPostClick(item.post_id, item.floor > 0 ? { floor: item.floor } : undefined)}
>
<span className="widget-item-title">{item.excerpt}</span>
<span className="widget-item-meta">
<span
className="widget-item-time"
title={formatShortDateTime(item.created_at)}
>
{formatTime(item.created_at)}
</span>
{item.post_title && (
<span className="widget-item-post-title">{item.post_title}</span>
)}
</span>
</button>
</div>
))}
</div>
</div>
);
default:
return null;
}
};
return (
<div className={`aside-panel-inner${isPostDetail ? ' aside-panel-inner--post-detail' : ''}`}>
{isPostDetail && (
@@ -114,121 +243,6 @@ export default function RightPanel({
</>
)}
{!isPostDetail && showWelcome && (
<div className="widget-card widget-card--welcome">
<div className="widget-card-head">
<Sparkles className="widget-card-icon widget-card-icon--welcome" aria-hidden />
</div>
<div className="widget-card-body widget-welcome-body">
<p></p>
<ul>
<li></li>
<li></li>
<li></li>
</ul>
</div>
</div>
)}
{!isPostDetail && showActive && (
<div className="widget-card">
<div className="widget-card-head">
<MessagesSquare className="widget-card-icon widget-card-icon--hot" aria-hidden />
</div>
<div className="widget-card-body">
{loading && hotList.length === 0 ? (
<ActiveSkeleton />
) : hotList.length === 0 ? (
<div className="widget-empty"> 7 </div>
) : hotList.map((item) => {
const replyLabel = item.last_reply_at
? `${formatTime(item.last_reply_at)}有人回`
: '近期有讨论';
const count = item.comment_count ?? 0;
return (
<button
key={item.id}
type="button"
className="widget-item widget-item--active"
onClick={() => onPostClick(item.id)}
title={item.title}
>
<span className="widget-item-title">{item.title}</span>
<span className="widget-item-meta">
<span className="widget-item-time">{replyLabel}</span>
{count > 0 && <span className="widget-item-count">{count} </span>}
</span>
</button>
);
})}
</div>
</div>
)}
{!isPostDetail && (
<div className="widget-card widget-card--tags">
<div className="widget-card-head">
<Tags className="widget-card-icon widget-card-icon--tags" aria-hidden />
</div>
<div className="widget-card-body widget-card-body--tags">
<TagCloud tags={tags} loading={tagsLoading} activeTag={activeTag} />
</div>
</div>
)}
{!isPostDetail && (
<div className="widget-card">
<div className="widget-card-head">
<MessageCircle className="widget-card-icon widget-card-icon--notice" aria-hidden />
</div>
<div className="widget-card-body">
{loading && commentList.length === 0 ? (
<CommentSkeleton />
) : commentList.length === 0 ? (
<div className="widget-empty"></div>
) : commentList.map(item => (
<div
key={item.id}
className="widget-item widget-item--comment"
title={item.post_title ? `${item.author} · ${item.post_title}` : item.author}
>
{item.user_id ? (
<UserLink
user={{ id: item.user_id, nickname: item.author, avatar: item.avatar }}
showAvatar={false}
showName={false}
stopPropagation
className="widget-item-avatar user-link--avatar-only"
>
{item.avatar
? <img src={item.avatar} alt="" loading="lazy" decoding="async" />
: (item.author?.[0] || '?')}
</UserLink>
) : (
<span className="widget-item-avatar" aria-hidden>
{item.avatar
? <img src={item.avatar} alt="" loading="lazy" decoding="async" />
: (item.author?.[0] || '?')}
</span>
)}
<button
type="button"
className="widget-item-comment-main"
onClick={() => onPostClick(item.post_id, item.floor > 0 ? { floor: item.floor } : undefined)}
>
<span className="widget-item-title">{item.excerpt}</span>
<span className="widget-item-time">{formatShortDateTime(item.created_at)}</span>
</button>
</div>
))}
</div>
</div>
)}
{!isPostDetail && (
<div className="widget-card widget-card--about">
<div className="widget-card-body">
@@ -238,14 +252,32 @@ export default function RightPanel({
) : (
<p className="widget-about-title">{branding.name}</p>
)}
<p className="widget-about-desc">{aboutText}</p>
{description && slogan && slogan !== description && (
<p className="widget-about-slogan">{slogan}</p>
{introText && (
<p className="widget-about-desc">{introText}</p>
)}
</div>
{stats && (
<div className="widget-stats" aria-label="论坛统计">
<div className="widget-stat">
<span className="widget-stat-value">{stats.posts}</span>
<span className="widget-stat-label"></span>
</div>
<div className="widget-stat">
<span className="widget-stat-value">{stats.comments}</span>
<span className="widget-stat-label"></span>
</div>
<div className="widget-stat">
<span className="widget-stat-value">{stats.users}</span>
<span className="widget-stat-label"></span>
</div>
</div>
)}
<AsideCheckInStrip />
</div>
</div>
)}
{!isPostDetail && enabledWidgets.map(renderWidget)}
</div>
);
}

View File

@@ -1,7 +1,7 @@
import {
Home, Star, LayoutDashboard, FolderGit2, FolderKanban, ArrowLeft,
Home, Star, LayoutDashboard, FolderGit2, FolderKanban, ArrowLeft, FileText, Link2,
} from 'lucide-react';
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import { useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom';
import type { Board } from '../api/types';
import type { PostHeading } from '../utils/postHeadings';
import { useAuth } from '../hooks/useAuth';
@@ -12,9 +12,12 @@ import { navigateFeed } from '../utils/feedCache';
import BoardIconDisplay from './BoardIconDisplay';
import { getBoardThemeIndex } from '../utils/boardTheme';
import ArticleOutline from './ArticleOutline';
import { useSitePages } from '../hooks/useSitePages';
import { pagePath } from '../utils/permalink';
import { useForumLimits } from '../hooks/useForumLimits';
// 内容页不参与左侧栏高亮(非 feed 浏览上下文)
const NEUTRAL_SIDEBAR_PREFIXES = ['/post/', '/profile', '/user/'];
const NEUTRAL_SIDEBAR_PREFIXES = ['/post/', '/profile', '/user/', '/page/'];
export function isNeutralSidebarRoute(pathname: string): boolean {
return NEUTRAL_SIDEBAR_PREFIXES.some(prefix => pathname.startsWith(prefix));
@@ -26,6 +29,8 @@ function resolveMenuKey(pathname: string, activeBoard: number, keyword = ''): st
if (keyword.trim()) return null;
if (pathname.startsWith('/favorites')) return 'favorites';
if (pathname.startsWith('/projects')) return 'projects';
if (pathname.startsWith('/links')) return 'links';
if (pathname.startsWith('/page/')) return 'pages';
if (pathname.startsWith('/admin')) return 'admin';
return activeBoard === 0 ? 'all' : String(activeBoard);
}
@@ -59,9 +64,37 @@ export default function Sidebar({
const sort = parseFeedSort(params.get('sort'));
const { user } = useAuth();
const isAdmin = user?.role === 'admin';
const { navPages } = useSitePages();
const { limits } = useForumLimits();
const keyword = params.get('keyword') || '';
const menuKey = resolveMenuKey(loc.pathname, activeBoard, keyword);
const permalinkOpts = { permalink: limits };
const feedNavLink = (
key: string,
to: string,
label: React.ReactNode,
icon: React.ReactNode,
selectId: number,
className?: string,
trailing?: React.ReactNode,
) => (
<Link
key={key}
to={to}
className={cn('sidebar-nav-item', className, menuKey != null && menuKey === key && 'active')}
onClick={(e) => {
e.preventDefault();
onSelectBoard(selectId);
navigateFeed(nav, to);
}}
>
{icon}
<span className="flex-1 truncate">{label}</span>
{trailing}
</Link>
);
const navItem = (key: string, label: React.ReactNode, icon?: React.ReactNode, onClick?: () => void) => (
<button
@@ -99,9 +132,10 @@ export default function Sidebar({
<aside className="sidebar">
<div className="sidebar-section"></div>
<nav className="sidebar-nav">
{navItem('all', '全部帖子', <Home aria-hidden />, () => { onSelectBoard(0); navigateFeed(nav, buildHomeUrl(0, sort)); })}
{feedNavLink('all', buildHomeUrl(0, sort, permalinkOpts), '全部帖子', <Home aria-hidden />, 0)}
{user && navItem('favorites', '我的收藏', <Star aria-hidden />, () => nav('/favorites'))}
{navItem('projects', '开源码桶', <FolderGit2 aria-hidden />, () => nav('/projects'))}
{navItem('links', '友情链接', <Link2 aria-hidden />, () => nav('/links'))}
</nav>
{(boardsLoading && boards.length === 0) ? (
@@ -123,29 +157,27 @@ export default function Sidebar({
{boards.map(b => {
const isActive = menuKey != null && menuKey === String(b.id);
const themeIdx = getBoardThemeIndex(b);
return (
<button
type="button"
key={b.id}
className={cn(
'sidebar-nav-item',
'sidebar-nav-item--board',
isActive && 'active',
isActive && `sidebar-nav-item--board-${themeIdx}`,
)}
onClick={() => { onSelectBoard(b.id); navigateFeed(nav, buildHomeUrl(b.id, sort)); }}
>
<BoardIconDisplay
board={b}
className={cn('sidebar-board-icon', `sidebar-board-icon--${themeIdx}`)}
/>
<span className="flex-1 truncate">{b.name}</span>
{(b.post_count ?? 0) > 0 && (
<span className="sidebar-nav-item__meta" title={`${b.post_count} 篇帖子`}>
{b.post_count}
</span>
)}
</button>
const boardUrl = buildHomeUrl(b.id, sort, permalinkOpts);
const postMeta = (b.post_count ?? 0) > 0 ? (
<span className="sidebar-nav-item__meta" title={`${b.post_count} 篇帖子`}>
{b.post_count}
</span>
) : null;
return feedNavLink(
String(b.id),
boardUrl,
b.name,
<BoardIconDisplay
board={b}
className={cn('sidebar-board-icon', `sidebar-board-icon--${themeIdx}`)}
/>,
b.id,
cn(
'sidebar-nav-item--board',
isActive && 'active',
isActive && `sidebar-nav-item--board-${themeIdx}`,
),
postMeta,
);
})}
</nav>
@@ -159,6 +191,17 @@ export default function Sidebar({
</>
) : null}
{(navPages.length > 0) && (
<>
<div className="sidebar-section sidebar-section--spaced"></div>
<nav className="sidebar-nav">
{navPages.map(p => (
navItem(`page-${p.slug}`, p.title, <FileText aria-hidden />, () => nav(pagePath(p.slug, limits)))
))}
</nav>
</>
)}
{isAdmin && (
<>
<div className="sidebar-section sidebar-section--spaced"></div>

View File

@@ -1,16 +1,20 @@
import { useSiteBranding } from '../hooks/useSiteBranding';
import { useMediaQuery } from '../hooks/useTheme';
import type { FriendLink } from '../api/types';
import { Link } from 'react-router-dom';
import { useSitePages } from '../hooks/useSitePages';
import { pagePath } from '../utils/permalink';
import { useForumLimits } from '../hooks/useForumLimits';
function FooterSep() {
return <span className="site-footer__sep" aria-hidden>·</span>;
}
/** 站点页脚版权、Sitemap、友链、备案号 */
/** 站点页脚版权、Sitemap、备案号 */
export default function SiteFooter() {
const { branding } = useSiteBranding();
const { footerPages } = useSitePages();
const { limits } = useForumLimits();
const year = new Date().getFullYear();
const links = Array.isArray(branding.friend_links) ? branding.friend_links : [];
const icp = branding.icp_beian?.trim() || '';
const icpURL = branding.icp_beian_url?.trim() || 'https://beian.miit.gov.cn/';
@@ -29,31 +33,30 @@ export default function SiteFooter() {
)}
</div>
{(links.length > 0 || icp) && (
<nav className="site-footer__nav" aria-label="站点链接">
{links.map((link: FriendLink, i) => (
<span key={`${link.name}-${link.url}`} className="site-footer__friend">
{i > 0 && <FooterSep />}
<a href={link.url} target="_blank" rel="noopener noreferrer">
{link.name}
</a>
</span>
))}
{icp && (
<>
{links.length > 0 && <FooterSep />}
<a
href={icpURL}
target="_blank"
rel="noopener noreferrer"
className="site-footer__icp"
>
{icp}
</a>
</>
)}
</nav>
)}
<nav className="site-footer__nav" aria-label="站点链接">
<span className="site-footer__friend">
<Link to="/links"></Link>
</span>
{footerPages.map(p => (
<span key={p.slug} className="site-footer__friend">
<FooterSep />
<Link to={pagePath(p.slug, limits)}>{p.title}</Link>
</span>
))}
{icp && (
<>
<FooterSep />
<a
href={icpURL}
target="_blank"
rel="noopener noreferrer"
className="site-footer__icp"
>
{icp}
</a>
</>
)}
</nav>
</div>
</footer>
);

View File

@@ -4,10 +4,11 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import { Inbox, SearchX } from 'lucide-react';
import { Button } from '@/components/ui/button';
import PostListItem from './PostListItem';
import PostListSkeleton from './PostListSkeleton';
import PostListSkeleton, { feedListRowEstimate } from './PostListSkeleton';
import FeedPagination from './FeedPagination';
import { InFlowSiteFooter } from './SiteFooter';
import { useAuth } from '../hooks/useAuth';
import { useForumLimits } from '../hooks/useForumLimits';
import { useMediaQuery } from '../hooks/useTheme';
import { loginPath } from '../utils/authRedirect';
import type { PostItem } from '../api/types';
@@ -67,7 +68,10 @@ export default function VirtualPostList({
}: Props) {
const nav = useNavigate();
const { user } = useAuth();
const { limits } = useForumLimits();
const isMobile = useMediaQuery('(max-width: 768px)');
const feedStyle = limits.feed_list_style ?? 'title';
const rowEstimate = feedListRowEstimate(feedStyle);
const parentRef = useRef<HTMLDivElement>(null);
const restoredRef = useRef(false);
const onScrollTopChangeRef = useRef(onScrollTopChange);
@@ -116,7 +120,7 @@ export default function VirtualPostList({
const virtualizer = useVirtualizer({
count: posts.length,
getScrollElement,
estimateSize: () => 108,
estimateSize: () => rowEstimate,
overscan: 8,
scrollMargin: isMobile ? scrollMargin : 0,
measureElement:
@@ -214,7 +218,7 @@ export default function VirtualPostList({
return (
<div className="post-list-scroll" ref={parentRef}>
{isInitialLoad ? (
<PostListSkeleton />
<PostListSkeleton listStyle={feedStyle} />
) : isEmpty ? (
<div className="empty-feed" role="status">
{isSearchEmpty

View File

@@ -0,0 +1,215 @@
import {
type CSSProperties,
type ElementType,
type HTMLAttributes,
type ReactNode,
} from 'react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
rectSortingStrategy,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
type SortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { ArrowDown, ArrowUp, GripVertical } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { reorderItems, shouldShowSortableMoveButtons } from '../../utils/sortOrder';
export type SortableItemControls = {
setNodeRef: (node: HTMLElement | null) => void;
style: CSSProperties;
isDragging: boolean;
dragHandleProps: HTMLAttributes<HTMLButtonElement>;
moveUp: () => void;
moveDown: () => void;
canMoveUp: boolean;
canMoveDown: boolean;
};
type AdminSortableListProps<T> = {
items: T[];
getId: (item: T) => string | number;
onReorder: (items: T[]) => void;
renderItem: (item: T, index: number, controls: SortableItemControls) => ReactNode;
showMoveButtons?: boolean | 'auto';
strategy?: 'vertical' | 'grid';
as?: ElementType;
className?: string;
ariaLabel?: string;
};
function resolveStrategy(mode: 'vertical' | 'grid'): SortingStrategy {
return mode === 'grid' ? rectSortingStrategy : verticalListSortingStrategy;
}
function SortableItem<T>({
item,
index,
items,
getId,
onReorder,
showMoveButtons,
renderItem,
}: {
item: T;
index: number;
items: T[];
getId: (item: T) => string | number;
onReorder: (items: T[]) => void;
showMoveButtons: boolean;
renderItem: AdminSortableListProps<T>['renderItem'];
}) {
const id = getId(item);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
};
const moveUp = () => {
if (index > 0) onReorder(reorderItems(items, index, index - 1));
};
const moveDown = () => {
if (index < items.length - 1) onReorder(reorderItems(items, index, index + 1));
};
const controls: SortableItemControls = {
setNodeRef,
style,
isDragging,
dragHandleProps: { ...attributes, ...listeners },
moveUp,
moveDown,
canMoveUp: index > 0,
canMoveDown: index < items.length - 1,
};
return (
<>
{renderItem(item, index, controls)}
{showMoveButtons && (
<span className="sr-only" aria-live="polite">
{controls.canMoveUp ? '可上移' : ''}{controls.canMoveDown ? '可下移' : ''}
</span>
)}
</>
);
}
export function SortableDragHandle({
label = '拖拽调整顺序',
className = 'admin-sortable-row__handle',
...props
}: HTMLAttributes<HTMLButtonElement> & { label?: string }) {
return (
<button
type="button"
className={className}
aria-label={label}
{...props}
>
<GripVertical size={16} aria-hidden />
</button>
);
}
export function SortableMoveButtons({
controls,
className = 'admin-sortable-row__order',
}: {
controls: Pick<SortableItemControls, 'moveUp' | 'moveDown' | 'canMoveUp' | 'canMoveDown'>;
className?: string;
}) {
return (
<div className={className}>
<Button
type="button"
variant="ghost"
size="icon"
disabled={!controls.canMoveUp}
onClick={controls.moveUp}
aria-label="上移"
>
<ArrowUp size={14} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
disabled={!controls.canMoveDown}
onClick={controls.moveDown}
aria-label="下移"
>
<ArrowDown size={14} />
</Button>
</div>
);
}
export default function AdminSortableList<T>({
items,
getId,
onReorder,
renderItem,
showMoveButtons = 'auto',
strategy = 'vertical',
as: Wrapper = 'div',
className,
ariaLabel,
}: AdminSortableListProps<T>) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const moveButtons = shouldShowSortableMoveButtons(items.length, showMoveButtons);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = items.findIndex(item => getId(item) === active.id);
const newIndex = items.findIndex(item => getId(item) === over.id);
if (oldIndex < 0 || newIndex < 0) return;
onReorder(reorderItems(items, oldIndex, newIndex));
};
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={items.map(getId)} strategy={resolveStrategy(strategy)}>
<Wrapper className={className} role={ariaLabel ? 'group' : undefined} aria-label={ariaLabel}>
{items.map((item, index) => (
<SortableItem
key={String(getId(item))}
item={item}
index={index}
items={items}
getId={getId}
onReorder={onReorder}
showMoveButtons={moveButtons}
renderItem={renderItem}
/>
))}
</Wrapper>
</SortableContext>
</DndContext>
);
}

View File

@@ -0,0 +1,71 @@
import AdminSortableList, { SortableDragHandle } from './AdminSortableList';
import type { AsideWidget, AsideWidgetId } from '../../api/types';
const WIDGET_META: Record<AsideWidgetId, { label: string; hint: string }> = {
tag_cloud: {
label: '标签云',
hint: '在右侧栏展示热门标签',
},
recent_comments: {
label: '最新评论',
hint: '在右侧栏展示最近回复',
},
friend_links: {
label: '友情链接',
hint: '关闭后不在右侧栏展示,友链仍可在「友情链接」页面查看与申请',
},
};
type Props = {
widgets: AsideWidget[];
onChange: (next: AsideWidget[]) => void;
};
export default function AsideWidgetList({ widgets, onChange }: Props) {
const handleToggle = (id: AsideWidgetId, enabled: boolean) => {
onChange(widgets.map(w => (w.id === id ? { ...w, enabled } : w)));
};
return (
<AdminSortableList
items={widgets}
getId={widget => widget.id}
onReorder={onChange}
showMoveButtons={false}
className="admin-sortable-list admin-sortable-list--boxed"
ariaLabel="右侧栏组件"
renderItem={(widget, _index, controls) => {
const meta = WIDGET_META[widget.id];
return (
<div
ref={controls.setNodeRef}
style={controls.style}
className={`admin-sortable-row admin-sortable-row--widget${controls.isDragging ? ' is-dragging' : ''}`}
>
<SortableDragHandle
label={`拖拽调整「${meta.label}」顺序`}
{...controls.dragHandleProps}
/>
<div className="admin-sortable-row__main">
<span className="admin-sortable-row__label" id={`aside-widget-label-${widget.id}`}>
{meta.label}
</span>
<span className="admin-sortable-row__hint">{meta.hint}</span>
</div>
<button
type="button"
id={`aside-widget-${widget.id}`}
role="switch"
aria-checked={widget.enabled}
aria-labelledby={`aside-widget-label-${widget.id}`}
className={`admin-settings-switch${widget.enabled ? ' is-on' : ''}`}
onClick={() => handleToggle(widget.id, !widget.enabled)}
>
<span className="admin-settings-switch-ui" aria-hidden />
</button>
</div>
);
}}
/>
);
}

View File

@@ -3,7 +3,13 @@ import BoardIconDisplay from '../BoardIconDisplay';
import { getBoardThemeIndex } from '../../utils/boardTheme';
import type { Board, ForumLimitsPublic } from '../../api/types';
export type PostType = 'normal' | 'question';
export type PostType = 'normal' | 'question' | 'poll' | 'bounty' | 'lottery';
const SPECIAL_TYPE_LABELS: Record<'poll' | 'bounty' | 'lottery', string> = {
poll: '投票',
bounty: '悬赏',
lottery: '抽奖',
};
interface Props {
isEdit: boolean;
@@ -32,34 +38,93 @@ export default function ComposeContextBar({
onTagsChange,
limits,
}: Props) {
const isSpecialEdit = isEdit && (postType === 'poll' || postType === 'bounty' || postType === 'lottery');
return (
<section className="compose-context" aria-label="发布设置">
<div className="compose-context-row">
<span className="compose-context-label"></span>
<div className="compose-type-field">
<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={() => onPostTypeChange('normal')}
>
</button>
<button
type="button"
role="radio"
aria-checked={postType === 'question'}
className={`compose-type-pill${postType === 'question' ? ' active' : ''}`}
onClick={() => onPostTypeChange('question')}
>
</button>
{isSpecialEdit ? (
<button
type="button"
role="radio"
aria-checked
className="compose-type-pill active"
disabled
>
{SPECIAL_TYPE_LABELS[postType]}
</button>
) : (
<>
<button
type="button"
role="radio"
aria-checked={postType === 'normal'}
className={`compose-type-pill${postType === 'normal' ? ' active' : ''}`}
onClick={() => onPostTypeChange('normal')}
>
</button>
<button
type="button"
role="radio"
aria-checked={postType === 'question'}
className={`compose-type-pill${postType === 'question' ? ' active' : ''}`}
onClick={() => onPostTypeChange('question')}
disabled={isEdit}
>
</button>
{!isEdit && (
<>
<button
type="button"
role="radio"
aria-checked={postType === 'poll'}
className={`compose-type-pill${postType === 'poll' ? ' active' : ''}`}
onClick={() => onPostTypeChange('poll')}
>
</button>
<button
type="button"
role="radio"
aria-checked={postType === 'bounty'}
className={`compose-type-pill${postType === 'bounty' ? ' active' : ''}`}
onClick={() => onPostTypeChange('bounty')}
>
</button>
<button
type="button"
role="radio"
aria-checked={postType === 'lottery'}
className={`compose-type-pill${postType === 'lottery' ? ' active' : ''}`}
onClick={() => onPostTypeChange('lottery')}
>
</button>
</>
)}
</>
)}
</div>
{postType === 'question' && (
<span className="compose-type-hint"></span>
)}
{postType === 'poll' && (
<span className="compose-type-hint">
{isEdit ? '投票选项发布后不可修改' : '发布后选项不可修改'}
</span>
)}
{postType === 'bounty' && (
<span className="compose-type-hint"></span>
)}
{postType === 'lottery' && (
<span className="compose-type-hint"></span>
)}
</div>
</div>
<div className="compose-context-row">

View File

@@ -0,0 +1,259 @@
import { Plus, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import type { PostType } from './ComposeContextBar';
interface Props {
postType: PostType;
pollOptions: string[];
onPollOptionsChange: (opts: string[]) => void;
pollMulti: boolean;
onPollMultiChange: (v: boolean) => void;
pollMaxChoices: number;
onPollMaxChoicesChange: (v: number) => void;
pollEndsAt: string;
onPollEndsAtChange: (v: string) => void;
pollNoEndTime: boolean;
onPollNoEndTimeChange: (v: boolean) => void;
bountyPoints: number;
onBountyPointsChange: (v: number) => void;
userPointsBalance?: number;
lotteryWinners: number;
onLotteryWinnersChange: (v: number) => void;
disabled?: boolean;
}
/** 默认截止时间7 天后,分钟进位到下一整点 */
export function defaultPollEndsAtLocal(): string {
const d = new Date(Date.now() + 7 * 24 * 3600_000);
d.setMinutes(0, 0, 0);
if (d.getTime() <= Date.now()) {
d.setHours(d.getHours() + 1);
}
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** datetime-local → ISO8601UTC */
export function pollEndsAtLocalToISO(local: string): string | undefined {
const trimmed = local.trim();
if (!trimmed) return undefined;
const d = new Date(trimmed);
if (Number.isNaN(d.getTime())) return undefined;
return d.toISOString();
}
/** ISO8601 → datetime-local */
export function pollEndsAtISOToLocal(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** 特殊帖类型附加字段(投票/悬赏/抽奖) */
export default function ComposeSpecialFields({
postType,
pollOptions,
onPollOptionsChange,
pollMulti,
onPollMultiChange,
pollMaxChoices,
onPollMaxChoicesChange,
pollEndsAt,
onPollEndsAtChange,
pollNoEndTime,
onPollNoEndTimeChange,
bountyPoints,
onBountyPointsChange,
userPointsBalance,
lotteryWinners,
onLotteryWinnersChange,
disabled,
}: Props) {
if (postType === 'normal' || postType === 'question') return null;
if (postType === 'poll') {
return (
<section className="compose-special" aria-label="投票设置">
<Label>2-10 </Label>
<div className="compose-special__poll-mode">
<Switch checked={pollMulti} onCheckedChange={onPollMultiChange} disabled={disabled} id="poll-multi" />
<label htmlFor="poll-multi"></label>
{pollMulti && (
<>
<span className="compose-special__max-choices-label"></span>
<Input
type="number"
min={1}
max={pollOptions.length || 10}
value={pollMaxChoices}
onChange={e => onPollMaxChoicesChange(Number(e.target.value) || 1)}
disabled={disabled}
className="compose-special__max-choices"
aria-label="最多可选"
/>
<span className="compose-special__max-choices-suffix"></span>
</>
)}
</div>
<div className="compose-special__poll-deadline">
<div className="compose-special__poll-deadline-toggle">
<Switch
checked={pollNoEndTime}
onCheckedChange={onPollNoEndTimeChange}
disabled={disabled}
id="poll-no-end-time"
/>
<label htmlFor="poll-no-end-time"></label>
</div>
{!pollNoEndTime && (
<div className="compose-special__poll-deadline-field">
<Label htmlFor="poll-ends-at" className="compose-special__poll-deadline-label"></Label>
<Input
id="poll-ends-at"
type="datetime-local"
value={pollEndsAt}
onChange={e => onPollEndsAtChange(e.target.value)}
disabled={disabled}
className="compose-special__poll-deadline-input w-auto"
/>
</div>
)}
</div>
<div className="compose-special__options">
{pollOptions.map((opt, i) => (
<div key={i} className="compose-special__option-row">
<Input
value={opt}
placeholder={`选项 ${i + 1}`}
maxLength={64}
disabled={disabled}
onChange={e => {
const next = [...pollOptions];
next[i] = e.target.value;
onPollOptionsChange(next);
}}
/>
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled || pollOptions.length <= 2}
onClick={() => onPollOptionsChange(pollOptions.filter((_, j) => j !== i))}
aria-label="删除选项"
>
<Trash2 size={16} />
</Button>
</div>
))}
</div>
{pollOptions.length < 10 && (
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
onClick={() => onPollOptionsChange([...pollOptions, ''])}
>
<Plus size={14} />
</Button>
)}
</section>
);
}
if (postType === 'bounty') {
const showBalance = !disabled && typeof userPointsBalance === 'number';
const balance = userPointsBalance ?? 0;
const overBudget = showBalance && bountyPoints > balance;
const remaining = showBalance && bountyPoints > 0 && !overBudget
? balance - bountyPoints
: null;
return (
<section className="compose-special" aria-label="悬赏设置">
<Label htmlFor="bounty-points"></Label>
<div className="compose-special__bounty-row">
<Input
id="bounty-points"
type="number"
min={1}
value={bountyPoints || ''}
disabled={disabled}
onChange={e => onBountyPointsChange(Math.max(0, Number(e.target.value) || 0))}
/>
{showBalance && (
<p className={cn(
'compose-special__balance',
overBudget && 'compose-special__balance--over',
)}
>
{overBudget
? '积分不足'
: remaining != null
? `当前余额 ${balance} · 发布后剩余 ${remaining}`
: `当前余额 ${balance}`}
</p>
)}
</div>
</section>
);
}
if (postType === 'lottery') {
return (
<section className="compose-special" aria-label="抽奖设置">
<Label htmlFor="lottery-winners">1-20</Label>
<Input
id="lottery-winners"
type="number"
min={1}
max={20}
value={lotteryWinners || 1}
disabled={disabled}
onChange={e => onLotteryWinnersChange(Math.min(20, Math.max(1, Number(e.target.value) || 1)))}
/>
<p className="compose-special__hint"></p>
</section>
);
}
return null;
}
export function buildPollOptionsPayload(
options: string[],
multi: boolean,
maxChoices: number,
endsAtISO?: string | null,
): string {
const payload: Record<string, unknown> = {
multi,
max_choices: maxChoices,
options: options.filter(o => o.trim()).map(text => ({ text: text.trim() })),
};
if (endsAtISO) payload.ends_at = endsAtISO;
return JSON.stringify(payload);
}
/** 统计有效(非空)投票选项数量 */
export function countValidPollOptions(options: string[]): number {
return options.filter(o => o.trim()).length;
}
/** 前端校验投票截止时间(非不限时时) */
export function validatePollEndsAtLocal(local: string): string | null {
const iso = pollEndsAtLocalToISO(local);
if (!iso) return '请选择有效的投票截止时间';
if (new Date(iso).getTime() <= Date.now() + 5 * 60_000) {
return '投票截止时间须晚于当前时间至少 5 分钟';
}
if (new Date(iso).getTime() > Date.now() + 365 * 24 * 3600_000) {
return '投票截止时间不能超过 365 天';
}
return null;
}