feat: 新增站点公告系统与多项体验优化
此提交完成了以下核心功能与优化: 1. 新增站点公告全流程支持:包含公告模型、CRUD接口、管理后台页面、公告详情页与首页展示 2. 实现评论楼层化与回复嵌套:重构评论系统为树状结构,支持回复评论与层级限制 3. 添加用户在线状态追踪:异步更新用户最后活跃时间并做SQL限频 4. 优化用户体验:移动端浮动操作条、统一板块标签样式、用户页签到积分展示、注册即登录优化 5. 其他细节优化:SEO站点地图新增公告页、评论分页与统计、登录/注册表单增强等
This commit is contained in:
@@ -1,30 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Trash2, MessagesSquare } from "lucide-react";
|
||||
import { apiCreateComment, apiDeleteComment, apiMe, type Comment, type User } from "@/lib/api";
|
||||
import { Trash2, MessagesSquare, ChevronRight, ChevronDown } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { apiCreateComment, apiDeleteComment, type CommentNode, type User } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
import Avatar from "./Avatar";
|
||||
import Pagination from "./Pagination";
|
||||
|
||||
interface CommentSectionProps {
|
||||
postId: string;
|
||||
comments: Comment[];
|
||||
comments: CommentNode[];
|
||||
total: number; // 楼层数(主评论数)→ 分页与楼层号计算
|
||||
totalComments: number; // 全部评论数(含回复)→ 头部徽标
|
||||
page: number;
|
||||
size: number;
|
||||
// SSR 直出的登录用户(layout 已取,避免客户端再请求一次 /api/me)
|
||||
user: User | null;
|
||||
}
|
||||
|
||||
export default function CommentSection({ postId, comments }: CommentSectionProps) {
|
||||
// 删除按钮的两段式确认:点一下进入确认态,3 秒未确认自动复原
|
||||
const CONFIRM_RESET_MS = 3000;
|
||||
|
||||
// 递归统计子树回复数(删除级联提示用)
|
||||
function countReplies(node: CommentNode): number {
|
||||
return node.replies?.reduce((acc, r) => acc + 1 + countReplies(r), 0) ?? 0;
|
||||
}
|
||||
|
||||
export default function CommentSection({
|
||||
postId,
|
||||
comments,
|
||||
total,
|
||||
totalComments,
|
||||
page,
|
||||
size,
|
||||
user,
|
||||
}: CommentSectionProps) {
|
||||
const router = useRouter();
|
||||
const [content, setContent] = useState("");
|
||||
// 内联回复目标:在该评论下方就地发子评论(同一时间只有一个回复框)
|
||||
const [replyTarget, setReplyTarget] = useState<{ id: number; nickname: string } | null>(null);
|
||||
const [replyContent, setReplyContent] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiMe()
|
||||
.then((res) => setUser(res.user || null))
|
||||
.catch(() => setUser(null));
|
||||
}, []);
|
||||
const [confirmingId, setConfirmingId] = useState<number | null>(null);
|
||||
// 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起)
|
||||
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set());
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -34,6 +58,7 @@ export default function CommentSection({ postId, comments }: CommentSectionProps
|
||||
const res = await apiCreateComment(postId, content);
|
||||
if (res.comment) {
|
||||
setContent("");
|
||||
// 新楼层在最后一页:刷新后由服务端直出最新分页
|
||||
router.refresh();
|
||||
} else if (res.error === "未登录") {
|
||||
router.push(`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`);
|
||||
@@ -47,8 +72,38 @@ export default function CommentSection({ postId, comments }: CommentSectionProps
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitReply = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!replyTarget || !replyContent.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await apiCreateComment(postId, replyContent, replyTarget.id);
|
||||
if (res.comment) {
|
||||
setReplyTarget(null);
|
||||
setReplyContent("");
|
||||
// 刷新后由 SSR 直出新树(子评论全量挂在本楼)
|
||||
router.refresh();
|
||||
} else if (res.error === "未登录") {
|
||||
router.push(`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`);
|
||||
} else {
|
||||
toast(res.error || "回复失败");
|
||||
}
|
||||
} catch {
|
||||
toast("网络错误,回复失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (commentId: number) => {
|
||||
if (!confirm("确定要删除这条评论吗?")) return;
|
||||
if (confirmingId !== commentId) {
|
||||
setConfirmingId(commentId);
|
||||
setTimeout(() => {
|
||||
setConfirmingId((cur) => (cur === commentId ? null : cur));
|
||||
}, CONFIRM_RESET_MS);
|
||||
return;
|
||||
}
|
||||
setConfirmingId(null);
|
||||
setDeletingId(commentId);
|
||||
try {
|
||||
const res = await apiDeleteComment(postId, commentId);
|
||||
@@ -64,26 +119,271 @@ export default function CommentSection({ postId, comments }: CommentSectionProps
|
||||
}
|
||||
};
|
||||
|
||||
const canDelete = (comment: Comment) =>
|
||||
// 引用功能已移除:与"回复"功能重叠,且会割裂讨论上下文(抖音风格无此功能)
|
||||
|
||||
const canDelete = (comment: CommentNode) =>
|
||||
user && (user.id === comment.user.id || user.role === "admin");
|
||||
|
||||
const openReply = (c: CommentNode) => {
|
||||
setReplyTarget({ id: c.id, nickname: c.user.nickname || c.user.username });
|
||||
setReplyContent("");
|
||||
};
|
||||
|
||||
// 切换某楼层子回复的展开/折叠
|
||||
const toggleFloor = (floorId: number) => {
|
||||
setExpandedFloors((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(floorId)) next.delete(floorId);
|
||||
else next.add(floorId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / size));
|
||||
|
||||
// 内联回复表单(出现在目标评论正下方)
|
||||
const inlineReplyForm = replyTarget && (
|
||||
<form onSubmit={handleSubmitReply} className="cmt-reply-form mt-3">
|
||||
<div className="text-[12px] mb-2" style={{ color: "var(--ink-3)" }}>
|
||||
回复 <span style={{ color: "var(--accent)" }}>@{replyTarget.nickname}</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={replyContent}
|
||||
onChange={(e) => setReplyContent(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.currentTarget.form?.requestSubmit();
|
||||
}
|
||||
}}
|
||||
rows={2}
|
||||
autoFocus
|
||||
className="field"
|
||||
aria-label={`回复 ${replyTarget.nickname}`}
|
||||
placeholder="友善回复..."
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3 mt-2">
|
||||
<span className="meta text-[11.5px]">支持 Markdown · Ctrl + Enter 发送</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
onClick={() => {
|
||||
setReplyTarget(null);
|
||||
setReplyContent("");
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" disabled={submitting || !replyContent.trim()} className="btn btn-primary btn-sm">
|
||||
{submitting ? "发送中..." : "回复"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
// 单条评论渲染(递归):depth 0 = 楼层主评论,>0 = 子回复
|
||||
// 主评论下方子回复默认折叠,点击"展开 X 条回复"展开
|
||||
// depth=1(回复楼层)不显示指向;depth≥2(回复子评论)显示"回复者 ▶ 被回复者"
|
||||
const renderComment = (node: CommentNode, depth: number, floor?: number, parent?: CommentNode) => {
|
||||
const c = node;
|
||||
const replyCount = countReplies(node);
|
||||
const isFloor = depth === 0;
|
||||
const isExpanded = isFloor ? expandedFloors.has(c.id) : true;
|
||||
const parentAuthor = parent?.user.nickname || parent?.user.username;
|
||||
|
||||
if (!isFloor) {
|
||||
// 子回复:紧凑行式布局(抖音风格)
|
||||
const isReplyToReply = depth >= 2;
|
||||
return (
|
||||
<div key={c.id} id={`comment-${c.id}`} className="cmt-reply-row scroll-mt-24">
|
||||
<div className="flex gap-2.5">
|
||||
<Link href={`/u/${c.user.id}`} className="shrink-0" aria-label={c.user.nickname || c.user.username}>
|
||||
<Avatar name={c.user.nickname || c.user.username} size={28} />
|
||||
</Link>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<Link href={`/u/${c.user.id}`} className="group shrink-0">
|
||||
<span className="cmt-reply-name group-hover:text-[var(--accent)] transition-colors">
|
||||
{c.user.nickname || c.user.username}
|
||||
</span>
|
||||
</Link>
|
||||
{isReplyToReply && parent && (
|
||||
<>
|
||||
<ChevronRight size={12} className="cmt-reply-arrow" aria-hidden="true" />
|
||||
<a href={`#comment-${parent.id}`} className="cmt-reply-target">
|
||||
{parentAuthor}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="prose-content prose-comment mt-0.5">
|
||||
<ReactMarkdown>{c.content}</ReactMarkdown>
|
||||
</div>
|
||||
{/* 底部行:时间 · 回复(左),删除(右) */}
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="meta text-[11.5px]">{new Date(c.created_at).toLocaleString("zh-CN")}</span>
|
||||
{user && (
|
||||
<button
|
||||
onClick={() => openReply(c)}
|
||||
className={`cmt-reply-btn transition-colors ${
|
||||
replyTarget?.id === c.id ? "!text-[var(--accent)] font-semibold" : ""
|
||||
}`}
|
||||
aria-label={`回复 ${c.user.nickname || c.user.username}`}
|
||||
title="回复"
|
||||
>
|
||||
回复
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{canDelete(c) && (
|
||||
<button
|
||||
onClick={() => handleDelete(c.id)}
|
||||
disabled={deletingId === c.id}
|
||||
className={`meta transition-colors disabled:opacity-50 p-1 ${
|
||||
confirmingId === c.id ? "!text-[var(--danger)] font-semibold" : "hover:text-[var(--danger)]"
|
||||
}`}
|
||||
aria-label={confirmingId === c.id ? "再次点击确认删除" : "删除评论"}
|
||||
title={confirmingId === c.id ? "再点一次确认删除" : "删除"}
|
||||
>
|
||||
{confirmingId === c.id ? (
|
||||
<span className="text-[11.5px] whitespace-nowrap">
|
||||
确认删除?{countReplies(c) > 0 && `(含 ${countReplies(c)} 条回复)`}
|
||||
</span>
|
||||
) : (
|
||||
<Trash2 size={14} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{replyTarget?.id === c.id && inlineReplyForm}
|
||||
</div>
|
||||
</div>
|
||||
{node.replies && node.replies.length > 0 && (
|
||||
<div className="cmt-children">
|
||||
{node.replies.map((r) => renderComment(r, depth + 1, undefined, node))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 主评论(楼层)
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
id={`comment-${c.id}`}
|
||||
className="py-5 border-t first:border-t-0 scroll-mt-24"
|
||||
style={{ borderColor: "var(--line)" }}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<Link href={`/u/${c.user.id}`} className="shrink-0 mt-0.5" aria-label={c.user.nickname || c.user.username}>
|
||||
<Avatar name={c.user.nickname || c.user.username} size={38} />
|
||||
</Link>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2.5 mb-1.5">
|
||||
<Link href={`/u/${c.user.id}`} className="group">
|
||||
<span className="font-semibold truncate group-hover:text-[var(--accent)] transition-colors text-sm" style={{ color: "var(--ink)" }}>
|
||||
{c.user.nickname || c.user.username}
|
||||
</span>
|
||||
</Link>
|
||||
{floor && (
|
||||
<a href={`#comment-${c.id}`} className="cmt-floor-label" aria-label={`楼层 ${floor}`}>
|
||||
#{floor}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="prose-content prose-comment">
|
||||
<ReactMarkdown>{c.content}</ReactMarkdown>
|
||||
</div>
|
||||
{/* 底部行:时间 · 回复(左),删除(右) */}
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="meta text-[11.5px]">{new Date(c.created_at).toLocaleString("zh-CN")}</span>
|
||||
{user && (
|
||||
<button
|
||||
onClick={() => openReply(c)}
|
||||
className={`cmt-reply-btn transition-colors ${
|
||||
replyTarget?.id === c.id ? "!text-[var(--accent)] font-semibold" : ""
|
||||
}`}
|
||||
aria-label={`回复 ${c.user.nickname || c.user.username}`}
|
||||
title="回复"
|
||||
>
|
||||
回复
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{canDelete(c) && (
|
||||
<button
|
||||
onClick={() => handleDelete(c.id)}
|
||||
disabled={deletingId === c.id}
|
||||
className={`meta transition-colors disabled:opacity-50 p-1 ${
|
||||
confirmingId === c.id ? "!text-[var(--danger)] font-semibold" : "hover:text-[var(--danger)]"
|
||||
}`}
|
||||
aria-label={confirmingId === c.id ? "再次点击确认删除" : "删除评论"}
|
||||
title={confirmingId === c.id ? "再点一次确认删除" : "删除"}
|
||||
>
|
||||
{confirmingId === c.id ? (
|
||||
<span className="text-[11.5px] whitespace-nowrap">
|
||||
确认删除?{countReplies(c) > 0 && `(含 ${countReplies(c)} 条回复)`}
|
||||
</span>
|
||||
) : (
|
||||
<Trash2 size={14} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{replyTarget?.id === c.id && inlineReplyForm}
|
||||
|
||||
{/* 折叠/展开子回复 */}
|
||||
{node.replies && node.replies.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFloor(c.id)}
|
||||
className="cmt-fold-btn"
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span>
|
||||
{isExpanded ? "收起回复" : `展开 ${replyCount} 条回复`}
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="cmt-replies-wrap">
|
||||
{node.replies.map((r) => renderComment(r, 1, undefined, node))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel p-6 sm:p-8 mt-6">
|
||||
<section className="panel p-6 sm:p-8 mt-6" aria-label="评论区">
|
||||
<div className="flex items-center gap-2.5 mb-6">
|
||||
<h2 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
|
||||
评论
|
||||
</h2>
|
||||
<span
|
||||
className="text-[12px] font-bold px-2 py-0.5 rounded-full"
|
||||
className="text-[12px] font-bold px-2 py-0.5 rounded-full tabular-nums"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
>
|
||||
{comments.length}
|
||||
{totalComments}
|
||||
</span>
|
||||
<span className="meta ml-auto text-[11.5px]">共 {totalPages} 页楼层</span>
|
||||
</div>
|
||||
|
||||
{user ? (
|
||||
<form onSubmit={handleSubmit} className="mb-8">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
id="comment-input"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -92,9 +392,10 @@ export default function CommentSection({ postId, comments }: CommentSectionProps
|
||||
e.currentTarget.form?.requestSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder="友善发言,说说你的看法..."
|
||||
placeholder="友善发言,发表一个新楼层..."
|
||||
rows={3}
|
||||
className="field"
|
||||
aria-label="写评论"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3 mt-2.5">
|
||||
<span className="meta text-[11.5px]">支持 Markdown · Ctrl + Enter 发送</span>
|
||||
@@ -111,52 +412,14 @@ export default function CommentSection({ postId, comments }: CommentSectionProps
|
||||
<span className="text-sm" style={{ color: "var(--ink-2)" }}>
|
||||
登录后参与讨论
|
||||
</span>
|
||||
<Link href="/login" className="btn btn-line btn-sm">
|
||||
<Link href={`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`} className="btn btn-line btn-sm">
|
||||
去登录
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
{comments.map((comment) => (
|
||||
<div
|
||||
key={comment.id}
|
||||
className="flex gap-3.5 py-5 border-t first:border-t-0"
|
||||
style={{ borderColor: "var(--line)" }}
|
||||
>
|
||||
<Link href={`/u/${comment.user.id}`} className="shrink-0 mt-0.5" aria-label={comment.user.nickname}>
|
||||
<Avatar name={comment.user.nickname || comment.user.username} size={38} />
|
||||
</Link>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-3 mb-1.5">
|
||||
<div className="flex items-center gap-2.5 min-w-0 flex-wrap">
|
||||
<Link href={`/u/${comment.user.id}`} className="group">
|
||||
<span
|
||||
className="text-sm font-semibold truncate group-hover:text-[var(--accent)] transition-colors"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{comment.user.nickname}
|
||||
</span>
|
||||
</Link>
|
||||
<span className="meta text-xs">{new Date(comment.created_at).toLocaleString("zh-CN")}</span>
|
||||
</div>
|
||||
{canDelete(comment) && (
|
||||
<button
|
||||
onClick={() => handleDelete(comment.id)}
|
||||
disabled={deletingId === comment.id}
|
||||
className="meta hover:text-[var(--danger)] transition-colors disabled:opacity-50 p-1"
|
||||
aria-label="删除评论"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[15px] leading-relaxed" style={{ color: "var(--ink)" }}>
|
||||
{comment.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{comments.map((node, i) => renderComment(node, 0, (page - 1) * size + i + 1))}
|
||||
{comments.length === 0 && (
|
||||
<div className="text-center py-14">
|
||||
<MessagesSquare size={28} className="mx-auto mb-3" style={{ color: "var(--ink-3)" }} />
|
||||
@@ -167,6 +430,8 @@ export default function CommentSection({ postId, comments }: CommentSectionProps
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Pagination total={total} page={page} size={size} basePath={`/post/${postId}`} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,8 +18,10 @@ import {
|
||||
Home,
|
||||
User as UserIcon,
|
||||
ChevronDown,
|
||||
Megaphone,
|
||||
Layers,
|
||||
} from "lucide-react";
|
||||
import { apiLogout, apiMe, type User } from "@/lib/api";
|
||||
import { apiLogout, apiMe, type User, type Board } from "@/lib/api";
|
||||
import { hasAuthCookieHint } from "@/lib/cookies";
|
||||
import NotificationBell from "./NotificationBell";
|
||||
import Avatar from "./Avatar";
|
||||
@@ -28,10 +30,12 @@ export default function Header({
|
||||
initialUser,
|
||||
initialUnread = 0,
|
||||
initialTheme = "light",
|
||||
boards = [],
|
||||
}: {
|
||||
initialUser: User | null;
|
||||
initialUnread?: number;
|
||||
initialTheme?: "light" | "dark";
|
||||
boards?: Board[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -167,44 +171,46 @@ export default function Header({
|
||||
<NavPill href="/" active={pathname === "/"} icon={<Home size={14} />}>
|
||||
首页
|
||||
</NavPill>
|
||||
<BoardsMenu boards={boards} />
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* 中组:搜索居中 */}
|
||||
<form onSubmit={handleSearch} className="hidden lg:flex flex-1 justify-center">
|
||||
<div className="relative w-full max-w-[380px] focus-within:max-w-[440px] transition-[max-width] duration-200">
|
||||
<Search
|
||||
size={15}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
/>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
placeholder="搜索帖子..."
|
||||
aria-label="搜索帖子"
|
||||
className="w-full pl-10 pr-10 py-2.5 text-sm rounded-full"
|
||||
style={{
|
||||
background: "var(--panel)",
|
||||
color: "var(--ink)",
|
||||
border: "1px solid var(--line)",
|
||||
}}
|
||||
/>
|
||||
<kbd
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-[11px] w-5 h-5 flex items-center justify-center rounded-md pointer-events-none"
|
||||
style={{ color: "var(--ink-3)", border: "1px solid var(--line-2)", background: "var(--bg)" }}
|
||||
>
|
||||
/
|
||||
</kbd>
|
||||
</div>
|
||||
</form>
|
||||
{/* 中部留白:为后续主导航菜单项预留位置 */}
|
||||
<div className="hidden lg:block flex-1" aria-hidden />
|
||||
|
||||
{/* 右侧操作区:桌面/移动共用铃铛(仅挂载一次,避免重复轮询),
|
||||
桌面额外显示主题/发帖/用户菜单,移动端收进汉堡抽屉 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 右侧操作区:搜索靠右停靠 + 桌面/移动共用铃铛(仅挂载一次,避免重复轮询),
|
||||
桌面额外显示主题/发帖/用户菜单,移动端搜索收进汉堡抽屉 */}
|
||||
<div className="flex items-center gap-2 lg:gap-3">
|
||||
<form onSubmit={handleSearch} className="hidden lg:block">
|
||||
<div className="relative w-[220px] xl:w-[260px] focus-within:w-[268px] xl:focus-within:w-[300px] transition-[width] duration-200">
|
||||
<Search
|
||||
size={15}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
/>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
placeholder="搜索帖子..."
|
||||
aria-label="搜索帖子"
|
||||
className="w-full pl-10 pr-10 py-2.5 text-sm rounded-full"
|
||||
style={{
|
||||
background: "var(--panel)",
|
||||
color: "var(--ink)",
|
||||
border: "1px solid var(--line)",
|
||||
}}
|
||||
/>
|
||||
<kbd
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-[11px] w-5 h-5 flex items-center justify-center rounded-md pointer-events-none"
|
||||
style={{ color: "var(--ink-3)", border: "1px solid var(--line-2)", background: "var(--bg)" }}
|
||||
>
|
||||
/
|
||||
</kbd>
|
||||
</div>
|
||||
</form>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className={`${iconBtn} hidden lg:inline-flex`}
|
||||
@@ -271,9 +277,47 @@ export default function Header({
|
||||
</MobileLink>
|
||||
)}
|
||||
{user && <MobileLink href="/settings">账号设置</MobileLink>}
|
||||
{user?.role === "admin" && (
|
||||
<MobileLink href="/admin/announcements">
|
||||
<span className="inline-flex items-center gap-2"><Megaphone size={15} /> 公告管理</span>
|
||||
</MobileLink>
|
||||
)}
|
||||
{!user && <MobileLink href="/login">登录</MobileLink>}
|
||||
{!user && <MobileLink href="/register">注册</MobileLink>}
|
||||
</div>
|
||||
{/* 全站板块入口:手机用户在任意页面都能切板块,不必回首页 */}
|
||||
{boards.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t" style={{ borderColor: "var(--line)" }}>
|
||||
<p className="px-3 mb-1.5 text-[11.5px] font-bold tracking-[0.08em] uppercase" style={{ color: "var(--ink-3)" }}>
|
||||
板块
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{boards.map((b, i) => {
|
||||
const active = pathname === `/board/${b.id}`;
|
||||
return (
|
||||
<Link
|
||||
key={b.id}
|
||||
href={`/board/${b.id}`}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className="flex items-center gap-2 px-3 py-2.5 rounded-xl text-[13.5px] font-semibold transition-colors"
|
||||
style={{
|
||||
color: active ? "var(--accent)" : "var(--ink)",
|
||||
background: active ? "var(--accent-soft)" : "transparent",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`av-${Math.max(0, b.color_index ?? i) % 8} w-6 h-6 rounded-lg flex items-center justify-center text-[11px] font-bold shrink-0`}
|
||||
>
|
||||
{b.name.slice(0, 1)}
|
||||
</span>
|
||||
<span className="truncate">{b.name}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="pt-3 mt-2 border-t flex items-center justify-between" style={{ borderColor: "var(--line)" }}>
|
||||
<button onClick={toggleTheme} className="btn btn-line btn-sm">
|
||||
{theme === "dark" ? <Sun size={14} /> : <Moon size={14} />}
|
||||
@@ -318,6 +362,96 @@ function NavPill({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 板块下拉菜单(桌面端):全站任意页面的 Header 都能直达板块,
|
||||
* 替代原先只有首页左栏才有板块导航的布局局限
|
||||
*/
|
||||
function BoardsMenu({ boards }: { boards: Board[] }) {
|
||||
const pathname = usePathname();
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (boards.length === 0) return null;
|
||||
const active = pathname.startsWith("/board/");
|
||||
|
||||
return (
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[13.5px] font-semibold transition-colors"
|
||||
style={
|
||||
active || open
|
||||
? { background: "var(--accent-soft)", color: "var(--accent)" }
|
||||
: { color: "var(--ink-2)" }
|
||||
}
|
||||
>
|
||||
<Layers size={14} />
|
||||
板块
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className="transition-transform duration-200"
|
||||
style={{ transform: open ? "rotate(180deg)" : "none" }}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute left-0 mt-2.5 w-60 max-h-[60vh] overflow-y-auto p-1.5 z-50 j13-toast-in"
|
||||
style={{
|
||||
background: "var(--panel)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
}}
|
||||
>
|
||||
{boards.map((b, i) => {
|
||||
const isActive = pathname === `/board/${b.id}`;
|
||||
return (
|
||||
<Link
|
||||
key={b.id}
|
||||
href={`/board/${b.id}`}
|
||||
onClick={() => setOpen(false)}
|
||||
role="menuitem"
|
||||
className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-[13.5px] font-semibold transition-colors hover:bg-[var(--panel-2)]"
|
||||
style={{
|
||||
color: isActive ? "var(--accent)" : "var(--ink)",
|
||||
background: isActive ? "var(--nav-active-bg)" : undefined,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`av-${Math.max(0, b.color_index ?? i) % 8} w-7 h-7 rounded-lg flex items-center justify-center text-[12px] font-bold shrink-0`}
|
||||
>
|
||||
{b.name.slice(0, 1)}
|
||||
</span>
|
||||
<span className="flex-1 truncate">{b.name}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileLink({
|
||||
href,
|
||||
active,
|
||||
@@ -428,6 +562,13 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
|
||||
<Settings size={15} style={{ color: "var(--ink-3)" }} /> 账号设置
|
||||
</Link>
|
||||
</div>
|
||||
{user.role === "admin" && (
|
||||
<div className="p-1.5" style={{ borderTop: "1px solid var(--line)" }}>
|
||||
<Link href="/admin/announcements" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
|
||||
<Megaphone size={15} style={{ color: "var(--accent)" }} /> 公告管理
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-1.5" style={{ borderTop: "1px solid var(--line)" }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
|
||||
82
frontend/components/MobilePostBar.tsx
Normal file
82
frontend/components/MobilePostBar.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowUp, MessageSquare } from "lucide-react";
|
||||
import LikeButton from "./LikeButton";
|
||||
|
||||
/**
|
||||
* 帖子详情移动端浮动操作条(lg 以下显示):
|
||||
* 长文读到中途也能点赞 / 跳评论框 / 回顶部,不必滚到文末。
|
||||
*/
|
||||
export default function MobilePostBar({
|
||||
postId,
|
||||
liked,
|
||||
likeCount,
|
||||
}: {
|
||||
postId: string;
|
||||
liked: boolean;
|
||||
likeCount: number;
|
||||
}) {
|
||||
const [showTop, setShowTop] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setShowTop(window.scrollY > 400);
|
||||
onScroll();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
const scrollToComments = () => {
|
||||
document.getElementById("comment-input")?.scrollIntoView({
|
||||
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
|
||||
block: "center",
|
||||
});
|
||||
};
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="lg:hidden fixed bottom-4 left-1/2 -translate-x-1/2 z-40" role="navigation" aria-label="阅读操作">
|
||||
<div
|
||||
className="flex items-center gap-0.5 rounded-full border px-1.5 py-1.5"
|
||||
style={{
|
||||
background: "color-mix(in srgb, var(--panel) 90%, transparent)",
|
||||
borderColor: "var(--line-2)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
backdropFilter: "blur(14px)",
|
||||
WebkitBackdropFilter: "blur(14px)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToComments}
|
||||
className="w-11 h-11 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)]"
|
||||
style={{ color: "var(--ink-2)" }}
|
||||
aria-label="跳到评论框"
|
||||
>
|
||||
<MessageSquare size={18} />
|
||||
</button>
|
||||
{/* 放大点赞按钮的触控区域(样式见 globals.css .j13-bar-like) */}
|
||||
<div className="j13-bar-like">
|
||||
<LikeButton postId={postId} liked={liked} likeCount={likeCount} variant="compact" />
|
||||
</div>
|
||||
{showTop && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToTop}
|
||||
className="w-11 h-11 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)] j13-toast-in"
|
||||
style={{ color: "var(--ink-2)" }}
|
||||
aria-label="回到顶部"
|
||||
>
|
||||
<ArrowUp size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -94,6 +94,7 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
|
||||
|
||||
const actionText = (n: NotificationItem) => {
|
||||
if (n.type === "comment") return "评论了你的帖子";
|
||||
if (n.type === "reply") return "回复了你的评论";
|
||||
return "点赞了你的帖子";
|
||||
};
|
||||
|
||||
|
||||
@@ -75,10 +75,7 @@ export default function PostRow({
|
||||
}`}
|
||||
>
|
||||
{boardHref && (
|
||||
<Link
|
||||
href={`/board/${post.board.id}`}
|
||||
className={`av-${post.board.id % 8} rounded-md px-1.5 py-px text-[11px] font-bold leading-[1.5] transition-opacity hover:opacity-85`}
|
||||
>
|
||||
<Link href={`/board/${post.board.id}`} className="j13-board-tag">
|
||||
{post.board.name}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -4,100 +4,128 @@ import {
|
||||
Users,
|
||||
MessageCircle,
|
||||
Heart,
|
||||
MessageSquareText,
|
||||
UserPlus,
|
||||
Link2,
|
||||
Megaphone,
|
||||
BarChart3,
|
||||
PenLine,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
PostListItem,
|
||||
ActiveUser,
|
||||
OverviewStats,
|
||||
LatestComment,
|
||||
AnnouncementSummary,
|
||||
NewUser,
|
||||
CheckinStatus,
|
||||
} from "@/lib/api";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import CheckinCard from "./CheckinCard";
|
||||
import { FRIEND_LINKS } from "@/lib/friendLinks";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
import { formatCompact, formatShortDate } from "@/lib/format";
|
||||
|
||||
/* ---------- 品牌卡 ---------- */
|
||||
/* ---------- 站点数据(2×2 极简统计表) ---------- */
|
||||
|
||||
function BrandCard({ stats }: { stats: OverviewStats }) {
|
||||
const items: { num: number; label: string }[] = [
|
||||
{ num: stats.posts, label: "帖子" },
|
||||
{ num: stats.comments, label: "回复" },
|
||||
{ num: stats.users, label: "用户" },
|
||||
];
|
||||
function StatCell({
|
||||
index,
|
||||
label,
|
||||
value,
|
||||
foot,
|
||||
}: {
|
||||
index: number;
|
||||
label: string;
|
||||
value: string;
|
||||
foot: string;
|
||||
}) {
|
||||
const rightCol = index % 2 === 1;
|
||||
const bottomRow = index >= 2;
|
||||
return (
|
||||
<section className="j13-module text-center" aria-label="姜十三论坛">
|
||||
<p className="text-[16.5px] font-extrabold tracking-wide" style={{ color: "var(--accent)" }}>
|
||||
姜十三论坛
|
||||
<div
|
||||
className="min-w-0 py-3.5"
|
||||
style={{
|
||||
borderLeft: rightCol ? "1px solid var(--line)" : undefined,
|
||||
borderTop: bottomRow ? "1px solid var(--line)" : undefined,
|
||||
paddingLeft: rightCol ? 14 : 0,
|
||||
paddingRight: rightCol ? 0 : 14,
|
||||
}}
|
||||
>
|
||||
<p className="text-[12px]" style={{ color: "var(--ink-3)" }}>
|
||||
{label}
|
||||
</p>
|
||||
<p className="meta mt-1 text-[12px]">分享技术、讨论问题、交流心得</p>
|
||||
<div
|
||||
className="mt-3.5 pt-3.5 grid grid-cols-3"
|
||||
style={{ borderTop: "1px solid var(--line)" }}
|
||||
<p
|
||||
className="mt-1.5 text-[20px] font-semibold leading-none tracking-tight tabular-nums"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{items.map((it) => (
|
||||
<div key={it.label}>
|
||||
<p
|
||||
className="text-[19px] font-extrabold tabular-nums leading-none"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
{it.num}
|
||||
</p>
|
||||
<p className="meta mt-1.5 text-[11.5px]">{it.label}</p>
|
||||
</div>
|
||||
))}
|
||||
{value}
|
||||
</p>
|
||||
<p className="mt-2 text-[11px] tabular-nums" style={{ color: "var(--ink-3)" }}>
|
||||
{foot}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PulseCard({ stats }: { stats: OverviewStats }) {
|
||||
return (
|
||||
<section className="j13-module" aria-label="站点数据">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="j13-module-title">
|
||||
<BarChart3 size={15} style={{ color: "var(--accent)" }} /> 站点数据
|
||||
</p>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 text-[11px] font-normal normal-case tracking-normal"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
title="近 5 分钟在线"
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full" style={{ background: "var(--ok)" }} />
|
||||
实时
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2">
|
||||
<StatCell index={0} label="注册用户" value={formatCompact(stats.users)} foot={`+${stats.today_users} 今日`} />
|
||||
<StatCell index={1} label="话题总数" value={formatCompact(stats.posts)} foot={`+${stats.today_posts} 今日`} />
|
||||
<StatCell index={2} label="回复总数" value={formatCompact(stats.comments)} foot={`+${stats.today_comments} 今日`} />
|
||||
<StatCell index={3} label="在线用户" value={formatCompact(stats.online)} foot={`峰值 ${formatCompact(stats.peak_online)}`} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- 最新评论 ---------- */
|
||||
/* ---------- 站点公告 ---------- */
|
||||
|
||||
function LatestComments({ comments }: { comments: LatestComment[] }) {
|
||||
if (comments.length === 0) return null;
|
||||
function Announcements({ items }: { items: AnnouncementSummary[] }) {
|
||||
return (
|
||||
<section className="j13-module" aria-label="最新评论">
|
||||
<p className="j13-module-title mb-1">
|
||||
<MessageSquareText size={15} style={{ color: "var(--accent)" }} /> 最新评论
|
||||
<section className="j13-module" aria-label="站点公告">
|
||||
<p className="j13-module-title mb-2">
|
||||
<Megaphone size={15} style={{ color: "var(--accent)" }} /> 站点公告
|
||||
</p>
|
||||
<ul>
|
||||
{comments.map((c) => {
|
||||
const name = c.user.nickname || c.user.username;
|
||||
return (
|
||||
<li key={c.id}>
|
||||
<Link href={`/post/${c.post.id}`} className="j13-raillink items-start">
|
||||
<Avatar name={name} src={c.user.avatar} size={28} className="mt-px" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-baseline gap-1.5">
|
||||
<span
|
||||
className="shrink-0 max-w-[84px] truncate text-[12.5px] font-semibold"
|
||||
style={{ color: "var(--ink-2)" }}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-[13px]"
|
||||
style={{ color: "var(--ink-2)" }}
|
||||
>
|
||||
{c.content}
|
||||
</span>
|
||||
</span>
|
||||
<span className="meta mt-0.5 block truncate text-[11.5px]">
|
||||
{c.post.title}
|
||||
{items.length === 0 ? (
|
||||
<p className="meta px-1 py-1.5">暂无公告</p>
|
||||
) : (
|
||||
<ul>
|
||||
{items.map((a) => (
|
||||
<li key={a.id}>
|
||||
<Link
|
||||
href={`/announcement/${a.id}`}
|
||||
className="group block -mx-1.5 rounded-lg px-1.5 py-1.5 transition-colors hover:bg-[var(--panel-2)]"
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
|
||||
<span className="meta shrink-0 text-[11px] tabular-nums">
|
||||
{formatShortDate(a.created_at)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="meta mt-px shrink-0 text-[11px]">
|
||||
{formatRelative(c.created_at)}
|
||||
<span
|
||||
className="mt-1.5 block truncate text-[13px] leading-snug transition-colors group-hover:text-[var(--accent)]"
|
||||
style={{ color: "var(--ink)" }}
|
||||
title={a.title}
|
||||
>
|
||||
{a.title}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -116,11 +144,11 @@ function HotRanking({ posts }: { posts: PostListItem[] }) {
|
||||
<ol className="j13-ranklist">
|
||||
{posts.map((p, i) => (
|
||||
<li key={p.id}>
|
||||
<Link href={`/post/${p.id}`} className="j13-rank" title={p.title}>
|
||||
<Link href={`/post/${p.id}`} className="j13-rank group" title={p.title}>
|
||||
<span className="j13-rank-no">{i + 1}</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className="block text-[13.5px] font-semibold leading-snug line-clamp-2"
|
||||
className="block truncate text-[13px] font-normal leading-snug transition-colors group-hover:text-[var(--accent)]"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{p.title}
|
||||
@@ -132,7 +160,6 @@ function HotRanking({ posts }: { posts: PostListItem[] }) {
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Heart size={11.5} /> {p.like_count}
|
||||
</span>
|
||||
<span className="truncate">{p.board.name}</span>
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
@@ -179,30 +206,56 @@ function NewUsers({ users }: { users: NewUser[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- 活跃用户 ---------- */
|
||||
/* ---------- 活跃成员(本周发帖 / 回复) ---------- */
|
||||
|
||||
function ActiveUsers({ users }: { users: ActiveUser[] }) {
|
||||
return (
|
||||
<section className="j13-module" aria-label="活跃成员">
|
||||
<p className="j13-module-title mb-3">
|
||||
<section className="j13-module" aria-label="本周活跃成员">
|
||||
<p className="j13-module-title mb-2">
|
||||
<Users size={15} style={{ color: "var(--accent)" }} /> 活跃成员
|
||||
<span className="ml-auto font-normal normal-case tracking-normal text-[11px]" style={{ color: "var(--ink-3)" }}>
|
||||
本周
|
||||
</span>
|
||||
</p>
|
||||
{users.length === 0 ? (
|
||||
<p className="meta px-1.5 py-2">近 14 天还没有人互动</p>
|
||||
<p className="meta px-1.5 py-2">本周还没有人互动</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{users.map((u) => (
|
||||
<li key={u.id}>
|
||||
<Link href={`/u/${u.id}`} className="j13-raillink">
|
||||
<Avatar name={u.nickname || u.username} src={u.avatar} size={28} />
|
||||
<span className="flex-1 truncate text-[13.5px]">{u.nickname || u.username}</span>
|
||||
<span className="meta text-[11.5px]">{u.activity_count} 次</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
<ul className="space-y-0.5">
|
||||
{users.map((u) => {
|
||||
const name = u.nickname || u.username;
|
||||
return (
|
||||
<li key={u.id}>
|
||||
<Link href={`/u/${u.id}`} className="j13-raillink" title={name}>
|
||||
<Avatar name={name} src={u.avatar} size={30} />
|
||||
<span className="flex-1 min-w-0">
|
||||
<span
|
||||
className="block truncate text-[13px] font-semibold leading-tight"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
<span className="mt-px flex items-center gap-2.5 text-[11px] meta">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<PenLine size={10.5} /> 发帖 {u.post_count}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MessageCircle size={10.5} /> 回复 {u.comment_count}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="shrink-0 text-[13px] font-extrabold tabular-nums"
|
||||
style={{ color: "var(--accent)" }}
|
||||
aria-label={`本周共 ${u.post_count + u.comment_count} 次互动`}
|
||||
>
|
||||
{u.post_count + u.comment_count}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<p className="meta mt-3 px-1 text-[11px]">按近 14 天发帖与评论计数</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -250,7 +303,7 @@ type PanelsProps = {
|
||||
stats: OverviewStats;
|
||||
hot: PostListItem[];
|
||||
activeUsers: ActiveUser[];
|
||||
latestComments: LatestComment[];
|
||||
announcements: AnnouncementSummary[];
|
||||
newUsers: NewUser[];
|
||||
checkin?: CheckinStatus | null;
|
||||
};
|
||||
@@ -259,16 +312,16 @@ export function CommunityPanels({
|
||||
stats,
|
||||
hot,
|
||||
activeUsers,
|
||||
latestComments,
|
||||
announcements,
|
||||
newUsers,
|
||||
checkin,
|
||||
}: PanelsProps) {
|
||||
return (
|
||||
<aside className="hidden w-[280px] shrink-0 lg:block xl:w-[300px]" aria-label="社区面板">
|
||||
<div className="sticky top-24 space-y-4">
|
||||
<BrandCard stats={stats} />
|
||||
<PulseCard stats={stats} />
|
||||
<Announcements items={announcements} />
|
||||
<CheckinCard initial={checkin} />
|
||||
<LatestComments comments={latestComments} />
|
||||
<HotRanking posts={hot} />
|
||||
<NewUsers users={newUsers} />
|
||||
<ActiveUsers users={activeUsers} />
|
||||
@@ -281,14 +334,14 @@ export function CommunityPanels({
|
||||
export function CommunityPanelsMobile({
|
||||
hot,
|
||||
activeUsers,
|
||||
latestComments,
|
||||
announcements,
|
||||
newUsers,
|
||||
checkin,
|
||||
}: Omit<PanelsProps, "stats">) {
|
||||
return (
|
||||
<div className="space-y-4 lg:hidden" aria-label="社区面板">
|
||||
<Announcements items={announcements} />
|
||||
<CheckinCard initial={checkin} />
|
||||
<LatestComments comments={latestComments} />
|
||||
<HotRanking posts={hot} />
|
||||
<NewUsers users={newUsers} />
|
||||
<ActiveUsers users={activeUsers} />
|
||||
|
||||
Reference in New Issue
Block a user