Files
jiang13-bbs/frontend/components/home/CommunityPanels.tsx
freefire ff2ab286fb feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
2026-10-01 03:06:05 +08:00

687 lines
22 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,
Pin,
ChevronRight,
CircleDollarSign,
Handshake,
} from "lucide-react";
import type {
PostListItem,
ActiveUser,
OverviewStats,
AnnouncementSummary,
NewUser,
CheckinStatus,
SidebarPageItem,
PublicAdItem,
PublicSponsorItem,
FriendLink,
} from "@/lib/api";
import Avatar from "@/components/Avatar";
import { AdText } from "@/components/AdText";
import { getPublicSettingsCached } from "@/lib/serverData";
import { announcementPath, postPath, userPath, pagePath } from "@/lib/urlStyle";
import { PulseCheckin } from "./CheckinCard";
import { formatCompact, formatShortDate } from "@/lib/format";
import { pageGlyph } from "@/lib/pageGlyph";
import { AboutSiteHeader } from "@/components/site-doc";
import {
AD_IMAGE_HEIGHT,
AD_IMAGE_SIZE_LABEL,
AD_IMAGE_WIDTH,
AD_TIP_DELAY_MS,
adHoverTip,
} from "@/lib/ads";
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
/* ---------- 站点数据(2×2 bento 统计瓦片) ---------- */
export function PulseCard({
stats,
checkin,
allowComments,
}: {
stats: OverviewStats;
checkin?: CheckinStatus | null;
allowComments: boolean;
}) {
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,
},
...(allowComments
? [
{
icon: <MessageCircle size={12.5} />,
label: "回复总数",
value: formatCompact(stats.comments),
foot: `+${formatCompact(stats.today_comments)} 今日`,
up: true,
},
]
: [
{
icon: <TrendingUp size={12.5} />,
label: "今日新帖",
value: formatCompact(stats.today_posts),
foot: "本日新增",
up: stats.today_posts > 0,
},
]),
{
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)" }}
data-tip="近 5 分钟在线"
>
<span className="j13-pulse-dot" aria-hidden="true" />
实时
</span>
</div>
<div className="j13-pulse">
{cells.map((c) => (
<div key={c.label} className="j13-pulse-cell">
<p className="j13-pulse-lab">
<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>
);
}
/* ---------- 关于本站(侧栏页目录,置于右栏最底) ---------- */
export async function AboutSite({
pages,
siteDescription,
canManagePages,
}: {
pages: SidebarPageItem[];
siteDescription?: string;
canManagePages?: boolean;
}) {
if (pages.length === 0 && !canManagePages) return null;
const lead = siteDescription?.trim() ?? "";
const urlStyle = (await getPublicSettingsCached()).url_style;
return (
<section className="j13-module j13-about" aria-label="关于本站">
<AboutSiteHeader canManage={canManagePages} />
{lead ? <p className="j13-about-lead">{lead}</p> : null}
{pages.length === 0 ? (
<p className="meta mt-2 px-0.5">暂无介绍</p>
) : (
<ul className="j13-about-list">
{pages.map((p) => {
const Icon = pageGlyph(p.slug);
return (
<li key={p.id}>
<Link href={pagePath(urlStyle, p.slug)} className="j13-about-item">
<span className="j13-about-ico" aria-hidden="true">
<Icon size={15} strokeWidth={2} />
</span>
<span className="j13-about-body">
<span className="j13-about-name">{p.title}</span>
{p.excerpt ? <span className="j13-about-excerpt">{p.excerpt}</span> : null}
</span>
<ChevronRight className="j13-about-chev" size={15} aria-hidden="true" />
</Link>
</li>
);
})}
</ul>
)}
</section>
);
}
/* ---------- 站点公告 ---------- */
export async function Announcements({
items,
canManageAnnouncements,
}: {
items: AnnouncementSummary[];
canManageAnnouncements?: boolean;
}) {
const urlStyle = (await getPublicSettingsCached()).url_style;
return (
<section className="j13-module" aria-label="站点公告">
<div className="flex items-center justify-between gap-2">
<p className="j13-module-title !mb-0">
<Megaphone size={15} style={{ color: "var(--accent)" }} /> 站点公告
</p>
<span className="inline-flex items-center gap-2 shrink-0">
<Link
href="/announcements"
className="meta text-[12px] font-normal transition-colors hover:text-[var(--accent)]"
>
全部
</Link>
{canManageAnnouncements ? (
<Link
href="/admin/announcements"
className="meta text-[12px] font-normal transition-colors hover:text-[var(--accent)]"
>
管理
</Link>
) : null}
</span>
</div>
{items.length === 0 ? (
<p className="meta mt-2 px-0.5">暂无公告</p>
) : (
<ul className="j13-anno-list">
{items.map((a) => (
<li key={a.id}>
<Link
href={announcementPath(urlStyle, a.id)}
className="j13-anno-item"
data-tip={a.title}
>
<span className="j13-anno-item-body">
<span className="j13-anno-item-title truncate inline-flex items-center gap-1 min-w-0">
{a.pinned ? (
<Pin
size={11}
className="shrink-0"
style={{ color: "var(--accent)" }}
aria-label="置顶"
/>
) : null}
<span className="truncate">{a.title}</span>
</span>
<span className="j13-anno-item-meta">
{a.tag ? (
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
) : null}
<span className="meta text-[11px] tabular-nums">
{formatShortDate(a.created_at)}
</span>
</span>
</span>
</Link>
</li>
))}
</ul>
)}
</section>
);
}
/* ---------- 热门榜 ---------- */
export async function HotRanking({ posts, allowComments }: { posts: PostListItem[]; allowComments: boolean }) {
const urlStyle = (await getPublicSettingsCached()).url_style;
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={postPath(urlStyle, p.id)} className="j13-rank" data-tip={p.title}>
<span className="j13-rank-no">{i + 1}</span>
<span className="min-w-0 flex-1">
<span className="j13-rank-title truncate">
{p.title}
</span>
<span className="mt-1 flex items-center gap-2.5 meta text-[11.5px]">
{allowComments ? (
<span className="inline-flex items-center gap-1">
<MessageCircle size={11.5} /> {p.comment_count}
</span>
) : null}
<span className="inline-flex items-center gap-1">
<Heart size={11.5} /> {p.like_count}
</span>
</span>
</span>
</Link>
</li>
))}
</ol>
)}
</section>
);
}
/* ---------- 最新注册 ---------- */
export async function NewUsers({ users }: { users: NewUser[] }) {
if (users.length === 0) return null;
const urlStyle = (await getPublicSettingsCached()).url_style;
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={userPath(urlStyle, u.id)}
className="flex flex-col items-center gap-1.5 rounded-lg px-1 py-1 transition-colors hover:bg-[var(--item-hover-bg)] focus-visible:bg-[var(--item-hover-bg)]"
>
<Avatar name={name} src={u.avatar} size={38} />
<span
className="w-full truncate text-center text-[11px]"
style={{ color: "var(--ink-2)" }}
data-tip={name}
>
{name}
</span>
</Link>
</li>
);
})}
</ul>
</section>
);
}
/* ---------- 活跃成员(本周发帖 / 回复) ---------- */
export async function ActiveUsers({ users, allowComments }: { users: ActiveUser[]; allowComments: boolean }) {
const urlStyle = (await getPublicSettingsCached()).url_style;
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={userPath(urlStyle, u.id)} className="j13-raillink" data-tip={name}>
<Avatar name={name} src={u.avatar} size={30} />
<span className="flex-1 min-w-0">
<span
className="block truncate text-[13px] font-medium 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>
{allowComments ? (
<span className="inline-flex items-center gap-1">
<MessageCircle size={10.5} /> 回复 {u.comment_count}
</span>
) : null}
</span>
</span>
<span
className="shrink-0 text-[13px] tabular-nums"
style={{ color: "var(--accent)" }}
aria-label={
allowComments
? `本周共 ${u.post_count + u.comment_count} 次互动`
: `本周发帖 ${u.post_count}`
}
>
{allowComments ? u.post_count + u.comment_count : u.post_count}
</span>
</Link>
</li>
);
})}
</ul>
)}
</section>
);
}
/* ---------- 友情链接 ---------- */
export function FriendLinks({ links }: { links: FriendLink[] }) {
// 块显隐由侧栏布局配置控制;列表为空时仍渲染(保留「申请」入口),仅展示占位提示
return (
<section className="j13-module" aria-label="友情链接">
<p className="j13-module-title mb-3">
<Link2 size={15} style={{ color: "var(--accent)" }} /> 友情链接
<span className="ml-auto inline-flex items-center gap-3 text-[12px] font-normal">
<Link href="/links" className="meta transition-colors hover:text-[var(--accent)]">
全部
</Link>
<Link
href="/links#apply"
className="meta transition-colors hover:text-[var(--accent)]"
>
申请
</Link>
</span>
</p>
{links.length === 0 ? (
<p className="meta text-[12.5px]">暂无友链,欢迎申请</p>
) : (
<div className="flex flex-wrap gap-2">
{links.map((l) => (
<a
key={l.id}
href={l.url}
target="_blank"
rel="noopener noreferrer"
className="j13-chip inline-flex items-center gap-1.5"
title={l.description || l.name}
>
{l.logo_url ? (
// 14px 显示位:本站图走 48/96 变体(PageSpeed 典型场景);外链 logo 保持原地址
// eslint-disable-next-line @next/next/no-img-element
<img
src={isOwnImage(l.logo_url) ? variantURL(l.logo_url, pickVariantWidth(14)) : l.logo_url}
srcSet={isOwnImage(l.logo_url) ? densitySrcSet(l.logo_url, 14) : undefined}
alt=""
width={14}
height={14}
className="object-contain"
style={{ width: 14, height: 14 }}
/>
) : null}
{l.name}
</a>
))}
</div>
)}
</section>
);
}
/* ---------- 桌面 / 移动两种装配 ---------- */
type PanelsProps = {
stats: OverviewStats;
hot: PostListItem[];
activeUsers: ActiveUser[];
announcements: AnnouncementSummary[];
newUsers: NewUser[];
checkin?: CheckinStatus | null;
allowComments: boolean;
canManageAnnouncements?: boolean;
canManagePages?: boolean;
sidebarPages?: SidebarPageItem[];
siteDescription?: string;
ads?: PublicAdItem[];
adsPanelTitle?: string;
adsEnabled?: boolean;
sponsors?: PublicSponsorItem[];
sponsorsPanelTitle?: string;
sponsorsEnabled?: boolean;
friendLinks?: FriendLink[];
};
export function AdsPanel({
items,
title,
enabled,
}: {
items: PublicAdItem[];
title: string;
enabled: boolean;
}) {
if (!enabled) return null;
const IMAGE_SLOTS = 5;
const TEXT_SLOTS = 6;
const images = items.filter((a) => a.kind === "image").slice(0, IMAGE_SLOTS);
const texts = items.filter((a) => a.kind === "text").slice(0, TEXT_SLOTS);
const imageSlots = Array.from({ length: IMAGE_SLOTS }, (_, i) => images[i] ?? null);
const textSlots = Array.from({ length: TEXT_SLOTS }, (_, i) => texts[i] ?? null);
return (
<section className="j13-module" aria-label={title || "自助推广"}>
<p className="j13-module-title mb-3">
<CircleDollarSign size={15} style={{ color: "var(--accent)" }} />
{title || "自助推广"}
<Link href="/ads/buy" className="j13-ad-buy-btn ml-auto">
购买广告位
</Link>
</p>
<div className="j13-ad-promo">
<ul className="space-y-2" aria-label="图片广告位">
{imageSlots.map((ad, i) =>
ad ? (
<li key={`img-${ad.id}`}>
<a
href={ad.link_url}
target="_blank"
rel="noopener noreferrer sponsored"
className="j13-ad-image-link"
data-tip={adHoverTip(ad.ends_at)}
data-tip-delay={AD_TIP_DELAY_MS}
>
{/* 固定 248×60 位:本站图走 256/512 密度变体 */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={isOwnImage(ad.image_url ?? "") ? variantURL(ad.image_url ?? "", pickVariantWidth(AD_IMAGE_WIDTH)) : ad.image_url}
srcSet={isOwnImage(ad.image_url ?? "") ? densitySrcSet(ad.image_url ?? "", AD_IMAGE_WIDTH) : undefined}
alt="推广"
className="j13-ad-image"
width={AD_IMAGE_WIDTH}
height={AD_IMAGE_HEIGHT}
loading="lazy"
/>
</a>
</li>
) : (
<li key={`img-slot-${i}`}>
<Link href="/ads/buy" className="j13-ad-slot j13-ad-slot-image">
<span>图片广告位 {AD_IMAGE_SIZE_LABEL}</span>
</Link>
</li>
)
)}
</ul>
<ul className="j13-ad-text-grid" aria-label="文字广告位">
{textSlots.map((ad, i) =>
ad ? (
<li key={`txt-${ad.id}`}>
<AdText
title={ad.title ?? ""}
textColor={ad.text_color || "#1E293B"}
bgColor={ad.bg_color || "#F1F5F9"}
href={ad.link_url}
endsAt={ad.ends_at}
/>
</li>
) : (
<li key={`txt-slot-${i}`}>
<Link href="/ads/buy" className="j13-ad-slot j13-ad-slot-text">
文字广告位
</Link>
</li>
)
)}
</ul>
</div>
</section>
);
}
export function SponsorsPanel({
items,
title,
enabled,
}: {
items: PublicSponsorItem[];
title: string;
enabled: boolean;
}) {
if (!enabled || items.length === 0) return null;
return (
<section className="j13-module" aria-label={title || "赞助商"}>
<p className="j13-module-title mb-3">
<Handshake size={15} style={{ color: "var(--accent)" }} />
{title || "赞助商"}
</p>
<ul className="j13-sponsors-grid">
{items.map((sp) => {
const tip = (sp.hover_text || sp.name || "").trim();
const href = sp.link_url?.trim() || undefined;
const inner = (
// logo ≤52px 高:本站图走 96/160 变体
// eslint-disable-next-line @next/next/no-img-element
<img
src={isOwnImage(sp.logo_url) ? variantURL(sp.logo_url, pickVariantWidth(52)) : sp.logo_url}
srcSet={isOwnImage(sp.logo_url) ? densitySrcSet(sp.logo_url, 52) : undefined}
alt={sp.name}
className="j13-sponsor-logo"
loading="lazy"
/>
);
return (
<li key={sp.id} className="j13-sponsor">
{href ? (
<a
href={href}
target="_blank"
rel="noopener noreferrer sponsored"
className="j13-sponsor-hit"
aria-label={sp.name}
data-tip={tip || undefined}
>
{inner}
</a>
) : (
<span
className="j13-sponsor-hit"
tabIndex={0}
aria-label={sp.name}
data-tip={tip || undefined}
>
{inner}
</span>
)}
</li>
);
})}
</ul>
</section>
);
}
function CommunityPanelsBody({
stats,
hot,
activeUsers,
announcements,
newUsers,
checkin,
allowComments,
canManageAnnouncements,
canManagePages,
sidebarPages = [],
siteDescription,
ads = [],
adsPanelTitle = "自助推广",
adsEnabled = true,
sponsors = [],
sponsorsPanelTitle = "赞助商",
sponsorsEnabled = true,
friendLinks = [],
}: PanelsProps) {
return (
<>
<PulseCard stats={stats} checkin={checkin} allowComments={allowComments} />
<Announcements items={announcements} canManageAnnouncements={canManageAnnouncements} />
<AdsPanel items={ads} title={adsPanelTitle} enabled={adsEnabled} />
<SponsorsPanel items={sponsors} title={sponsorsPanelTitle} enabled={sponsorsEnabled} />
<HotRanking posts={hot} allowComments={allowComments} />
<NewUsers users={newUsers} />
<ActiveUsers users={activeUsers} allowComments={allowComments} />
<FriendLinks links={friendLinks} />
<AboutSite
pages={sidebarPages}
siteDescription={siteDescription}
canManagePages={canManagePages}
/>
</>
);
}
export function CommunityPanels(props: PanelsProps) {
return (
<aside
className="hidden w-[280px] shrink-0 lg:block xl:w-[300px]"
aria-label="社区面板"
>
<div className="space-y-4">
<CommunityPanelsBody {...props} />
</div>
</aside>
);
}
export function CommunityPanelsMobile(props: PanelsProps) {
return (
<div className="space-y-4 lg:hidden" aria-label="社区面板">
<CommunityPanelsBody {...props} />
</div>
);
}