完善初始化更新

This commit is contained in:
2026-03-20 20:42:33 +08:00
parent 568ccb08fa
commit e6866feb29
39 changed files with 6986 additions and 2379 deletions

View File

@@ -0,0 +1,696 @@
import React, { useEffect, useState } from "react";
import {
API_BASE,
authClientFetchHeaders,
buildAuthCallbackUrl,
clearAuthClientContext,
fetchClientVisitMeta,
formatAuthBanMessage,
persistAuthClientFromFlow
} from "../config";
import UserPortalAuthSection from "./userPortal/UserPortalAuthSection";
import UserPortalProfileSection from "./userPortal/UserPortalProfileSection";
export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
const [account, setAccount] = useState("");
const [password, setPassword] = useState("");
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [mode, setMode] = useState("login");
const [registerForm, setRegisterForm] = useState({
account: "", password: "", username: "", email: "", code: "", inviteCode: ""
});
const [registrationRequireInvite, setRegistrationRequireInvite] = useState(false);
const [registerSent, setRegisterSent] = useState(false);
const [registerExpiresAt, setRegisterExpiresAt] = useState("");
const [registerLoading, setRegisterLoading] = useState(false);
const [registerError, setRegisterError] = useState("");
const [registerMessage, setRegisterMessage] = useState("");
const [resetForm, setResetForm] = useState({
account: "", email: "", code: "", newPassword: ""
});
const [resetSent, setResetSent] = useState(false);
const [resetExpiresAt, setResetExpiresAt] = useState("");
const [resetLoading, setResetLoading] = useState(false);
const [resetError, setResetError] = useState("");
const [resetMessage, setResetMessage] = useState("");
const [secondaryForm, setSecondaryForm] = useState({ email: "", code: "" });
const [secondarySent, setSecondarySent] = useState(false);
const [secondaryExpiresAt, setSecondaryExpiresAt] = useState("");
const [secondaryLoading, setSecondaryLoading] = useState(false);
const [secondaryError, setSecondaryError] = useState("");
const [secondaryMessage, setSecondaryMessage] = useState("");
const [profileForm, setProfileForm] = useState({
username: "", phone: "", avatarUrl: "", websiteUrl: "", bio: "", password: ""
});
const [profileLoading, setProfileLoading] = useState(false);
const [profileError, setProfileError] = useState("");
const [profileMessage, setProfileMessage] = useState("");
const [profileEditorOpen, setProfileEditorOpen] = useState(false);
const [profileEditorFocus, setProfileEditorFocus] = useState("");
const [secondaryEditorOpen, setSecondaryEditorOpen] = useState(false);
const [checkInReward, setCheckInReward] = useState(1);
const [checkInToday, setCheckInToday] = useState(false);
const [checkInLoading, setCheckInLoading] = useState(false);
const [checkInError, setCheckInError] = useState("");
const [checkInMessage, setCheckInMessage] = useState("");
const isAuthFlow = Boolean(authFlow?.redirectUri);
const redirectToAuthCallback = (tokenValue, userData, expiresAt = "") => {
if (!isAuthFlow || !authFlow?.redirectUri || !tokenValue) return;
const callbackUrl = buildAuthCallbackUrl(authFlow.redirectUri, {
token: tokenValue,
account: userData?.account || "",
username: userData?.username || "",
expiresAt,
state: authFlow.state || ""
});
window.location.replace(callbackUrl);
};
const clearTokenAndUser = () => {
localStorage.removeItem("sproutgate_token");
localStorage.removeItem("sproutgate_token_expires_at");
setUser(null);
};
const bearerHeaders = (tokenValue) => ({
...authClientFetchHeaders(),
Authorization: `Bearer ${tokenValue}`
});
const syncCurrentUser = async (tokenValue) => {
const baseHeaders = bearerHeaders(tokenValue);
const res = await fetch(`${API_BASE}/api/auth/me`, { headers: baseHeaders });
const data = await res.json();
if (!res.ok) {
if (res.status === 403) {
clearTokenAndUser();
const msg = formatAuthBanMessage(data);
setError(msg);
throw new Error(msg);
}
throw new Error(data.error || "加载用户失败");
}
if (data.user) setUser(data.user);
const checkIn = data.checkIn || {};
setCheckInReward(Number(checkIn.rewardCoins) || 1);
setCheckInToday(Boolean(checkIn.checkedInToday));
fetchClientVisitMeta()
.then((meta) => {
if (!meta.ip && !meta.displayLocation) return null;
const h = { ...baseHeaders };
if (meta.ip) h["X-Visit-Ip"] = meta.ip;
if (meta.displayLocation) h["X-Visit-Location"] = meta.displayLocation;
return fetch(`${API_BASE}/api/auth/me`, { headers: h });
})
.then(async (r) => {
if (!r) return null;
const d = await r.json().catch(() => ({}));
if (r.status === 403) {
clearTokenAndUser();
setError(formatAuthBanMessage(d));
return null;
}
return r.ok ? d : null;
})
.then((d) => {
if (!d) return;
if (d.user) setUser(d.user);
const c = d.checkIn;
if (c) {
setCheckInReward(Number(c.rewardCoins) || 1);
setCheckInToday(Boolean(c.checkedInToday));
}
})
.catch(() => {});
return data;
};
useEffect(() => {
let cancelled = false;
const done = () => { if (!cancelled && onReady) onReady(); };
const token = localStorage.getItem("sproutgate_token");
if (token) {
syncCurrentUser(token).catch(() => {}).finally(() => done());
} else {
done();
}
return () => { cancelled = true; };
}, [onReady]);
useEffect(() => {
persistAuthClientFromFlow(authFlow);
}, [authFlow]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch(`${API_BASE}/api/public/registration-policy`);
const data = await res.json().catch(() => ({}));
if (!cancelled && res.ok) {
setRegistrationRequireInvite(Boolean(data.requireInviteCode));
}
} catch {
/* 忽略,默认不要求邀请码 */
}
})();
return () => { cancelled = true; };
}, []);
useEffect(() => {
if (user) {
setProfileForm({
username: user.username || "",
phone: user.phone || "",
avatarUrl: user.avatarUrl || "",
websiteUrl: user.websiteUrl || "",
bio: user.bio || "",
password: ""
});
}
}, [user]);
const handleLogin = async (event) => {
event.preventDefault();
setLoading(true);
setError("");
try {
const loginPayload = { account, password };
const cid = (authFlow?.clientId || "").trim();
const cname = (authFlow?.clientName || "").trim();
if (cid) {
loginPayload.clientId = cid;
if (cname) loginPayload.clientName = cname;
} else {
const h = authClientFetchHeaders();
if (h["X-Auth-Client"]) {
loginPayload.clientId = h["X-Auth-Client"];
if (h["X-Auth-Client-Name"]) loginPayload.clientName = h["X-Auth-Client-Name"];
}
}
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(loginPayload)
});
const data = await res.json();
if (!res.ok) {
throw new Error(
res.status === 403 ? formatAuthBanMessage(data) : (data.error || "登录失败")
);
}
localStorage.setItem("sproutgate_token", data.token);
localStorage.setItem("sproutgate_token_expires_at", data.expiresAt || "");
setUser(data.user);
syncCurrentUser(data.token).catch(() => {});
setAccount("");
setPassword("");
if (isAuthFlow) {
redirectToAuthCallback(data.token, data.user, data.expiresAt || "");
return;
}
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleLogout = () => {
localStorage.removeItem("sproutgate_token");
localStorage.removeItem("sproutgate_token_expires_at");
clearAuthClientContext();
setUser(null);
setProfileEditorOpen(false);
setProfileEditorFocus("");
setSecondaryEditorOpen(false);
setCheckInReward(1);
setCheckInToday(false);
setCheckInError("");
setCheckInMessage("");
};
const handleContinueAuth = () => {
const tokenValue = localStorage.getItem("sproutgate_token") || "";
if (!tokenValue) {
setError("当前没有可用的登录会话,请先重新登录");
return;
}
redirectToAuthCallback(
tokenValue, user,
localStorage.getItem("sproutgate_token_expires_at") || ""
);
};
const handleSwitchAuthAccount = () => {
handleLogout();
setMode("login");
setAccount("");
setPassword("");
setError("");
};
const handleRegisterChange = (field, value) => {
setRegisterForm((prev) => ({ ...prev, [field]: value }));
};
const resetRegisterFlow = () => {
setRegisterSent(false);
setRegisterExpiresAt("");
setRegisterForm({ account: "", password: "", username: "", email: "", code: "", inviteCode: "" });
};
const handleSendCode = async (event) => {
event.preventDefault();
setRegisterLoading(true);
setRegisterError("");
setRegisterMessage("");
if (!registerForm.account || !registerForm.password || !registerForm.email) {
setRegisterError("请填写账户、密码和邮箱");
setRegisterLoading(false);
return;
}
if (registrationRequireInvite && !String(registerForm.inviteCode || "").trim()) {
setRegisterError("请输入邀请码");
setRegisterLoading(false);
return;
}
try {
const payload = {
account: registerForm.account,
password: registerForm.password,
username: registerForm.username,
email: registerForm.email
};
const inv = String(registerForm.inviteCode || "").trim();
if (inv) payload.inviteCode = inv;
const res = await fetch(`${API_BASE}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "发送验证码失败");
setRegisterSent(true);
setRegisterExpiresAt(data.expiresAt || "");
setRegisterMessage("验证码已发送,请检查邮箱");
} catch (err) {
setRegisterError(err.message);
} finally {
setRegisterLoading(false);
}
};
const handleVerifyRegister = async (event) => {
event.preventDefault();
setRegisterLoading(true);
setRegisterError("");
setRegisterMessage("");
if (!registerForm.account || !registerForm.code) {
setRegisterError("请输入账户与验证码");
setRegisterLoading(false);
return;
}
try {
const res = await fetch(`${API_BASE}/api/auth/verify-email`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ account: registerForm.account, code: registerForm.code })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "验证失败");
setRegisterMessage("注册成功,请使用账号登录");
setRegisterSent(false);
setRegisterForm({ account: "", password: "", username: "", email: "", code: "", inviteCode: "" });
setMode("login");
} catch (err) {
setRegisterError(err.message);
} finally {
setRegisterLoading(false);
}
};
const handleResetChange = (field, value) => {
setResetForm((prev) => ({ ...prev, [field]: value }));
};
const handleSendReset = async (event) => {
event.preventDefault();
setResetLoading(true);
setResetError("");
setResetMessage("");
if (!resetForm.account || !resetForm.email) {
setResetError("请填写账户与邮箱");
setResetLoading(false);
return;
}
try {
const res = await fetch(`${API_BASE}/api/auth/forgot-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ account: resetForm.account, email: resetForm.email })
});
const data = await res.json();
if (!res.ok) {
throw new Error(
res.status === 403 ? formatAuthBanMessage(data) : (data.error || "发送重置邮件失败")
);
}
setResetSent(true);
setResetExpiresAt(data.expiresAt || "");
setResetMessage("重置验证码已发送,请检查邮箱");
} catch (err) {
setResetError(err.message);
} finally {
setResetLoading(false);
}
};
const handleResetPassword = async (event) => {
event.preventDefault();
setResetLoading(true);
setResetError("");
setResetMessage("");
if (!resetForm.account || !resetForm.code || !resetForm.newPassword) {
setResetError("请填写账户、验证码与新密码");
setResetLoading(false);
return;
}
try {
const res = await fetch(`${API_BASE}/api/auth/reset-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
account: resetForm.account,
code: resetForm.code,
newPassword: resetForm.newPassword
})
});
const data = await res.json();
if (!res.ok) {
throw new Error(
res.status === 403 ? formatAuthBanMessage(data) : (data.error || "重置失败")
);
}
setResetMessage("密码已重置,请使用新密码登录");
setResetSent(false);
setResetForm({ account: "", email: "", code: "", newPassword: "" });
setMode("login");
} catch (err) {
setResetError(err.message);
} finally {
setResetLoading(false);
}
};
const handleSecondaryChange = (field, value) => {
setSecondaryForm((prev) => ({ ...prev, [field]: value }));
};
const handleSendSecondary = async (event) => {
event.preventDefault();
setSecondaryLoading(true);
setSecondaryError("");
setSecondaryMessage("");
const token = localStorage.getItem("sproutgate_token");
if (!token) { setSecondaryError("请先登录"); setSecondaryLoading(false); return; }
if (!secondaryForm.email) { setSecondaryError("请输入辅助邮箱"); setSecondaryLoading(false); return; }
try {
const res = await fetch(`${API_BASE}/api/auth/secondary-email/request`, {
method: "POST",
headers: { "Content-Type": "application/json", ...bearerHeaders(token) },
body: JSON.stringify({ email: secondaryForm.email })
});
const data = await res.json();
if (!res.ok) {
if (res.status === 403) {
clearTokenAndUser();
setSecondaryError(formatAuthBanMessage(data));
} else {
throw new Error(data.error || "发送验证码失败");
}
return;
}
setSecondarySent(true);
setSecondaryExpiresAt(data.expiresAt || "");
setSecondaryMessage("验证码已发送,请检查邮箱");
} catch (err) {
setSecondaryError(err.message);
} finally {
setSecondaryLoading(false);
}
};
const handleVerifySecondary = async (event) => {
event.preventDefault();
setSecondaryLoading(true);
setSecondaryError("");
setSecondaryMessage("");
const token = localStorage.getItem("sproutgate_token");
if (!token) { setSecondaryError("请先登录"); setSecondaryLoading(false); return; }
if (!secondaryForm.email || !secondaryForm.code) {
setSecondaryError("请输入邮箱与验证码");
setSecondaryLoading(false);
return;
}
try {
const res = await fetch(`${API_BASE}/api/auth/secondary-email/verify`, {
method: "POST",
headers: { "Content-Type": "application/json", ...bearerHeaders(token) },
body: JSON.stringify({ email: secondaryForm.email, code: secondaryForm.code })
});
const data = await res.json();
if (!res.ok) {
if (res.status === 403) {
clearTokenAndUser();
setSecondaryError(formatAuthBanMessage(data));
} else {
throw new Error(data.error || "验证失败");
}
return;
}
if (data.user) setUser(data.user);
setSecondaryMessage("辅助邮箱验证成功");
setSecondarySent(false);
setSecondaryForm({ email: "", code: "" });
setSecondaryEditorOpen(false);
} catch (err) {
setSecondaryError(err.message);
} finally {
setSecondaryLoading(false);
}
};
const handleProfileChange = (field, value) => {
setProfileForm((prev) => ({ ...prev, [field]: value }));
};
const handleProfileSave = async (event) => {
event.preventDefault();
setProfileLoading(true);
setProfileError("");
setProfileMessage("");
const token = localStorage.getItem("sproutgate_token");
if (!token) { setProfileError("请先登录"); setProfileLoading(false); return; }
const payload = {
username: profileForm.username,
phone: profileForm.phone,
avatarUrl: profileForm.avatarUrl,
websiteUrl: profileForm.websiteUrl,
bio: profileForm.bio
};
if (profileForm.password) payload.password = profileForm.password;
try {
const res = await fetch(`${API_BASE}/api/auth/profile`, {
method: "PUT",
headers: { "Content-Type": "application/json", ...bearerHeaders(token) },
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) {
if (res.status === 403) {
clearTokenAndUser();
setProfileError(formatAuthBanMessage(data));
} else {
throw new Error(data.error || "保存失败");
}
return;
}
setUser(data.user);
setProfileMessage("保存成功");
setProfileForm((prev) => ({ ...prev, password: "" }));
setProfileEditorOpen(false);
setProfileEditorFocus("");
} catch (err) {
setProfileError(err.message);
} finally {
setProfileLoading(false);
}
};
const openProfileEditor = (field = "") => {
setProfileError("");
setProfileMessage("");
setProfileEditorFocus(field);
setProfileEditorOpen(true);
};
const openSecondaryEditor = () => {
setSecondaryError("");
setSecondaryMessage("");
setSecondarySent(false);
setSecondaryForm({ email: "", code: "" });
setSecondaryEditorOpen(true);
};
const closeProfileEditor = () => {
setProfileEditorOpen(false);
setProfileEditorFocus("");
};
const closeSecondaryEditor = () => {
setSecondaryEditorOpen(false);
setSecondarySent(false);
setSecondaryForm({ email: "", code: "" });
setSecondaryError("");
setSecondaryMessage("");
};
const profileFocusLabels = {
phone: "手机号",
avatarUrl: "头像",
websiteUrl: "个人主页",
bio: "简介",
username: "用户名"
};
const profileModalTitle = profileEditorFocus && profileFocusLabels[profileEditorFocus]
? `修改${profileFocusLabels[profileEditorFocus]}`
: "修改资料";
const handleCheckIn = async () => {
const token = localStorage.getItem("sproutgate_token");
if (!token) { setCheckInError("请先登录"); return; }
setCheckInLoading(true);
setCheckInError("");
setCheckInMessage("");
try {
const res = await fetch(`${API_BASE}/api/auth/check-in`, {
method: "POST",
headers: bearerHeaders(token)
});
const data = await res.json();
if (!res.ok) {
if (res.status === 403) {
clearTokenAndUser();
setCheckInError(formatAuthBanMessage(data));
} else {
throw new Error(data.error || "签到失败");
}
return;
}
if (data.user) setUser(data.user);
const checkIn = data.checkIn || {};
setCheckInReward(Number(checkIn.rewardCoins) || 1);
setCheckInToday(Boolean(checkIn.checkedInToday));
setCheckInMessage(
data.message || (data.alreadyCheckedIn ? "今日已签到" : `签到成功,获得 ${data.awardedCoins ?? data.rewardCoins ?? 0} 萌芽币`)
);
} catch (err) {
setCheckInError(err.message);
} finally {
setCheckInLoading(false);
}
};
return (
<section className="panel">
<UserPortalAuthSection
isAuthFlow={isAuthFlow}
user={user}
onPreviewImage={onPreviewImage}
handleContinueAuth={handleContinueAuth}
handleSwitchAuthAccount={handleSwitchAuthAccount}
mode={mode}
setMode={setMode}
account={account}
setAccount={setAccount}
password={password}
setPassword={setPassword}
loading={loading}
error={error}
setError={setError}
handleLogin={handleLogin}
registerForm={registerForm}
handleRegisterChange={handleRegisterChange}
registerSent={registerSent}
registerExpiresAt={registerExpiresAt}
registerLoading={registerLoading}
registerError={registerError}
registerMessage={registerMessage}
handleSendCode={handleSendCode}
handleVerifyRegister={handleVerifyRegister}
setRegisterError={setRegisterError}
setRegisterMessage={setRegisterMessage}
registrationRequireInvite={registrationRequireInvite}
resetRegisterFlow={resetRegisterFlow}
resetForm={resetForm}
handleResetChange={handleResetChange}
resetSent={resetSent}
resetExpiresAt={resetExpiresAt}
resetLoading={resetLoading}
resetError={resetError}
resetMessage={resetMessage}
handleSendReset={handleSendReset}
handleResetPassword={handleResetPassword}
setResetError={setResetError}
setResetMessage={setResetMessage}
/>
{user && (
<UserPortalProfileSection
user={user}
onPreviewImage={onPreviewImage}
handleLogout={handleLogout}
openProfileEditor={openProfileEditor}
openSecondaryEditor={openSecondaryEditor}
profileEditorOpen={profileEditorOpen}
closeProfileEditor={closeProfileEditor}
handleProfileSave={handleProfileSave}
profileForm={profileForm}
handleProfileChange={handleProfileChange}
profileLoading={profileLoading}
profileError={profileError}
profileMessage={profileMessage}
profileModalTitle={profileModalTitle}
secondaryEditorOpen={secondaryEditorOpen}
closeSecondaryEditor={closeSecondaryEditor}
handleSendSecondary={handleSendSecondary}
handleVerifySecondary={handleVerifySecondary}
secondaryForm={secondaryForm}
handleSecondaryChange={handleSecondaryChange}
secondarySent={secondarySent}
secondaryLoading={secondaryLoading}
secondaryError={secondaryError}
secondaryMessage={secondaryMessage}
secondaryExpiresAt={secondaryExpiresAt}
checkInReward={checkInReward}
checkInToday={checkInToday}
checkInLoading={checkInLoading}
checkInError={checkInError}
checkInMessage={checkInMessage}
handleCheckIn={handleCheckIn}
/>
)}
</section>
);
}