feat: 帖子收藏与用户关注

- 新增收藏功能:favorite 服务与 handler、FavoriteButton 组件
- 新增关注功能:follow 服务与 handler、FollowButton 组件
- 用户主页适配关注/粉丝与收藏展示
- flash 消息工具、广告与侧边栏组件微调
This commit is contained in:
2026-09-26 03:02:40 +08:00
parent 31f5360c26
commit d7c6b3a961
40 changed files with 1528 additions and 268 deletions

View File

@@ -2,6 +2,7 @@
import { Crown, BadgeCheck, ShieldCheck, Shield, Users, type LucideIcon } from "lucide-react";
import { roleMeta, type RoleIconKey } from "@/lib/roles";
import { BOARD_COLOR_COUNT } from "@/components/BoardIcon";
// 角色图标集中映射(图标库不含 ShieldStar,超管使用 BadgeCheck 表达"全权限认证")
const ICONS: Record<RoleIconKey, LucideIcon> = {
@@ -12,59 +13,92 @@ const ICONS: Record<RoleIconKey, LucideIcon> = {
user: Users,
};
/** 已知板块信息:名称 + 配色下标(0..15) */
type BoardRef = { name: string; colorIdx: number | null };
/**
* 角色徽章:图标 + 文案,配色走角色元数据中的 CSS token。
* - board_admin 额外展示授权板块数(或板块名)
* - variant=plain 时仅图标+文字不着底色(用于表头/文本行)
* 角色徽章:图标 + 文案。
* - 普通角色:配色走角色元数据中的 CSS token
* - board_admin 提供板块名时逐板块渲染「{板块名} · 板块管理员」,配色对齐板块色
* (av-N,深浅色自适应);truncate 窄列场景折叠为单枚「{首板块}等 N 个板块 · 板块管理员」
* - board_admin 未提供板块名时退回「板块管理员 · N 个板块」
*/
export default function RoleBadge({
role,
boardIds,
boardNames,
size = 11,
boardColors,
size = 12,
className = "",
title,
truncate = false,
}: {
role?: string | null;
boardIds?: number[] | null;
/** id -> 名称映射;提供时板块管理员展示板块名(多选时折叠为 N 个板块) */
/** id -> 名称映射;提供时板块管理员按板块逐枚展示 */
boardNames?: Map<number, string> | Record<number, string>;
/** id -> 配色下标(boardColor 计算);提供时徽章配色对齐板块色 */
boardColors?: Map<number, number> | Record<number, number>;
size?: number;
className?: string;
title?: string;
/** 窄列场景:限制最大宽度并省略过长文案 */
/** 窄列场景:限制最大宽度并省略过长文案;多板块折叠为单枚 */
truncate?: boolean;
}) {
const meta = roleMeta(role);
const Icon = ICONS[meta.icon];
let label = meta.label;
if (role === "board_admin") {
if (boardNames) {
const names = (boardIds ?? [])
.map((id) => (boardNames instanceof Map ? boardNames.get(id) : boardNames[id]))
.filter(Boolean) as string[];
if (names.length === 1) {
label = `${meta.label} · ${names[0]}`;
} else if (names.length > 1) {
label = `${meta.label} · ${names.length} 个板块`;
}
} else if (boardIds && boardIds.length > 0) {
label = `${meta.label} · ${boardIds.length} 个板块`;
}
}
return (
// 单枚渲染:有板块配色下标时用 av-N 类(自带深浅色两套值),否则角色配色 token
const renderBadge = (label: string, boardColorIdx: number | null, key?: number) => (
<span
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium leading-[1.6] select-none ${
key={key}
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11.5px] font-medium leading-[1.5] select-none transition-transform duration-200 hover:-translate-y-[1px] ${
truncate ? "max-w-full whitespace-nowrap" : "whitespace-nowrap"
} ${className}`}
style={{ background: `var(${meta.softToken})`, color: `var(${meta.colorToken})` }}
} ${boardColorIdx != null ? `av-${boardColorIdx}` : ""} ${className}`}
style={
boardColorIdx != null
? undefined
: { background: `var(${meta.softToken})`, color: `var(${meta.colorToken})` }
}
data-tip={title ?? label}
>
<Icon size={size} className="shrink-0" />
{truncate ? <span className="overflow-hidden text-ellipsis">{label}</span> : label}
</span>
);
if (role === "board_admin" && boardNames) {
const boards = (boardIds ?? [])
.map((id): BoardRef | null => {
const name = boardNames instanceof Map ? boardNames.get(id) : boardNames[id];
if (!name) return null;
const c = boardColors
? boardColors instanceof Map
? boardColors.get(id)
: boardColors[id]
: undefined;
return { name, colorIdx: typeof c === "number" ? c % BOARD_COLOR_COUNT : null };
})
.filter((b): b is BoardRef => b !== null);
if (boards.length > 0) {
if (truncate) {
// 窄列:折叠为单枚,避免多枚徽章撑破布局
const first = boards[0];
const label =
boards.length === 1
? `${first.name} · ${meta.label}`
: `${first.name} 等 ${boards.length} 个板块 · ${meta.label}`;
return renderBadge(label, first.colorIdx);
}
return <>{boards.map((b, i) => renderBadge(`${b.name} · ${meta.label}`, b.colorIdx, i))}</>;
}
// 板块名缺失(板块被删等):回落通用渲染
}
let label = meta.label;
if (role === "board_admin" && boardIds && boardIds.length > 0) {
label = `${meta.label} · ${boardIds.length} 个板块`;
}
return renderBadge(label, null);
}