移除旧版 HTML 模板与兼容层,并完善私信、举报、媒体存储与 SEO。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
12
frontend/src/components/AuthPageFallback.tsx
Normal file
12
frontend/src/components/AuthPageFallback.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
frontend/src/components/AuthPasswordInput.tsx
Normal file
30
frontend/src/components/AuthPasswordInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
142
frontend/src/components/CommentRevisionDialog.tsx
Normal file
142
frontend/src/components/CommentRevisionDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
86
frontend/src/components/ComposeMessageDialog.tsx
Normal file
86
frontend/src/components/ComposeMessageDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
19
frontend/src/components/FeaturedIcon.tsx
Normal file
19
frontend/src/components/FeaturedIcon.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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">
|
||||
|
||||
176
frontend/src/components/PostAuthorCard.tsx
Normal file
176
frontend/src/components/PostAuthorCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
70
frontend/src/components/SiteFooter.tsx
Normal file
70
frontend/src/components/SiteFooter.tsx
Normal 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 />;
|
||||
}
|
||||
@@ -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;
|
||||
/** 当前板块 id,0 表示全部 */
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user