feat: 增加友链申请、独立页面、投票/悬赏/抽奖帖与侧栏签到,并统一开发数据目录
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
104
frontend/src/components/AsideCheckInStrip.tsx
Normal file
104
frontend/src/components/AsideCheckInStrip.tsx
Normal 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">登录后每日可得 5–15 积分</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}` : '/';
|
||||
}
|
||||
|
||||
|
||||
276
frontend/src/components/FriendLinkApplyDialog.tsx
Normal file
276
frontend/src/components/FriendLinkApplyDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
frontend/src/components/FriendLinkSiteInfo.tsx
Normal file
56
frontend/src/components/FriendLinkSiteInfo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
182
frontend/src/components/PostBountyBanner.tsx
Normal file
182
frontend/src/components/PostBountyBanner.tsx
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
98
frontend/src/components/PostLotteryCard.tsx
Normal file
98
frontend/src/components/PostLotteryCard.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
175
frontend/src/components/PostPollCard.tsx
Normal file
175
frontend/src/components/PostPollCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
215
frontend/src/components/admin/AdminSortableList.tsx
Normal file
215
frontend/src/components/admin/AdminSortableList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
frontend/src/components/admin/AsideWidgetList.tsx
Normal file
71
frontend/src/components/admin/AsideWidgetList.tsx
Normal 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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
259
frontend/src/components/compose/ComposeSpecialFields.tsx
Normal file
259
frontend/src/components/compose/ComposeSpecialFields.tsx
Normal 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 → ISO8601(UTC) */
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user