refactor(frontend): 重构站点数据面板与签到组件

将原有的站点统计卡片重构为bento布局样式,把签到组件内嵌到站点数据面板中,统一UI风格并简化代码结构
This commit is contained in:
2026-09-14 01:15:59 +08:00
parent 996a870015
commit e8882664fb
4 changed files with 202 additions and 117 deletions

View File

@@ -605,6 +605,101 @@ h1, h2, h3, h4 {
white-space: nowrap; white-space: nowrap;
} }
/* 站点数据 bento 统计瓦片 */
.j13-pulse {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.j13-pulse-cell {
min-width: 0;
padding: 11px 12px 10px;
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: var(--r-sm);
}
.j13-pulse-lab {
display: flex;
align-items: center;
gap: 5px;
font-size: 11.5px;
color: var(--ink-3);
white-space: nowrap;
}
.j13-pulse-num {
margin-top: 6px;
font-size: 21px;
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.015em;
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.j13-pulse-foot {
display: flex;
align-items: center;
gap: 3px;
margin-top: 6px;
font-size: 10.5px;
line-height: 1;
color: var(--ink-3);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.j13-pulse-foot.is-up {
color: var(--ok);
font-weight: 600;
}
.j13-pulse-dot {
position: relative;
width: 6px;
height: 6px;
border-radius: 9999px;
background: var(--ok);
flex-shrink: 0;
}
.j13-pulse-dot::after {
content: "";
position: absolute;
inset: -3px;
border-radius: 9999px;
background: var(--ok);
opacity: 0.4;
animation: j13-ping 1.8s cubic-bezier(0, 0, 0.2, 1) infinite;
}
@keyframes j13-ping {
0% { transform: scale(0.6); opacity: 0.5; }
70%, 100% { transform: scale(1.9); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.j13-pulse-dot::after {
animation: none;
opacity: 0.25;
}
}
/* 卡内签到条:分隔线 + 紧凑按钮 */
.j13-pulse-checkin {
margin-top: 10px;
padding-top: 11px;
border-top: 1px solid var(--line);
}
.j13-pulse-checkin-btn {
gap: 4px;
padding: 6px 11px;
font-size: 12px;
white-space: nowrap;
}
.j13-pulse-checkin-done {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 11px;
border-radius: 9999px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
/* 热门榜单 */ /* 热门榜单 */
.j13-rank { .j13-rank {
position: relative; position: relative;

View File

@@ -201,6 +201,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
{overview && ( {overview && (
<div className="mt-6"> <div className="mt-6">
<CommunityPanelsMobile <CommunityPanelsMobile
stats={overview.stats}
hot={overview.hot} hot={overview.hot}
activeUsers={overview.active_users} activeUsers={overview.active_users}
announcements={overview.announcements} announcements={overview.announcements}

View File

@@ -6,40 +6,16 @@ import { CalendarDays, CalendarCheck, LogIn, Loader2 } from "lucide-react";
import { apiDoCheckin, type CheckinStatus } from "@/lib/api"; import { apiDoCheckin, type CheckinStatus } from "@/lib/api";
/** /**
* 每日签到卡(右栏)。 * 每日签到紧凑条,内嵌在右栏「站点数据」卡底部。
* initial 为 undefined 表示游客;否则为 SSR 注入的登录用户签到状态。 * initial 为 undefined 表示游客;否则为 SSR 注入的登录用户签到状态。
*/ */
export default function CheckinCard({ initial }: { initial?: CheckinStatus | null }) { export function PulseCheckin({ initial }: { initial?: CheckinStatus | null }) {
const [status, setStatus] = useState<CheckinStatus | null>(initial ?? null); const [status, setStatus] = useState<CheckinStatus | null>(initial ?? null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
// 游客态
if (initial === undefined) {
return (
<section className="j13-module" aria-label="每日签到">
<div className="flex items-center gap-3">
<span
className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<CalendarDays size={19} />
</span>
<div className="min-w-0">
<p className="text-[14px] font-bold" style={{ color: "var(--ink)" }}>
每日签到
</p>
<p className="meta text-[12px] mt-0.5">登录后每日签到领积分</p>
</div>
</div>
<Link href="/login?redirect=/" className="btn btn-line btn-sm w-full justify-center mt-3.5">
<LogIn size={14} /> 登录后签到
</Link>
</section>
);
}
const checked = status?.checked_today ?? false; const checked = status?.checked_today ?? false;
const guest = initial === undefined;
async function doCheckin() { async function doCheckin() {
if (loading || checked) return; if (loading || checked) return;
@@ -55,59 +31,65 @@ export default function CheckinCard({ initial }: { initial?: CheckinStatus | nul
} }
return ( return (
<section className="j13-module" aria-label="每日签到"> <div aria-label="每日签到">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2.5">
<span <span
className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
style={{ style={{
background: checked ? "var(--accent-soft)" : "var(--panel-2)", background: checked || guest ? "var(--accent-soft)" : "var(--tint)",
color: checked ? "var(--accent)" : "var(--ink-2)", color: checked || guest ? "var(--accent)" : "var(--ink-2)",
}} }}
> >
{checked ? <CalendarCheck size={19} /> : <CalendarDays size={19} />} {checked ? <CalendarCheck size={16} /> : <CalendarDays size={16} />}
</span> </span>
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<p className="text-[14px] font-bold" style={{ color: "var(--ink)" }}> <p className="text-[12.5px] font-bold leading-tight" style={{ color: "var(--ink)" }}>
每日签到 每日签到
</p> </p>
<p className="meta text-[12px] mt-0.5"> <p className="meta mt-0.5 truncate text-[11px]">
{checked {guest
? `已连续签到 ${status?.streak ?? 0} 天 · 累计 ${status?.total_points ?? 0} 积分` ? "登录后每日签到领积分"
: `连续 ${status?.streak ?? 0} 天 · 今日可得 ${status?.today_points ?? 5} 积分`} : checked
? `连签 ${status?.streak ?? 0} 天 · 累计 ${status?.total_points ?? 0} 积分`
: `连签 ${status?.streak ?? 0} 天 · 今日 +${status?.today_points ?? 5}`}
</p> </p>
</div> </div>
{!checked && ( {guest ? (
<span className="text-[17px] font-extrabold tabular-nums shrink-0" style={{ color: "var(--accent)" }}> <Link
+{status?.today_points ?? 5} href="/login?redirect=/"
className="btn btn-line btn-sm j13-pulse-checkin-btn shrink-0"
>
<LogIn size={13} /> 登录
</Link>
) : checked ? (
<span
className="j13-pulse-checkin-done shrink-0"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<CalendarCheck size={12.5} /> 已签
</span> </span>
)} ) : (
</div>
<button <button
type="button" type="button"
onClick={doCheckin} onClick={doCheckin}
disabled={loading || checked} disabled={loading}
aria-disabled={loading || checked} className="btn btn-primary btn-sm j13-pulse-checkin-btn shrink-0"
className={`btn w-full justify-center mt-3.5 ${checked ? "btn-line" : "btn-primary"}`}
> >
{loading ? ( {loading ? (
<> <>
<Loader2 size={15} className="animate-spin" /> 签到中… <Loader2 size={13} className="animate-spin" /> 签到中
</>
) : checked ? (
<>
<CalendarCheck size={15} /> 今日已签到
</> </>
) : ( ) : (
"立即签到" `签到 +${status?.today_points ?? 5}`
)} )}
</button> </button>
)}
</div>
{error && ( {error && (
<p className="text-[12px] mt-2" style={{ color: "var(--clay)" }} role="alert"> <p className="mt-1.5 text-[11.5px]" style={{ color: "var(--clay)" }} role="alert">
{error} {error}
</p> </p>
)} )}
</section> </div>
); );
} }

View File

@@ -9,6 +9,8 @@ import {
Megaphone, Megaphone,
BarChart3, BarChart3,
PenLine, PenLine,
Activity,
TrendingUp,
} from "lucide-react"; } from "lucide-react";
import type { import type {
PostListItem, PostListItem,
@@ -19,52 +21,43 @@ import type {
CheckinStatus, CheckinStatus,
} from "@/lib/api"; } from "@/lib/api";
import Avatar from "@/components/Avatar"; import Avatar from "@/components/Avatar";
import CheckinCard from "./CheckinCard"; import { PulseCheckin } from "./CheckinCard";
import { FRIEND_LINKS } from "@/lib/friendLinks"; import { FRIEND_LINKS } from "@/lib/friendLinks";
import { formatCompact, formatShortDate } from "@/lib/format"; import { formatCompact, formatShortDate } from "@/lib/format";
/* ---------- 站点数据(2×2 极简统计表) ---------- */ /* ---------- 站点数据(2×2 bento 统计瓦片) ---------- */
function StatCell({ function PulseCard({ stats, checkin }: { stats: OverviewStats; checkin?: CheckinStatus | null }) {
index, const cells = [
label, {
value, icon: <Users size={12.5} />,
foot, label: "注册用户",
}: { value: formatCompact(stats.users),
index: number; foot: `+${formatCompact(stats.today_users)} 今日`,
label: string; up: true,
value: string; },
foot: string; {
}) { icon: <PenLine size={12.5} />,
const rightCol = index % 2 === 1; label: "话题总数",
const bottomRow = index >= 2; value: formatCompact(stats.posts),
return ( foot: `+${formatCompact(stats.today_posts)} 今日`,
<div up: true,
className="min-w-0 py-3.5" },
style={{ {
borderLeft: rightCol ? "1px solid var(--line)" : undefined, icon: <MessageCircle size={12.5} />,
borderTop: bottomRow ? "1px solid var(--line)" : undefined, label: "回复总数",
paddingLeft: rightCol ? 14 : 0, value: formatCompact(stats.comments),
paddingRight: rightCol ? 0 : 14, foot: `+${formatCompact(stats.today_comments)} 今日`,
}} up: true,
> },
<p className="text-[12px]" style={{ color: "var(--ink-3)" }}> {
{label} icon: <Activity size={12.5} />,
</p> label: "在线用户",
<p value: formatCompact(stats.online),
className="mt-1.5 text-[20px] font-semibold leading-none tracking-tight tabular-nums" foot: `峰值 ${formatCompact(stats.peak_online)}`,
style={{ color: "var(--ink)" }} up: false,
> },
{value} ];
</p>
<p className="mt-2 text-[11px] tabular-nums" style={{ color: "var(--ink-3)" }}>
{foot}
</p>
</div>
);
}
function PulseCard({ stats }: { stats: OverviewStats }) {
return ( return (
<section className="j13-module" aria-label="站点数据"> <section className="j13-module" aria-label="站点数据">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -76,15 +69,29 @@ function PulseCard({ stats }: { stats: OverviewStats }) {
style={{ color: "var(--ink-3)" }} style={{ color: "var(--ink-3)" }}
title="近 5 分钟在线" title="近 5 分钟在线"
> >
<span className="h-1.5 w-1.5 rounded-full" style={{ background: "var(--ok)" }} /> <span className="j13-pulse-dot" aria-hidden="true" />
实时 实时
</span> </span>
</div> </div>
<div className="mt-2 grid grid-cols-2"> <div className="j13-pulse mt-3">
<StatCell index={0} label="注册用户" value={formatCompact(stats.users)} foot={`+${stats.today_users} 今日`} /> {cells.map((c) => (
<StatCell index={1} label="话题总数" value={formatCompact(stats.posts)} foot={`+${stats.today_posts} 今日`} /> <div key={c.label} className="j13-pulse-cell">
<StatCell index={2} label="回复总数" value={formatCompact(stats.comments)} foot={`+${stats.today_comments} 今日`} /> <p className="j13-pulse-lab" style={{ color: "var(--ink-3)" }}>
<StatCell index={3} label="在线用户" value={formatCompact(stats.online)} foot={`峰值 ${formatCompact(stats.peak_online)}`} /> <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> </div>
</section> </section>
); );
@@ -319,9 +326,8 @@ export function CommunityPanels({
return ( return (
<aside className="hidden w-[280px] shrink-0 lg:block xl:w-[300px]" aria-label="社区面板"> <aside className="hidden w-[280px] shrink-0 lg:block xl:w-[300px]" aria-label="社区面板">
<div className="sticky top-24 space-y-4"> <div className="sticky top-24 space-y-4">
<PulseCard stats={stats} /> <PulseCard stats={stats} checkin={checkin} />
<Announcements items={announcements} /> <Announcements items={announcements} />
<CheckinCard initial={checkin} />
<HotRanking posts={hot} /> <HotRanking posts={hot} />
<NewUsers users={newUsers} /> <NewUsers users={newUsers} />
<ActiveUsers users={activeUsers} /> <ActiveUsers users={activeUsers} />
@@ -332,16 +338,17 @@ export function CommunityPanels({
} }
export function CommunityPanelsMobile({ export function CommunityPanelsMobile({
stats,
hot, hot,
activeUsers, activeUsers,
announcements, announcements,
newUsers, newUsers,
checkin, checkin,
}: Omit<PanelsProps, "stats">) { }: PanelsProps) {
return ( return (
<div className="space-y-4 lg:hidden" aria-label="社区面板"> <div className="space-y-4 lg:hidden" aria-label="社区面板">
<PulseCard stats={stats} checkin={checkin} />
<Announcements items={announcements} /> <Announcements items={announcements} />
<CheckinCard initial={checkin} />
<HotRanking posts={hot} /> <HotRanking posts={hot} />
<NewUsers users={newUsers} /> <NewUsers users={newUsers} />
<ActiveUsers users={activeUsers} /> <ActiveUsers users={activeUsers} />