Files
jiang13-bbs/frontend/components/home/CommunityPanels.tsx
freefire 24c6dea38c feat: 完成头像上传、站点主题配置与UI优化
- 新增附件模型与上传服务,拆分头像上传/管理逻辑
- 重构用户资料接口,移除直接修改头像字段,改用专用上传接口
- 添加站点公开设置API与管理员外观配置页
- 全局替换hover背景为accent-soft,优化交互一致性
- 完善头像组件,支持图片加载失败回退与src更新重置
- 新增用户状态事件总线,解决同页头像更新不及时问题
- 调整依赖包顺序与CI依赖版本
- 重构板块列表接口,新增帖子计数
- 迁移账号设置到用户主页tab,合并重复代码
2026-09-15 01:06:12 +08:00

359 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Link from "next/link";
import {
Flame,
Users,
MessageCircle,
Heart,
UserPlus,
Link2,
Megaphone,
BarChart3,
PenLine,
Activity,
TrendingUp,
} from "lucide-react";
import type {
PostListItem,
ActiveUser,
OverviewStats,
AnnouncementSummary,
NewUser,
CheckinStatus,
} from "@/lib/api";
import Avatar from "@/components/Avatar";
import { PulseCheckin } from "./CheckinCard";
import { FRIEND_LINKS } from "@/lib/friendLinks";
import { formatCompact, formatShortDate } from "@/lib/format";
/* ---------- 站点数据(2×2 bento 统计瓦片) ---------- */
function PulseCard({ stats, checkin }: { stats: OverviewStats; checkin?: CheckinStatus | null }) {
const cells = [
{
icon: <Users size={12.5} />,
label: "注册用户",
value: formatCompact(stats.users),
foot: `+${formatCompact(stats.today_users)} 今日`,
up: true,
},
{
icon: <PenLine size={12.5} />,
label: "话题总数",
value: formatCompact(stats.posts),
foot: `+${formatCompact(stats.today_posts)} 今日`,
up: true,
},
{
icon: <MessageCircle size={12.5} />,
label: "回复总数",
value: formatCompact(stats.comments),
foot: `+${formatCompact(stats.today_comments)} 今日`,
up: true,
},
{
icon: <Activity size={12.5} />,
label: "在线用户",
value: formatCompact(stats.online),
foot: `峰值 ${formatCompact(stats.peak_online)}`,
up: false,
},
];
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="j13-pulse-dot" aria-hidden="true" />
实时
</span>
</div>
<div className="j13-pulse mt-3">
{cells.map((c) => (
<div key={c.label} className="j13-pulse-cell">
<p className="j13-pulse-lab" style={{ color: "var(--ink-3)" }}>
<span style={{ color: "var(--accent)" }} aria-hidden="true">
{c.icon}
</span>
{c.label}
</p>
<p className="j13-pulse-num">{c.value}</p>
<p className={`j13-pulse-foot${c.up ? " is-up" : ""}`}>
{c.up && <TrendingUp size={10.5} aria-hidden="true" />}
{c.foot}
</p>
</div>
))}
</div>
<div className="j13-pulse-checkin">
<PulseCheckin initial={checkin} />
</div>
</section>
);
}
/* ---------- 站点公告 ---------- */
function Announcements({ items }: { items: AnnouncementSummary[] }) {
return (
<section className="j13-module" aria-label="站点公告">
<p className="j13-module-title mb-2">
<Megaphone size={15} style={{ color: "var(--accent)" }} /> 站点公告
</p>
{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(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
>
<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="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>
)}
</section>
);
}
/* ---------- 热门榜 ---------- */
function HotRanking({ posts }: { posts: PostListItem[] }) {
return (
<section className="j13-module" aria-label="热门讨论">
<p className="j13-module-title mb-1.5">
<Flame size={15} style={{ color: "var(--clay)" }} /> 热门讨论
</p>
{posts.length === 0 ? (
<p className="meta px-1.5 py-3">还没有热门讨论</p>
) : (
<ol className="j13-ranklist">
{posts.map((p, i) => (
<li key={p.id}>
<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 truncate text-[13px] font-normal leading-snug transition-colors group-hover:text-[var(--accent)]"
style={{ color: "var(--ink)" }}
>
{p.title}
</span>
<span className="mt-1 flex items-center gap-2.5 meta text-[11.5px]">
<span className="inline-flex items-center gap-1">
<MessageCircle size={11.5} /> {p.comment_count}
</span>
<span className="inline-flex items-center gap-1">
<Heart size={11.5} /> {p.like_count}
</span>
</span>
</span>
</Link>
</li>
))}
</ol>
)}
</section>
);
}
/* ---------- 最新注册 ---------- */
function NewUsers({ users }: { users: NewUser[] }) {
if (users.length === 0) return null;
return (
<section className="j13-module" aria-label="最新注册">
<p className="j13-module-title mb-3">
<UserPlus size={15} style={{ color: "var(--accent)" }} /> 最新注册
</p>
<ul className="grid grid-cols-4 gap-y-3">
{users.map((u) => {
const name = u.nickname || u.username;
return (
<li key={u.id}>
<Link
href={`/u/${u.id}`}
className="flex flex-col items-center gap-1.5 rounded-lg px-1 py-1 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
>
<Avatar name={name} src={u.avatar} size={38} />
<span
className="w-full truncate text-center text-[11px]"
style={{ color: "var(--ink-2)" }}
title={name}
>
{name}
</span>
</Link>
</li>
);
})}
</ul>
</section>
);
}
/* ---------- 活跃成员(本周发帖 / 回复) ---------- */
function ActiveUsers({ users }: { users: ActiveUser[] }) {
return (
<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">本周还没有人互动</p>
) : (
<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>
)}
</section>
);
}
/* ---------- 友情链接 ---------- */
function FriendLinks() {
return (
<section className="j13-module" aria-label="友情链接">
<p className="j13-module-title mb-3">
<Link2 size={15} style={{ color: "var(--accent)" }} /> 友情链接
<Link
href="/compose"
className="meta ml-auto text-[12px] font-normal transition-colors hover:text-[var(--accent)]"
>
申请
</Link>
</p>
<div className="flex flex-wrap gap-2">
{FRIEND_LINKS.map((l) =>
l.href.startsWith("http") ? (
<a
key={l.name}
href={l.href}
target="_blank"
rel="noopener noreferrer"
className="j13-chip"
>
{l.name}
</a>
) : (
<span key={l.name} className="j13-chip" title="友链待配置">
{l.name}
</span>
)
)}
</div>
</section>
);
}
/* ---------- 桌面 / 移动两种装配 ---------- */
type PanelsProps = {
stats: OverviewStats;
hot: PostListItem[];
activeUsers: ActiveUser[];
announcements: AnnouncementSummary[];
newUsers: NewUser[];
checkin?: CheckinStatus | null;
};
export function CommunityPanels({
stats,
hot,
activeUsers,
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">
<PulseCard stats={stats} checkin={checkin} />
<Announcements items={announcements} />
<HotRanking posts={hot} />
<NewUsers users={newUsers} />
<ActiveUsers users={activeUsers} />
<FriendLinks />
</div>
</aside>
);
}
export function CommunityPanelsMobile({
stats,
hot,
activeUsers,
announcements,
newUsers,
checkin,
}: PanelsProps) {
return (
<div className="space-y-4 lg:hidden" aria-label="社区面板">
<PulseCard stats={stats} checkin={checkin} />
<Announcements items={announcements} />
<HotRanking posts={hot} />
<NewUsers users={newUsers} />
<ActiveUsers users={activeUsers} />
<FriendLinks />
</div>
);
}