移除旧版 HTML 模板与兼容层,并完善私信、举报、媒体存储与 SEO。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 16:37:11 +08:00
parent 060b7707cb
commit 48db333272
121 changed files with 11147 additions and 3225 deletions

View File

@@ -0,0 +1,12 @@
import { Spinner } from '@/components/ui/spinner';
/** 登录/注册懒加载占位:保持 auth 页氛围,避免整屏空白转圈 */
export default function AuthPageFallback() {
return (
<div className="auth-page" aria-busy="true" aria-label="加载中">
<div className="auth-box auth-box--loading">
<Spinner size="lg" />
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { useState, type ComponentProps } from 'react';
import { Eye, EyeOff } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
type Props = ComponentProps<typeof Input>;
/** 带显示/隐藏切换的密码输入 */
export default function AuthPasswordInput({ className, ...props }: Props) {
const [visible, setVisible] = useState(false);
return (
<div className="auth-password-field">
<Input
{...props}
type={visible ? 'text' : 'password'}
className={cn('auth-password-field__input', className)}
/>
<button
type="button"
className="auth-password-field__toggle"
onClick={() => setVisible(v => !v)}
aria-label={visible ? '隐藏密码' : '显示密码'}
tabIndex={-1}
>
{visible ? <EyeOff size={16} aria-hidden /> : <Eye size={16} aria-hidden />}
</button>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { ArrowUp } from 'lucide-react';
import { ArrowUp, MessageSquare } from 'lucide-react';
/** 滚动超过该距离后显示按钮 */
const SHOW_THRESHOLD = 320;
@@ -32,6 +32,7 @@ export default function BackToTop() {
const loc = useLocation();
const [visible, setVisible] = useState(false);
const scrollElRef = useRef<HTMLElement | null>(null);
const isPostDetail = /^\/post\/\d+/.test(loc.pathname);
const syncVisible = useCallback(() => {
const el = scrollElRef.current;
@@ -110,16 +111,35 @@ export default function BackToTop() {
el.scrollTo({ top: 0, behavior: 'smooth' });
};
const scrollToComments = () => {
const section = document.querySelector<HTMLElement>('.comment-section');
section?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
return (
<button
type="button"
className={`back-to-top${visible ? ' back-to-top--visible' : ''}`}
onClick={scrollToTop}
aria-label="回到顶部"
title="回到顶部"
tabIndex={visible ? 0 : -1}
>
<ArrowUp size={20} strokeWidth={2.25} />
</button>
<div className={`back-to-top-stack${visible ? ' back-to-top-stack--visible' : ''}`}>
{isPostDetail && (
<button
type="button"
className="back-to-top back-to-top--comment"
onClick={scrollToComments}
aria-label="前往评论"
title="前往评论"
tabIndex={visible ? 0 : -1}
>
<MessageSquare size={18} strokeWidth={2.25} />
</button>
)}
<button
type="button"
className="back-to-top"
onClick={scrollToTop}
aria-label="回到顶部"
title="回到顶部"
tabIndex={visible ? 0 : -1}
>
<ArrowUp size={20} strokeWidth={2.25} />
</button>
</div>
);
}

View File

@@ -1,16 +1,16 @@
import { useState, useRef, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Send } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { notify } from '@/lib/notify';
import type { User, Comment } from '../api/types';
import EmojiPicker from './EmojiPicker';
import { loadGuestInfo, saveGuestInfo } from '../utils/guest';
import { commentNick } from '../utils/comment';
import { loginPath, registerPath } from '../utils/authRedirect';
export interface CommentSubmitData {
content: string;
guestNick?: string;
guestEmail?: string;
guestUrl?: string;
isPrivate: boolean;
}
@@ -24,13 +24,9 @@ interface Props {
onCancelReply?: () => void;
}
/** Waline 风格评论输入框:登录用户 / 游客双模式 */
/** 评论输入框:登录后发表 */
export default function CommentBox({ user, replyTo, inline, submitting, submitCount = 0, onSubmit, onCancelReply }: Props) {
const saved = loadGuestInfo();
const [content, setContent] = useState('');
const [guestNick, setGuestNick] = useState(saved.nick);
const [guestEmail, setGuestEmail] = useState(saved.email);
const [guestUrl, setGuestUrl] = useState(saved.url);
const [isPrivate, setIsPrivate] = useState(false);
const [showEmoji, setShowEmoji] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -39,7 +35,6 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
useEffect(() => {
if (inline && replyTo) {
// preventScroll 避免 focus 与页面 scrollIntoView 争抢滚动位置
textareaRef.current?.focus({ preventScroll: true });
}
}, [replyTo?.id, inline]);
@@ -90,21 +85,14 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
};
const handleSubmit = () => {
if (!user) return;
const text = content.trim();
if (!text) return;
if (!user && !guestNick.trim()) return;
if (!user) {
saveGuestInfo({ nick: guestNick.trim(), email: guestEmail.trim(), url: guestUrl.trim() });
if (!text) {
notify.warning('请先写点内容');
textareaRef.current?.focus();
return;
}
onSubmit({
content: text,
guestNick: user ? undefined : guestNick.trim(),
guestEmail: user ? undefined : guestEmail.trim(),
guestUrl: user ? undefined : guestUrl.trim(),
isPrivate,
});
onSubmit({ content: text, isPrivate });
};
const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -114,20 +102,33 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
}
};
const avatarInitial = user?.nickname?.[0] || guestNick?.[0] || '?';
if (!user) {
return (
<div className={`comment-login-gate${inline ? ' comment-login-gate--inline' : ''}`}>
<p className="comment-login-gate__text"></p>
<div className="comment-login-gate__actions">
<Button asChild size="sm">
<Link to={loginPath()}></Link>
</Button>
<Link to={registerPath()} className="comment-login-gate__register">
</Link>
</div>
</div>
);
}
const avatarInitial = user.nickname?.[0] || '?';
const canSend = !!content.trim() && !submitting;
return (
<div className="comment-box" ref={boxRef}>
<div className="comment-box-avatar">
{user?.avatar ? (
{user.avatar ? (
<img src={user.avatar} alt="" className="comment-box-avatar-img" loading="lazy" decoding="async" />
) : (
<div className={`comment-box-avatar-placeholder ${user ? '' : 'guest'}`}>
{user ? avatarInitial : (
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z" />
</svg>
)}
<div className="comment-box-avatar-placeholder">
{avatarInitial}
</div>
)}
</div>
@@ -155,62 +156,15 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
<button
type="button"
className="comment-box-send"
disabled={submitting || !content.trim() || (!user && !guestNick.trim())}
disabled={!canSend}
onClick={handleSubmit}
aria-label="发送评论"
title="发送"
title="发送Ctrl/⌘ + Enter"
>
<Send size={16} />
</button>
</div>
{!user && (
<div className="comment-box-guest-fields">
<label className="comment-box-guest-field">
<span className="comment-box-guest-label">
<em className="comment-box-guest-required"></em>
</span>
<input
className="comment-box-guest-input"
placeholder="怎么称呼你"
autoComplete="nickname"
value={guestNick}
onChange={(e) => setGuestNick(e.target.value)}
/>
</label>
<label className="comment-box-guest-field">
<span className="comment-box-guest-label">
<em className="comment-box-guest-optional"></em>
</span>
<input
className="comment-box-guest-input"
placeholder="name@example.com"
type="email"
autoComplete="email"
value={guestEmail}
onChange={(e) => setGuestEmail(e.target.value)}
/>
</label>
<label className="comment-box-guest-field">
<span className="comment-box-guest-label">
<em className="comment-box-guest-optional"></em>
</span>
<input
className="comment-box-guest-input"
placeholder="https://example.com"
type="url"
autoComplete="url"
value={guestUrl}
onChange={(e) => setGuestUrl(e.target.value)}
/>
</label>
<p className="comment-box-guest-hint"></p>
</div>
)}
<div className="comment-box-toolbar">
<button
ref={owoRef}
@@ -223,10 +177,11 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
>
OwO
</button>
<label className="comment-box-private">
<label className="comment-box-private" title="仅作者与管理员可见">
<Switch checked={isPrivate} onCheckedChange={setIsPrivate} />
<span></span>
</label>
<span className="comment-box-private-hint"></span>
</div>
{showEmoji && <EmojiPicker id="comment-emoji-picker" onSelect={insertEmoji} />}

View File

@@ -0,0 +1,142 @@
import { useEffect, useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { Comment, CommentRevision } from '../api/types';
import { formatTime } from '../utils/content';
import { countLineChanges, diffTextLines } from '../utils/revisionDiff';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
comment: Comment | null;
}
function DiffBlock({ before, after }: { before: string; after: string }) {
const parts = diffTextLines(before, after);
const { added, removed } = countLineChanges(parts);
if (before === after) {
return <p className="revision-diff-unchanged"></p>;
}
return (
<div className="revision-diff-lines">
<div className="revision-diff-stats">
{removed > 0 && <span className="revision-diff-stat revision-diff-stat--del"> {removed} </span>}
{added > 0 && <span className="revision-diff-stat revision-diff-stat--add"> {added} </span>}
</div>
<pre className="revision-diff-pre">
{parts.map((part, i) => {
const lines = part.value.split('\n');
return lines.map((line, j) => {
if (j === lines.length - 1 && line === '') return null;
const cls = part.added
? 'revision-diff-line revision-diff-line--add'
: part.removed
? 'revision-diff-line revision-diff-line--del'
: 'revision-diff-line revision-diff-line--same';
const prefix = part.added ? '+' : part.removed ? '' : ' ';
return (
<div key={`${i}-${j}`} className={cls}>
<span className="revision-diff-gutter" aria-hidden="true">{prefix}</span>
<span className="revision-diff-text">{line || ' '}</span>
</div>
);
});
})}
</pre>
</div>
);
}
/** 管理员查看评论编辑历史 */
export default function CommentRevisionDialog({ open, onOpenChange, comment }: Props) {
const [revisions, setRevisions] = useState<CommentRevision[]>([]);
const [loading, setLoading] = useState(false);
const [activeId, setActiveId] = useState<number | null>(null);
useEffect(() => {
if (!open || !comment) {
setRevisions([]);
setActiveId(null);
return;
}
let cancelled = false;
setLoading(true);
api.adminCommentRevisions(comment.id)
.then((r) => {
if (cancelled) return;
const list = r.revisions ?? [];
setRevisions(list);
setActiveId(list[0]?.id ?? null);
})
.catch((e: unknown) => {
if (!cancelled) notify.error(e instanceof Error ? e.message : '加载失败');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [open, comment]);
const active = revisions.find((r) => r.id === activeId) || null;
const activeIndex = active ? revisions.findIndex((r) => r.id === active.id) : -1;
const afterContent = activeIndex <= 0
? (comment?.content ?? '')
: (revisions[activeIndex - 1]?.content ?? '');
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{comment ? `#${comment.floor} 楼 · 共 ${revisions.length} 次修改前快照` : '评论编辑历史'}
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex justify-center py-10"><Spinner /></div>
) : revisions.length === 0 ? (
<div className="admin-empty"></div>
) : (
<div className="comment-rev-layout">
<aside className="comment-rev-list" aria-label="历史版本">
{revisions.map((rev, i) => (
<button
key={rev.id}
type="button"
className={`comment-rev-item${activeId === rev.id ? ' active' : ''}`}
onClick={() => setActiveId(rev.id)}
>
<span className="comment-rev-item__ver"> {revisions.length - i}</span>
<span className="comment-rev-item__meta">
{rev.editor?.nickname || `用户 #${rev.editor_id}`}
{' · '}
{formatTime(rev.created_at)}
</span>
</button>
))}
</aside>
<div className="comment-rev-detail">
{active ? (
<>
<p className="comment-rev-detail__hint">
{activeIndex <= 0 ? '当前正文' : '下一版本'}
</p>
<DiffBlock before={active.content} after={afterContent} />
</>
) : null}
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -1,8 +1,9 @@
import { useState, useEffect } from 'react';
import { Clock, MessageSquare, X, Pencil, Trash2 } from 'lucide-react';
import { Check, Clock, History, MessageSquare, X, Pencil, Trash2 } from 'lucide-react';
import type { ReactNode } from 'react';
import type { Comment, User } from '../api/types';
import CommentContent from './CommentContent';
import CommentRevisionDialog from './CommentRevisionDialog';
import {
AlertDialog,
AlertDialogAction,
@@ -26,10 +27,18 @@ import { isTimeDiffSignificant } from '../utils/content';
import { useForumLimits } from '../hooks/useForumLimits';
import UserLink from './UserLink';
function canManageComment(c: Comment, user?: User | null): boolean {
function isCommentAuthor(c: Comment, user?: User | null): boolean {
return !!user && c.user_id > 0 && c.user_id === user.id;
}
function canEditComment(c: Comment, user: User | null | undefined, windowHours: number): boolean {
if (!user) return false;
if (user.role === 'admin') return true;
return c.user_id > 0 && c.user_id === user.id;
if (!isCommentAuthor(c, user)) return false;
if (windowHours <= 0) return true;
const created = new Date(c.created_at).getTime();
if (Number.isNaN(created)) return false;
return Date.now() - created <= windowHours * 3600_000;
}
interface ItemProps {
@@ -45,6 +54,7 @@ interface ItemProps {
onCancelEdit: () => void;
onSaveEdit: (comment: Comment, content: string) => Promise<void>;
onDelete: (comment: Comment) => Promise<void>;
onApprove?: (comment: Comment) => Promise<void>;
renderReplyBox?: (comment: Comment) => ReactNode;
}
@@ -62,6 +72,7 @@ function CommentItem({
onCancelEdit,
onSaveEdit,
onDelete,
onApprove,
renderReplyBox,
}: ItemProps) {
const { limits } = useForumLimits();
@@ -72,11 +83,18 @@ function CommentItem({
const hidden = !!c.content_hidden;
const isReplying = replyToId === c.id;
const isEditing = editingId === c.id;
const manageable = canManageComment(c, currentUser);
const isAdmin = currentUser?.role === 'admin';
const canEdit = canEditComment(c, currentUser, limits.comment_edit_window_hours ?? 24);
const canDelete = isAdmin;
const canApprove = isAdmin
&& (c.status === 'pending' || c.status === 'rejected')
&& !!onApprove;
const showEdited = !hidden && !!c.updated_at && isTimeDiffSignificant(c.created_at, c.updated_at);
const [editText, setEditText] = useState(c.content);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [approving, setApproving] = useState(false);
const [revOpen, setRevOpen] = useState(false);
useEffect(() => {
if (isEditing) setEditText(c.content);
@@ -178,7 +196,27 @@ function CommentItem({
<Clock size={14} />
{formatCommentDate(c.created_at)}
{showEdited && <span className="waline-comment-edited"> · </span>}
{c.status === 'pending' && <span className="waline-comment-status waline-comment-status--pending"> · </span>}
{c.status === 'rejected' && <span className="waline-comment-status waline-comment-status--rejected"> · </span>}
</span>
{!hidden && !isEditing && canApprove && (
<button
type="button"
className="waline-comment-reply-btn waline-comment-approve-btn"
disabled={approving}
onClick={async () => {
setApproving(true);
try {
await onApprove?.(c);
} finally {
setApproving(false);
}
}}
>
<Check size={14} />
{approving ? '通过中…' : '通过'}
</button>
)}
{!hidden && !isEditing && (
isReplying ? (
<button type="button" className="waline-comment-reply-btn cancel" onClick={onCancelReply}>
@@ -192,13 +230,19 @@ function CommentItem({
</button>
)
)}
{!hidden && !isEditing && manageable && (
{!hidden && !isEditing && canEdit && (
<button type="button" className="waline-comment-reply-btn" onClick={() => onStartEdit(c)}>
<Pencil size={14} />
</button>
)}
{!hidden && !isEditing && manageable && (
{!hidden && !isEditing && isAdmin && showEdited && (
<button type="button" className="waline-comment-reply-btn" onClick={() => setRevOpen(true)}>
<History size={14} />
</button>
)}
{!hidden && !isEditing && canDelete && (
<AlertDialog>
<AlertDialogTrigger asChild>
<button type="button" className="waline-comment-reply-btn cancel" disabled={deleting}>
@@ -231,6 +275,10 @@ function CommentItem({
)}
</div>
{isAdmin && (
<CommentRevisionDialog open={revOpen} onOpenChange={setRevOpen} comment={c} />
)}
{isReplying && renderReplyBox && (
<div id={`reply-box-${c.id}`} className="comment-box-wrap inline">
{renderReplyBox(c)}
@@ -254,6 +302,7 @@ function CommentItem({
onCancelEdit={onCancelEdit}
onSaveEdit={onSaveEdit}
onDelete={onDelete}
onApprove={onApprove}
renderReplyBox={renderReplyBox}
/>
))}
@@ -276,6 +325,7 @@ interface Props {
onCancelEdit: () => void;
onSaveEdit: (comment: Comment, content: string) => Promise<void>;
onDelete: (comment: Comment) => Promise<void>;
onApprove?: (comment: Comment) => Promise<void>;
renderReplyBox?: (comment: Comment) => ReactNode;
}
@@ -292,6 +342,7 @@ export default function CommentThreadList({
onCancelEdit,
onSaveEdit,
onDelete,
onApprove,
renderReplyBox,
}: Props) {
const tree = buildCommentTree(comments);
@@ -312,6 +363,7 @@ export default function CommentThreadList({
onCancelEdit={onCancelEdit}
onSaveEdit={onSaveEdit}
onDelete={onDelete}
onApprove={onApprove}
renderReplyBox={renderReplyBox}
/>
))}

View File

@@ -0,0 +1,86 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
toUserId: number;
toNickname: string;
onSent?: () => void;
}
/** 发送私信对话框(对话式,无需标题) */
export default function ComposeMessageDialog({
open,
onOpenChange,
toUserId,
toNickname,
onSent,
}: Props) {
const [content, setContent] = useState('');
const [sending, setSending] = useState(false);
const handleOpenChange = (next: boolean) => {
if (!next) setContent('');
onOpenChange(next);
};
const submit = async () => {
if (!content.trim()) {
notify.warning('请填写内容');
return;
}
setSending(true);
try {
await api.sendMessage({
to_user_id: toUserId,
content: content.trim(),
});
notify.success('私信已发送');
handleOpenChange(false);
onSent?.();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '发送失败');
} finally {
setSending(false);
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription> {toNickname}</DialogDescription>
</DialogHeader>
<div className="pm-compose-fields">
<label className="pm-field">
<span className="sr-only"></span>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
rows={6}
maxLength={4000}
placeholder="写点什么…"
autoFocus
/>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)}></Button>
<Button loading={sending} onClick={submit}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -19,12 +19,20 @@ export default class ErrorBoundary extends Component<Props, State> {
render() {
if (this.state.error) {
return (
<div className="error-boundary">
<h3></h3>
<p className="error-boundary-msg">{this.state.error.message}</p>
<Button size="sm" onClick={() => { this.setState({ error: null }); window.location.reload(); }}>
</Button>
<div className="error-page-shell">
<div className="error-page">
<div className="error-page__code" aria-hidden>500</div>
<h1 className="error-page__title"></h1>
<p className="error-page__desc">{this.state.error.message || '发生了意外错误,请尝试刷新页面。'}</p>
<div className="error-page__actions">
<Button size="sm" onClick={() => { this.setState({ error: null }); window.location.reload(); }}>
</Button>
<Button size="sm" variant="outline" onClick={() => { window.location.href = '/'; }}>
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
interface Props {
className?: string;
size?: number;
}
/** 精华帖标识 */
export default function FeaturedIcon({ className, size = 16 }: Props) {
return (
<Sparkles
className={cn('post-featured-icon', className)}
size={size}
aria-label="精华"
role="img"
/>
);
}

View File

@@ -8,9 +8,11 @@ interface Props {
boards: Board[];
stats: ForumStats | null;
postTotal: number;
/** 首页「全部帖子」用 h2板块/搜索页用 h1 */
titleAs?: 'h1' | 'h2';
}
export default function FeedHeader({ boardId, keyword, boards, stats, postTotal }: Props) {
export default function FeedHeader({ boardId, keyword, boards, stats, postTotal, titleAs = 'h1' }: Props) {
const nav = useNavigate();
const board = boards.find(b => b.id === boardId);
@@ -19,12 +21,27 @@ export default function FeedHeader({ boardId, keyword, boards, stats, postTotal
: (boardId && board ? board.name : '全部帖子');
const boardHint = boardId && board ? (board.description || '') : '';
const TitleTag = titleAs;
const inBoard = !keyword && boardId > 0 && !!board;
return (
<div className={`feed-head${keyword ? ' feed-head--solo' : ''}`}>
<div className="feed-head__title">
<h2 title={boardHint || undefined}>{title}</h2>
{!keyword && stats && (
<TitleTag title={boardHint || undefined}>{title}</TitleTag>
{!keyword && inBoard && (
<div className="feed-head__stats">
<span className="feed-stat-chip">
<FileText aria-hidden />
<strong>{postTotal}</strong>
</span>
{stats && (
<span className="feed-stat-chip feed-stat-chip--muted" title="全站统计">
{stats.posts} · {stats.users}
</span>
)}
</div>
)}
{!keyword && !inBoard && stats && (
<div className="feed-head__stats">
<span className="feed-stat-chip">
<Users aria-hidden />

View File

@@ -7,22 +7,24 @@ export default function FeedPageSkeleton() {
<div className="page-wrap page-wrap--feed" aria-busy="true" aria-label="内容加载中">
<div className="feed-panel">
<div className="feed-top">
<div className="feed-head">
<div className="feed-head__title">
<Skeleton className="skeleton--feed-title" />
<div className="feed-head__stats">
<Skeleton className="skeleton--stat-chip" />
<Skeleton className="skeleton--stat-chip" />
<Skeleton className="skeleton--stat-chip" />
<div className="feed-top__bar">
<div className="feed-head">
<div className="feed-head__title">
<Skeleton className="skeleton--feed-title" />
<div className="feed-head__stats">
<Skeleton className="skeleton--stat-chip" />
<Skeleton className="skeleton--stat-chip" />
<Skeleton className="skeleton--stat-chip" />
</div>
</div>
</div>
</div>
<div className="feed-toolbar feed-toolbar--skeleton" aria-hidden>
<Skeleton className="skeleton--sort-tab" />
<Skeleton className="skeleton--sort-tab" />
<Skeleton className="skeleton--sort-tab" />
<span className="feed-toolbar__spacer" />
<Skeleton className="skeleton--count" />
<div className="feed-toolbar feed-toolbar--skeleton" aria-hidden>
<Skeleton className="skeleton--sort-tab" />
<Skeleton className="skeleton--sort-tab" />
<Skeleton className="skeleton--sort-tab" />
<span className="feed-toolbar__spacer" />
<Skeleton className="skeleton--count" />
</div>
</div>
</div>
<div className="post-list-scroll">

View File

@@ -0,0 +1,176 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Eye, FileText, Heart, Mail, MessageCircle, UserRound } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { api } from '../api/client';
import type { User, UserActivityStats, UserPublic } from '../api/types';
import { useAuth } from '../hooks/useAuth';
import { loginPath } from '../utils/authRedirect';
import { formatTime } from '../utils/content';
import { userPath } from '../utils/userPath';
import ComposeMessageDialog from './ComposeMessageDialog';
import UserLink from './UserLink';
interface Props {
author?: User | null;
publishedAt?: string;
viewCount?: number;
}
/** 帖子详情右栏:作者信息卡(私信 / 主页 / 统计) */
export default function PostAuthorCard({
author,
publishedAt,
viewCount,
}: Props) {
const nav = useNavigate();
const { user: me } = useAuth();
const [profile, setProfile] = useState<UserPublic | null>(null);
const [stats, setStats] = useState<UserActivityStats | null>(null);
const [msgOpen, setMsgOpen] = useState(false);
useEffect(() => {
if (!author?.id) {
setProfile(null);
setStats(null);
return;
}
let cancelled = false;
api.userProfile(author.id)
.then((r) => {
if (cancelled) return;
setProfile(r.user);
setStats(r.stats);
})
.catch(() => {
if (cancelled) return;
// 详情里已有轻量 user接口失败时仍可展示基本信息
setProfile(null);
setStats(null);
});
return () => { cancelled = true; };
}, [author?.id]);
if (!author?.id) {
return (
<div className="widget-card widget-card--author">
<div className="widget-card-head">
<UserRound className="widget-card-icon widget-card-icon--author" aria-hidden />
</div>
<div className="widget-card-body">
<div className="widget-empty"></div>
</div>
</div>
);
}
const display = profile ?? author;
const nick = display.nickname || display.username || `用户 #${author.id}`;
const initial = nick.charAt(0) || '?';
const signature = (profile?.signature ?? author.signature ?? '').trim();
const isAdmin = display.role === 'admin';
const isSelf = !!me && me.id === author.id;
const profileHref = userPath(author.id);
const openMessage = () => {
if (!me) {
nav(loginPath(profileHref));
return;
}
setMsgOpen(true);
};
return (
<div className="widget-card widget-card--author">
<div className="widget-card-head">
<UserRound className="widget-card-icon widget-card-icon--author" aria-hidden />
</div>
<div className="widget-author-panel">
<div className="widget-author-body">
<UserLink
user={display}
showAvatar={false}
showName={false}
className="widget-author-avatar user-link--avatar-only"
>
{display.avatar
? <img src={display.avatar} alt="" loading="lazy" decoding="async" />
: initial}
</UserLink>
<div className="widget-author-meta">
<div className="widget-author-name-row">
<UserLink user={display} className="widget-author-name" />
{isAdmin && <Badge variant="green" className="widget-author-badge"></Badge>}
{display.banned && <Badge variant="destructive" className="widget-author-badge"></Badge>}
</div>
{signature ? (
<p className="widget-author-signature" title={signature}>{signature}</p>
) : null}
{(publishedAt || typeof viewCount === 'number') && (
<p className="widget-author-stats">
{publishedAt ? <span>{formatTime(publishedAt)} </span> : null}
{publishedAt && typeof viewCount === 'number' ? (
<span className="widget-author-stats-dot" aria-hidden>·</span>
) : null}
{typeof viewCount === 'number' ? (
<span className="widget-author-views">
<Eye size={12} aria-hidden />
{viewCount}
</span>
) : null}
</p>
)}
</div>
</div>
<div className="widget-author-metrics" aria-label="作者统计">
<div className="widget-author-metric">
<FileText size={13} aria-hidden />
<strong>{stats?.post_count ?? '—'}</strong>
<span></span>
</div>
<div className="widget-author-metric">
<MessageCircle size={13} aria-hidden />
<strong>{stats?.comment_count ?? '—'}</strong>
<span></span>
</div>
<div className="widget-author-metric">
<Heart size={13} aria-hidden />
<strong>{stats?.like_received ?? '—'}</strong>
<span></span>
</div>
</div>
<div className="widget-author-actions">
{!isSelf && (
<Button size="sm" className="widget-author-action" onClick={openMessage}>
<Mail size={14} />
</Button>
)}
<Button
size="sm"
variant="outline"
className="widget-author-action"
onClick={() => nav(isSelf ? '/profile' : profileHref)}
>
{isSelf ? '我的主页' : '查看主页'}
</Button>
</div>
</div>
{!isSelf && (
<ComposeMessageDialog
open={msgOpen}
onOpenChange={setMsgOpen}
toUserId={author.id}
toNickname={nick}
onSent={() => nav(`/messages?peer=${author.id}`)}
/>
)}
</div>
);
}

View File

@@ -1,11 +1,14 @@
import { memo } from 'react';
import { MessageCircle, ThumbsUp } from 'lucide-react';
import { Eye, Image as ImageIcon, MessageCircle, ThumbsUp } from 'lucide-react';
import BoardBadge from '@/components/BoardBadge';
import FeaturedIcon from '@/components/FeaturedIcon';
import PinnedIcon from '@/components/PinnedIcon';
import UserLink from '@/components/UserLink';
import type { PostItem } from '../api/types';
import type { FeedSort } from './FeedSortBar';
import { formatTime } from '../utils/content';
import { postPath } from '../utils/permalink';
import { excerptFromHTML, firstImageFromHTML } from '../utils/seoText';
interface Props {
post: PostItem;
@@ -22,6 +25,10 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
: formatTime(post.created_at);
const commentCount = post.comment_count ?? 0;
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 openPost = () => onSelect(post.id);
const onKeyDown = (e: React.KeyboardEvent) => {
@@ -30,11 +37,21 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
openPost();
}
};
const onTitleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
// 修饰键 / 非左键:交给浏览器(新标签等)
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
e.stopPropagation();
return;
}
e.preventDefault();
e.stopPropagation();
openPost();
};
return (
<div
className="post-row"
role="button"
role="link"
tabIndex={0}
onClick={openPost}
onKeyDown={onKeyDown}
@@ -50,26 +67,68 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
? <img src={post.user.avatar} alt="" loading="lazy" decoding="async" />
: initial}
</UserLink>
<div className="post-body">
<div className="post-title">
{post.pinned && <PinnedIcon className="mr-1.5" />}
<div className="post-head">
<div className="post-head-meta">
<UserLink user={post.user} stopPropagation className="post-author" />
<span className="post-head-dot" aria-hidden>·</span>
<span className="post-time">{timeLabel}</span>
</div>
{(post.featured || post.pinned || post.status === 'pending' || post.status === 'rejected') && (
<div className="post-head-badges">
{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.featured && (
<span className="post-feature-badge" title="精华">
<FeaturedIcon size={12} />
</span>
)}
{post.pinned && (
<span className="post-pin-badge" title="置顶">
<PinnedIcon size={12} />
</span>
)}
</div>
)}
</div>
<a href={href} className="post-title" onClick={onTitleClick}>
{post.title}
</a>
{excerpt && <p className="post-excerpt">{excerpt}</p>}
<div className="post-foot">
<div className="post-foot-left">
{post.board && <BoardBadge board={post.board} />}
</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>
</div>
<div className="post-meta">
{post.board && <BoardBadge board={post.board} />}
<UserLink user={post.user} stopPropagation className="post-meta-user" />
<span>{timeLabel}</span>
</div>
</div>
<div className="post-stats">
<span className={`post-stat${commentCount === 0 ? ' post-stat--zero' : ''}`}>
<MessageCircle aria-hidden />
{commentCount}
</span>
<span className={`post-stat${likeCount === 0 ? ' post-stat--zero' : ''}`}>
<ThumbsUp aria-hidden />
{likeCount}
</span>
</div>
</div>
);

View File

@@ -4,7 +4,7 @@ interface Props {
count?: number;
}
/** 帖子列表加载骨架屏 */
/** 帖子列表加载骨架屏(对齐卡片式列表) */
export default function PostListSkeleton({ count = 8 }: Props) {
return (
<div className="post-list-skeleton" aria-busy="true" aria-label="加载中">
@@ -12,16 +12,23 @@ export default function PostListSkeleton({ count = 8 }: Props) {
<div key={i} className="post-row post-row--skeleton">
<Skeleton className="skeleton--avatar" />
<div className="post-body">
<Skeleton className="skeleton--title" style={{ width: `${55 + (i % 4) * 10}%` }} />
<div className="skeleton-meta-row">
<Skeleton className="skeleton--badge" />
<Skeleton className="skeleton--meta" />
<Skeleton className="skeleton--meta skeleton--meta-short" />
<div className="post-head">
<div className="skeleton-meta-row">
<Skeleton className="skeleton--meta" />
<Skeleton className="skeleton--meta skeleton--meta-short" />
</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>
</div>
</div>
<div className="post-stats">
<Skeleton className="skeleton--stat" />
<Skeleton className="skeleton--stat" />
</div>
</div>
))}

View File

@@ -1,19 +1,33 @@
import { Flame, MessageCircle, Tags } from 'lucide-react';
import { useSearchParams } from 'react-router-dom';
import { Flame, ListTree, MessageCircle, Tags, Sparkles } from 'lucide-react';
import { useLocation, useSearchParams } from 'react-router-dom';
import { Skeleton } from '@/components/ui/skeleton';
import type { PostItem, RecentComment, TagCount } from '../api/types';
import type { PostItem, RecentComment, TagCount, User } from '../api/types';
import type { PostHeading } from '../utils/postHeadings';
import { useSiteBranding } from '../hooks/useSiteBranding';
import TagCloud from './TagCloud';
import UserLink from './UserLink';
import ArticleOutline from './ArticleOutline';
import PostAuthorCard from './PostAuthorCard';
export type PostDetailAside = {
author?: User | null;
publishedAt?: string;
viewCount?: number;
headings: PostHeading[];
scrollRoot?: HTMLElement | null;
outlineTitle?: string;
};
interface Props {
hot: PostItem[];
recentComments: RecentComment[];
tags?: TagCount[];
tagsLoading?: boolean;
onPostClick: (id: number) => void;
onPostClick: (id: number, opts?: { floor?: number }) => void;
/** 首次拉取中,显示骨架避免空态闪烁 */
loading?: boolean;
/** 帖子详情:右侧顶部展示作者与目录 */
postDetail?: PostDetailAside | null;
}
function hotRankClass(index: number): string {
@@ -57,107 +71,173 @@ export default function RightPanel({
tagsLoading = false,
onPostClick,
loading = false,
postDetail = null,
}: Props) {
const { branding } = useSiteBranding();
const loc = useLocation();
const [params] = useSearchParams();
const activeTag = params.get('keyword') || '';
const hotList = hot?.slice(0, 8) ?? [];
const commentList = recentComments?.slice(0, 6) ?? [];
// 站点首页:右侧品牌块承担唯一 h1板块/搜索等页面由 Feed 标题作 h1
const isSiteHome = loc.pathname === '/' && !params.get('board') && !params.get('keyword');
const description = branding.description?.trim() || '';
const slogan = branding.slogan?.trim() || '';
// 有独立简介时展示简介;否则用欢迎语,避免与页脚 slogan 三连重复
const aboutText = description || '欢迎参与讨论,发帖、评论,一起把小圈子聊热。';
// 帖子很少时热门几乎等于主列表,改显示欢迎引导
const showHot = loading || hotList.length >= 4;
const showWelcome = !loading && hotList.length > 0 && hotList.length < 4;
const isPostDetail = !!postDetail;
return (
<div className="aside-panel-inner">
<div className="widget-card">
<div className="widget-card-head">
<Flame className="widget-card-icon widget-card-icon--hot" aria-hidden />
</div>
<div className="widget-card-body">
{loading && hotList.length === 0 ? (
<HotSkeleton />
) : hotList.length === 0 ? (
<div className="widget-empty"></div>
) : hotList.map((item, i) => (
<button
key={item.id}
type="button"
className="widget-item"
onClick={() => onPostClick(item.id)}
>
<span className={hotRankClass(i)}>{i + 1}</span>
<span className="widget-item-title">{item.title}</span>
</button>
))}
</div>
</div>
<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>
<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)}
>
<span className="widget-item-title">{item.excerpt}</span>
<span className="widget-item-time">{item.created_at}</span>
</button>
<div className={`aside-panel-inner${isPostDetail ? ' aside-panel-inner--post-detail' : ''}`}>
{isPostDetail && (
<>
<PostAuthorCard
author={postDetail.author}
publishedAt={postDetail.publishedAt}
viewCount={postDetail.viewCount}
/>
<div className="widget-card widget-card--outline">
<div className="widget-card-head">
<ListTree className="widget-card-icon widget-card-icon--outline" aria-hidden />
{postDetail.outlineTitle || '文章目录'}
</div>
))}
</div>
</div>
<div className="widget-card-body widget-outline-body">
<ArticleOutline
headings={postDetail.headings}
scrollRoot={postDetail.scrollRoot}
title={postDetail.outlineTitle || '文章目录'}
className="article-outline--aside"
/>
</div>
</div>
</>
)}
<div className="widget-card widget-card--about">
<div className="widget-card-body">
<p className="widget-about-text">
<strong>{branding.name}</strong>
{branding.slogan
? `${branding.slogan}${branding.name_en ? ` · ${branding.name_en}` : ''}`
: (branding.name_en || '轻量社区')}
</p>
{!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>
</div>
)}
{!isPostDetail && showHot && (
<div className="widget-card">
<div className="widget-card-head">
<Flame className="widget-card-icon widget-card-icon--hot" aria-hidden />
</div>
<div className="widget-card-body">
{loading && hotList.length === 0 ? (
<HotSkeleton />
) : hotList.length === 0 ? (
<div className="widget-empty"></div>
) : hotList.map((item, i) => (
<button
key={item.id}
type="button"
className="widget-item"
onClick={() => onPostClick(item.id)}
>
<span className={hotRankClass(i)}>{i + 1}</span>
<span className="widget-item-title">{item.title}</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">{item.created_at}</span>
</button>
</div>
))}
</div>
</div>
)}
{!isPostDetail && (
<div className="widget-card widget-card--about">
<div className="widget-card-body">
<div className="widget-about-text">
{isSiteHome ? (
<h1 className="widget-about-title">{branding.name}</h1>
) : (
<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>
)}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -20,8 +20,10 @@ export function isNeutralSidebarRoute(pathname: string): boolean {
return NEUTRAL_SIDEBAR_PREFIXES.some(prefix => pathname.startsWith(prefix));
}
function resolveMenuKey(pathname: string, activeBoard: number): string | null {
function resolveMenuKey(pathname: string, activeBoard: number, keyword = ''): string | null {
if (isNeutralSidebarRoute(pathname)) return null;
// 搜索结果不属于「全部帖子」或某一板块,取消侧栏选中高亮
if (keyword.trim()) return null;
if (pathname.startsWith('/favorites')) return 'favorites';
if (pathname.startsWith('/projects')) return 'projects';
if (pathname.startsWith('/admin')) return 'admin';
@@ -58,7 +60,8 @@ export default function Sidebar({
const { user } = useAuth();
const isAdmin = user?.role === 'admin';
const menuKey = resolveMenuKey(loc.pathname, activeBoard);
const keyword = params.get('keyword') || '';
const menuKey = resolveMenuKey(loc.pathname, activeBoard, keyword);
const navItem = (key: string, label: React.ReactNode, icon?: React.ReactNode, onClick?: () => void) => (
<button
@@ -138,7 +141,9 @@ export default function Sidebar({
/>
<span className="flex-1 truncate">{b.name}</span>
{(b.post_count ?? 0) > 0 && (
<span className="sidebar-nav-item__meta">{b.post_count}</span>
<span className="sidebar-nav-item__meta" title={`${b.post_count} 篇帖子`}>
{b.post_count}
</span>
)}
</button>
);

View File

@@ -0,0 +1,70 @@
import { useSiteBranding } from '../hooks/useSiteBranding';
import { useMediaQuery } from '../hooks/useTheme';
import type { FriendLink } from '../api/types';
function FooterSep() {
return <span className="site-footer__sep" aria-hidden>·</span>;
}
/** 站点页脚版权、Sitemap、友链、备案号 */
export default function SiteFooter() {
const { branding } = useSiteBranding();
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/';
return (
<footer className="site-footer">
<div className="site-footer__inner">
<div className="site-footer__meta">
<span className="site-footer__copy">
© {year} {branding.name}
</span>
{branding.slogan?.trim() && (
<>
<FooterSep />
<span className="site-footer__slogan">{branding.slogan.trim()}</span>
</>
)}
</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>
)}
</div>
</footer>
);
}
/**
* 手机端随内容滚动的页脚(放在 .page-wrap / .post-list-scroll 末尾)。
* 桌面端返回 null由 MainLayout 壳层贴底页脚负责。
*/
export function InFlowSiteFooter() {
const isMobile = useMediaQuery('(max-width: 768px)');
if (!isMobile) return null;
return <SiteFooter />;
}

View File

@@ -1,11 +1,12 @@
import { useRef, useEffect, useLayoutEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Inbox } from 'lucide-react';
import { Inbox, SearchX } from 'lucide-react';
import { Button } from '@/components/ui/button';
import PostListItem from './PostListItem';
import PostListSkeleton from './PostListSkeleton';
import FeedPagination from './FeedPagination';
import { InFlowSiteFooter } from './SiteFooter';
import { useAuth } from '../hooks/useAuth';
import { loginPath } from '../utils/authRedirect';
import type { PostItem } from '../api/types';
@@ -28,6 +29,12 @@ interface Props {
resetScrollKey?: number;
onScrollTopChange?: (top: number) => void;
onScrollRestored?: () => void;
/** 搜索关键词(用于空态文案) */
keyword?: string;
/** 当前板块 id0 表示全部 */
boardId?: number;
/** 当前板块名 */
boardName?: string;
}
export default function VirtualPostList({
@@ -45,6 +52,9 @@ export default function VirtualPostList({
resetScrollKey = 0,
onScrollTopChange,
onScrollRestored,
keyword = '',
boardId = 0,
boardName = '',
}: Props) {
const nav = useNavigate();
const { user } = useAuth();
@@ -58,7 +68,7 @@ export default function VirtualPostList({
const virtualizer = useVirtualizer({
count: posts.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 72,
estimateSize: () => 108,
overscan: 8,
measureElement:
typeof window !== 'undefined' && !navigator.userAgent.includes('Firefox')
@@ -69,6 +79,8 @@ export default function VirtualPostList({
const showEnd = !hasMore && !showPagination && posts.length > 0 && !loading;
const isInitialLoad = loading && posts.length === 0;
const isEmpty = !loading && posts.length === 0;
const isSearchEmpty = isEmpty && !!keyword.trim();
const composeTarget = boardId > 0 ? `/compose?board=${boardId}` : '/compose';
useLayoutEffect(() => {
if (resetScrollKey <= 0) return;
@@ -102,26 +114,56 @@ export default function VirtualPostList({
return () => el.removeEventListener('scroll', onScroll);
}, []);
const emptyActions = (
<div className="empty-feed-actions">
{isSearchEmpty ? (
<>
<Button type="button" size="sm" variant="outline" onClick={() => nav('/')}>
</Button>
<Button type="button" size="sm" onClick={() => nav(user ? composeTarget : loginPath(composeTarget))}>
{user ? '发帖' : '登录后发帖'}
</Button>
</>
) : (
<>
{boardId > 0 && (
<Button type="button" size="sm" variant="outline" onClick={() => nav('/')}>
</Button>
)}
{user ? (
<Button type="button" size="sm" onClick={() => nav(composeTarget)}>
{boardName ? `成为「${boardName}」第一帖` : '发第一帖'}
</Button>
) : (
<Button type="button" size="sm" onClick={() => nav(loginPath(composeTarget))}>
</Button>
)}
</>
)}
</div>
);
return (
<div className="post-list-scroll" ref={parentRef}>
{isInitialLoad ? (
<PostListSkeleton />
) : isEmpty ? (
<div className="empty-feed" role="status">
<Inbox className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />
<p></p>
<p className="empty-feed-hint"></p>
<div className="empty-feed-actions">
{user ? (
<Button type="button" size="sm" onClick={() => nav('/compose')}>
</Button>
) : (
<Button type="button" size="sm" onClick={() => nav(loginPath('/compose'))}>
</Button>
)}
</div>
{isSearchEmpty
? <SearchX className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />
: <Inbox className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />}
<p>{isSearchEmpty ? '没有匹配的帖子' : '暂无帖子'}</p>
<p className="empty-feed-hint">
{isSearchEmpty
? '试试更短的关键词,或浏览标签云 / 板块'
: boardName
? `${boardName}」还没有内容,来发第一篇吧`
: '换个板块看看,或发第一篇内容'}
</p>
{emptyActions}
</div>
) : (
<>
@@ -163,6 +205,7 @@ export default function VirtualPostList({
)}
</>
)}
<InFlowSiteFooter />
</div>
);
}

View File

@@ -39,17 +39,29 @@ export interface ButtonProps
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, loading, children, disabled, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
const classes = cn(buttonVariants({ variant, size, className }));
// asChild 时 Slot 只能有单一子元素,不能夹 loading 图标
if (asChild) {
return (
<Slot
className={classes}
ref={ref}
{...props}
>
{children}
</Slot>
);
}
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
<button
className={classes}
ref={ref}
disabled={disabled || loading}
{...props}
>
{loading ? <Loader2 className="animate-spin" /> : null}
{children}
</Comp>
</button>
);
},
);