Skip to content

Commit a0dbd2a

Browse files
committed
feat(follows): 个人主页关注按钮 + 粉丝/关注数
- FollowButton 客户端组件:拉 /api/user-center/follows/stats 填初始值, 点击走 POST/DELETE /api/user-center/follows/{id},乐观更新失败回滚 - 自己访问自己主页只显示统计,不显示按钮 - 匿名用户可见数字,按钮显示"登录后关注"置灰 - prisma/schema.prisma 加 user_follows model 同步(DB 已在 Neon 手动建表) 配套后端端点见 involutionhell-backend#main 30daf9d
1 parent 5dd8c22 commit a0dbd2a

3 files changed

Lines changed: 168 additions & 0 deletions

File tree

app/u/[username]/FollowButton.tsx

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import { useAuth } from "@/lib/use-auth";
5+
6+
interface Props {
7+
/** 目标用户的 identifier(/u/{identifier},数字或 username 都行) */
8+
identifier: string;
9+
/** 目标用户的 user_accounts.id,用于判断"不能关注自己" */
10+
targetUserId: number;
11+
}
12+
13+
interface StatsResponse {
14+
success: boolean;
15+
data?: {
16+
userId: number;
17+
followerCount: number;
18+
followingCount: number;
19+
isFollowing: boolean;
20+
};
21+
}
22+
23+
function readToken(): string | null {
24+
if (typeof window === "undefined") return null;
25+
try {
26+
return localStorage.getItem("satoken");
27+
} catch {
28+
return null;
29+
}
30+
}
31+
32+
/**
33+
* 个人主页上的关注按钮 + 统计。
34+
* - 匿名用户显示 followers/following 数字但按钮置灰提示"登录后关注"
35+
* - 自己访问自己主页时不显示按钮,只显示统计
36+
* - 乐观更新:点击立即切换 UI,失败回滚
37+
*/
38+
export function FollowButton({ identifier, targetUserId }: Props) {
39+
const { user, status } = useAuth();
40+
const [followerCount, setFollowerCount] = useState<number | null>(null);
41+
const [followingCount, setFollowingCount] = useState<number | null>(null);
42+
const [isFollowing, setIsFollowing] = useState(false);
43+
const [pending, setPending] = useState(false);
44+
45+
const isSelf = user?.id === targetUserId;
46+
47+
// 初次载入拉 stats
48+
useEffect(() => {
49+
const token = readToken();
50+
fetch(`/api/user-center/follows/stats/${encodeURIComponent(identifier)}`, {
51+
headers: token ? { satoken: token } : {},
52+
})
53+
.then((r) => (r.ok ? (r.json() as Promise<StatsResponse>) : null))
54+
.then((json) => {
55+
if (!json?.success || !json.data) return;
56+
setFollowerCount(json.data.followerCount);
57+
setFollowingCount(json.data.followingCount);
58+
setIsFollowing(json.data.isFollowing);
59+
})
60+
.catch(() => {});
61+
}, [identifier]);
62+
63+
async function toggle() {
64+
if (status !== "authenticated" || isSelf || pending) return;
65+
const token = readToken();
66+
if (!token) return;
67+
68+
const nextFollowing = !isFollowing;
69+
// 乐观更新
70+
setPending(true);
71+
setIsFollowing(nextFollowing);
72+
setFollowerCount((c) =>
73+
c == null ? c : Math.max(0, c + (nextFollowing ? 1 : -1)),
74+
);
75+
76+
try {
77+
const res = await fetch(
78+
`/api/user-center/follows/${encodeURIComponent(identifier)}`,
79+
{
80+
method: nextFollowing ? "POST" : "DELETE",
81+
headers: { satoken: token },
82+
},
83+
);
84+
if (!res.ok) {
85+
// 回滚
86+
setIsFollowing(!nextFollowing);
87+
setFollowerCount((c) =>
88+
c == null ? c : Math.max(0, c - (nextFollowing ? 1 : -1)),
89+
);
90+
} else {
91+
// 以服务端为准修正计数
92+
const json = (await res.json()) as StatsResponse;
93+
if (json.success && json.data) {
94+
setFollowerCount(json.data.followerCount);
95+
setFollowingCount(json.data.followingCount);
96+
setIsFollowing(json.data.isFollowing);
97+
}
98+
}
99+
} catch {
100+
// 网络异常回滚
101+
setIsFollowing(!nextFollowing);
102+
setFollowerCount((c) =>
103+
c == null ? c : Math.max(0, c - (nextFollowing ? 1 : -1)),
104+
);
105+
} finally {
106+
setPending(false);
107+
}
108+
}
109+
110+
return (
111+
<div className="flex items-center gap-4 border-t border-[var(--foreground)] pt-4">
112+
<div className="flex gap-4 font-mono text-[10px] uppercase tracking-widest text-neutral-500">
113+
<span>
114+
<strong className="text-[var(--foreground)] font-serif text-base font-black">
115+
{followerCount?.toLocaleString() ?? "—"}
116+
</strong>{" "}
117+
粉丝
118+
</span>
119+
<span>
120+
<strong className="text-[var(--foreground)] font-serif text-base font-black">
121+
{followingCount?.toLocaleString() ?? "—"}
122+
</strong>{" "}
123+
关注
124+
</span>
125+
</div>
126+
{!isSelf && (
127+
<button
128+
type="button"
129+
disabled={status !== "authenticated" || pending}
130+
onClick={toggle}
131+
className={[
132+
"font-mono text-[10px] uppercase tracking-widest px-3 py-1.5 border transition-colors",
133+
isFollowing
134+
? "border-[var(--foreground)] text-[var(--foreground)] hover:bg-[#CC0000] hover:border-[#CC0000] hover:text-[var(--background)]"
135+
: "border-[var(--foreground)] bg-[var(--foreground)] text-[var(--background)] hover:bg-[#CC0000] hover:border-[#CC0000]",
136+
"disabled:opacity-50 disabled:cursor-not-allowed",
137+
].join(" ")}
138+
data-umami-event={isFollowing ? "unfollow_click" : "follow_click"}
139+
>
140+
{status !== "authenticated"
141+
? "登录后关注"
142+
: pending
143+
? "..."
144+
: isFollowing
145+
? "已关注"
146+
: "+ 关注"}
147+
</button>
148+
)}
149+
</div>
150+
);
151+
}

app/u/[username]/page.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Footer } from "@/app/components/Footer";
88
import { ProfileCard } from "./ProfileCard";
99
import { EditLinkIfOwner } from "./EditLinkIfOwner";
1010
import { ActivityHeatmap } from "./ActivityHeatmap";
11+
import { FollowButton } from "./FollowButton";
1112

1213
interface UserView {
1314
id: number;
@@ -215,6 +216,8 @@ export default async function UserProfilePage({ params }: Param) {
215216
<Stat label="累计 Commits" value={commits} />
216217
<Stat label="积分" value={points} />
217218
</div>
219+
{/* 关注按钮 + 粉丝/关注数,客户端动态拉 */}
220+
<FollowButton identifier={username} targetUserId={user.id} />
218221
{links.length > 0 && (
219222
<div className="border-t border-[var(--foreground)] pt-4 flex flex-wrap gap-2">
220223
{links.slice(0, 5).map((link) => (

prisma/schema.prisma

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,17 @@ model AnalyticsEvent {
139139
@@index([eventType])
140140
@@index([userId])
141141
}
142+
143+
/// 用户关注关系:follower_id 关注了 followee_id。
144+
/// 两个字段都对应 user_accounts.id(BigInt/Long),和 analytics/chat 保持一致。
145+
/// 不建外键约束:user_accounts 由 Java 侧 SaToken 管理,Prisma 只负责 schema 声明,
146+
/// 避免 cross-ownership 的删除级联踩坑。
147+
model user_follows {
148+
follower_id BigInt
149+
followee_id BigInt
150+
created_at DateTime @default(now()) @db.Timestamptz(6)
151+
152+
@@id([follower_id, followee_id])
153+
@@index([followee_id])
154+
@@index([follower_id])
155+
}

0 commit comments

Comments
 (0)