feat: 更新SproutGate前后端代码
This commit is contained in:
@@ -1,44 +1,100 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { API_BASE, marked, formatWebsiteLabel, formatUserRegisteredAt } from "../config";
|
||||
import icons from "../icons";
|
||||
import { InfoRow, StatItem } from "./common";
|
||||
import AvatarImg from "./AvatarImg";
|
||||
|
||||
export default function PublicUserPage({ account, onReady, onPreviewImage }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [profileLikeCount, setProfileLikeCount] = useState(0);
|
||||
const [viewerHasLikedToday, setViewerHasLikedToday] = useState(false);
|
||||
const [viewerLikesRemaining, setViewerLikesRemaining] = useState(5);
|
||||
const [profileLikeDailyMax, setProfileLikeDailyMax] = useState(5);
|
||||
const [viewerIsOwner, setViewerIsOwner] = useState(false);
|
||||
const [likeLoading, setLikeLoading] = useState(false);
|
||||
const [likeError, setLikeError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const authHeaders = () => {
|
||||
try {
|
||||
const token = (localStorage.getItem("sproutgate_token") || "").trim();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const loadPublicUser = async () => {
|
||||
if (!account) {
|
||||
setError("缺少账户名");
|
||||
setLoading(false);
|
||||
if (!cancelled && onReady) onReady();
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setUser(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/public/users/${encodeURIComponent(account)}`);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "加载公开主页失败");
|
||||
setUser(data.user || null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!cancelled && onReady) onReady();
|
||||
}
|
||||
};
|
||||
|
||||
loadPublicUser();
|
||||
return () => { cancelled = true; };
|
||||
const loadPublicUser = useCallback(async () => {
|
||||
if (!account) {
|
||||
setError("缺少账户名");
|
||||
setLoading(false);
|
||||
onReady?.();
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setUser(null);
|
||||
setLikeError("");
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/public/users/${encodeURIComponent(account)}`, {
|
||||
headers: { ...authHeaders() }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "加载公开主页失败");
|
||||
setUser(data.user || null);
|
||||
setProfileLikeCount(Number(data.profileLikeCount) || 0);
|
||||
setViewerHasLikedToday(Boolean(data.viewerHasLikedToday));
|
||||
const rem = Number(data.viewerLikesRemainingToday);
|
||||
if (Number.isFinite(rem) && rem >= 0) setViewerLikesRemaining(rem);
|
||||
const mx = Number(data.profileLikeDailyMax);
|
||||
if (Number.isFinite(mx) && mx > 0) setProfileLikeDailyMax(mx);
|
||||
setViewerIsOwner(Boolean(data.viewerIsOwner));
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
onReady?.();
|
||||
}
|
||||
}, [account, onReady]);
|
||||
|
||||
const avatarUrl = user?.avatarUrl || "https://dummyimage.com/160x160/ddd/fff&text=Avatar";
|
||||
useEffect(() => {
|
||||
loadPublicUser();
|
||||
}, [loadPublicUser]);
|
||||
|
||||
const handleProfileLike = async () => {
|
||||
if (!account || likeLoading || viewerIsOwner || viewerHasLikedToday || viewerLikesRemaining <= 0) return;
|
||||
const h = authHeaders();
|
||||
if (!h.Authorization) {
|
||||
setLikeError("请先登录用户中心后再点赞");
|
||||
return;
|
||||
}
|
||||
setLikeLoading(true);
|
||||
setLikeError("");
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/public/users/${encodeURIComponent(account)}/like`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...h }
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const rem = Number(data.viewerLikesRemainingToday);
|
||||
if (Number.isFinite(rem) && rem >= 0) setViewerLikesRemaining(rem);
|
||||
throw new Error(data.error || "点赞失败");
|
||||
}
|
||||
setProfileLikeCount(Number(data.profileLikeCount) || 0);
|
||||
setViewerHasLikedToday(true);
|
||||
const remOk = Number(data.viewerLikesRemainingToday);
|
||||
if (Number.isFinite(remOk) && remOk >= 0) setViewerLikesRemaining(remOk);
|
||||
const mxOk = Number(data.profileLikeDailyMax);
|
||||
if (Number.isFinite(mxOk) && mxOk > 0) setProfileLikeDailyMax(mxOk);
|
||||
} catch (err) {
|
||||
setLikeError(err.message);
|
||||
} finally {
|
||||
setLikeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const displayName = user?.username || user?.account || "未命名用户";
|
||||
|
||||
return (
|
||||
@@ -56,13 +112,14 @@ export default function PublicUserPage({ account, onReady, onPreviewImage }) {
|
||||
{!loading && user && (
|
||||
<div className="card profile">
|
||||
<div className="profile-header">
|
||||
<img
|
||||
src={avatarUrl}
|
||||
<AvatarImg
|
||||
user={user}
|
||||
placeholderSize={160}
|
||||
alt={displayName}
|
||||
className="previewable-image"
|
||||
onClick={() => onPreviewImage?.(avatarUrl, displayName)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
previewable
|
||||
onPreviewImage={onPreviewImage}
|
||||
previewLabel={displayName}
|
||||
/>
|
||||
<div>
|
||||
<h2>{displayName}</h2>
|
||||
@@ -90,6 +147,7 @@ export default function PublicUserPage({ account, onReady, onPreviewImage }) {
|
||||
|
||||
<div className="profile-section-title">统计信息</div>
|
||||
<div className="profile-stats-flow">
|
||||
<StatItem icon={icons.heart} label="获赞" value={`${profileLikeCount}`} />
|
||||
<StatItem icon={icons.level} label="等级" value={`${user.level ?? 0}`} />
|
||||
<StatItem icon={icons.coins} label="萌芽币" value={user.sproutCoins ?? 0} />
|
||||
<StatItem icon={icons.calendar} label="签到天数" value={`${user.checkInDays ?? 0}`} />
|
||||
@@ -97,6 +155,35 @@ export default function PublicUserPage({ account, onReady, onPreviewImage }) {
|
||||
<StatItem icon={icons.statClock} label="访问天数" value={`${user.visitDays ?? 0}`} />
|
||||
<StatItem icon={icons.statRepeat} label="连续访问" value={`${user.visitStreak ?? 0}`} />
|
||||
</div>
|
||||
{!viewerIsOwner && (
|
||||
<div className="profile-like-row">
|
||||
{authHeaders().Authorization ? (
|
||||
<span className="hint profile-like-quota">
|
||||
今日还可点赞 <strong>{viewerLikesRemaining}</strong> / {profileLikeDailyMax} 人(每自然日刷新)
|
||||
</span>
|
||||
) : null}
|
||||
{viewerHasLikedToday ? (
|
||||
<span className="profile-like-done">今日已为 Ta 点赞</span>
|
||||
) : viewerLikesRemaining <= 0 ? (
|
||||
<span className="profile-like-done profile-like-quota-exhausted">
|
||||
今日点赞名额已用完,明天可再给最多 {profileLikeDailyMax} 人点赞
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="primary profile-like-btn"
|
||||
onClick={handleProfileLike}
|
||||
disabled={likeLoading}
|
||||
>
|
||||
{likeLoading ? "提交中…" : "为 Ta 点赞"}
|
||||
</button>
|
||||
)}
|
||||
{likeError ? <span className="error profile-like-error" role="alert">{likeError}</span> : null}
|
||||
{!authHeaders().Authorization && !viewerHasLikedToday ? (
|
||||
<span className="hint profile-like-hint">登录同一浏览器的用户中心后即可点赞(每人每天最多 {profileLikeDailyMax} 人)</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="profile-section-title">活动记录</div>
|
||||
<div className="profile-activity-row">
|
||||
|
||||
Reference in New Issue
Block a user