215 lines
8.7 KiB
JavaScript
215 lines
8.7 KiB
JavaScript
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("");
|
||
|
||
const authHeaders = () => {
|
||
try {
|
||
const token = (localStorage.getItem("sproutgate_token") || "").trim();
|
||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||
} catch {
|
||
return {};
|
||
}
|
||
};
|
||
|
||
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]);
|
||
|
||
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 (
|
||
<section className="panel">
|
||
{loading && <div className="unified-page-loading">加载公开主页中…</div>}
|
||
{!loading && error && (
|
||
<div className="unified-page-miss">
|
||
<h2>未找到用户</h2>
|
||
<div className="error">{error}</div>
|
||
<div className="actions">
|
||
<a className="primary" href="/">返回首页</a>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{!loading && user && (
|
||
<div className="card profile">
|
||
<div className="profile-header">
|
||
<AvatarImg
|
||
user={user}
|
||
placeholderSize={160}
|
||
alt={displayName}
|
||
className="previewable-image"
|
||
previewable
|
||
onPreviewImage={onPreviewImage}
|
||
previewLabel={displayName}
|
||
/>
|
||
<div>
|
||
<h2>{displayName}</h2>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="profile-section-title profile-section-title--lead">基本信息</div>
|
||
<div className="profile-info-rows">
|
||
<InfoRow icon={icons.account} label="账户" value={user.account} />
|
||
<InfoRow icon={icons.username} label="用户名" value={user.username || "未填写"} />
|
||
<InfoRow icon={icons.calendar} label="注册时间" value={formatUserRegisteredAt(user.createdAt)} />
|
||
{user.websiteUrl ? (
|
||
<InfoRow icon={icons.link} label="个人主页">
|
||
<a
|
||
href={user.websiteUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="profile-external-link"
|
||
>
|
||
{formatWebsiteLabel(user.websiteUrl)}
|
||
</a>
|
||
</InfoRow>
|
||
) : null}
|
||
</div>
|
||
|
||
<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}`} />
|
||
<StatItem icon={icons.statLightning} label="连续签到" value={`${user.checkInStreak ?? 0}`} />
|
||
<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">
|
||
<span>最后签到 <strong>{user.lastCheckInAt || user.lastCheckInDate || "未签到"}</strong></span>
|
||
<span>最后访问 <strong>{user.lastVisitAt || user.lastVisitDate || "未访问"}</strong></span>
|
||
</div>
|
||
<div className="profile-activity-row profile-visit-meta">
|
||
<span>
|
||
最后访问 IP{" "}
|
||
<strong className="mono">{user.lastVisitIp || "暂无"}</strong>
|
||
</span>
|
||
<span>
|
||
最后位置 <strong>{user.lastVisitDisplayLocation || "暂无"}</strong>
|
||
</span>
|
||
</div>
|
||
|
||
<div className="profile-section-title">个人简介</div>
|
||
<div className="markdown profile-markdown">
|
||
<div
|
||
className="markdown-body"
|
||
dangerouslySetInnerHTML={{ __html: marked.parse(user.bio || "暂无简介") }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|