feat: 新增站点公告系统与多项体验优化
此提交完成了以下核心功能与优化: 1. 新增站点公告全流程支持:包含公告模型、CRUD接口、管理后台页面、公告详情页与首页展示 2. 实现评论楼层化与回复嵌套:重构评论系统为树状结构,支持回复评论与层级限制 3. 添加用户在线状态追踪:异步更新用户最后活跃时间并做SQL限频 4. 优化用户体验:移动端浮动操作条、统一板块标签样式、用户页签到积分展示、注册即登录优化 5. 其他细节优化:SEO站点地图新增公告页、评论分页与统计、登录/注册表单增强等
This commit is contained in:
@@ -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