feat: 更新SproutGate前后端代码
This commit is contained in:
@@ -31,6 +31,8 @@ export default function AdminPanel({ onReady }) {
|
||||
const [regError, setRegError] = useState("");
|
||||
const [regMessage, setRegMessage] = useState("");
|
||||
const [requireInviteReg, setRequireInviteReg] = useState(false);
|
||||
const [forbiddenAccountsReg, setForbiddenAccountsReg] = useState("");
|
||||
const [inviteRegisterRewardReg, setInviteRegisterRewardReg] = useState(10);
|
||||
const [inviteList, setInviteList] = useState([]);
|
||||
const [newInvNote, setNewInvNote] = useState("");
|
||||
const [newInvMax, setNewInvMax] = useState(0);
|
||||
@@ -99,6 +101,11 @@ export default function AdminPanel({ onReady }) {
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "加载注册策略失败");
|
||||
setRequireInviteReg(Boolean(data.requireInviteCode));
|
||||
setForbiddenAccountsReg(
|
||||
typeof data.forbiddenAccounts === "string" ? data.forbiddenAccounts : ""
|
||||
);
|
||||
const ir = Number(data.inviteRegisterRewardCoins);
|
||||
setInviteRegisterRewardReg(Number.isFinite(ir) && ir >= 0 ? ir : 10);
|
||||
setInviteList(data.invites || []);
|
||||
} catch (err) {
|
||||
setRegError(err.message);
|
||||
@@ -116,11 +123,20 @@ export default function AdminPanel({ onReady }) {
|
||||
const res = await fetch(`${API_BASE}/api/admin/registration`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", "X-Admin-Token": token },
|
||||
body: JSON.stringify({ requireInviteCode: requireInviteReg })
|
||||
body: JSON.stringify({
|
||||
requireInviteCode: requireInviteReg,
|
||||
forbiddenAccounts: forbiddenAccountsReg,
|
||||
inviteRegisterRewardCoins: Math.max(0, Math.floor(Number(inviteRegisterRewardReg)) || 0)
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "保存失败");
|
||||
setRequireInviteReg(Boolean(data.requireInviteCode));
|
||||
if (typeof data.forbiddenAccounts === "string") {
|
||||
setForbiddenAccountsReg(data.forbiddenAccounts);
|
||||
}
|
||||
const irs = Number(data.inviteRegisterRewardCoins);
|
||||
if (Number.isFinite(irs) && irs >= 0) setInviteRegisterRewardReg(irs);
|
||||
setRegMessage("注册策略已保存");
|
||||
} catch (err) {
|
||||
setRegError(err.message);
|
||||
@@ -198,7 +214,7 @@ export default function AdminPanel({ onReady }) {
|
||||
sproutCoins: user.sproutCoins || 0,
|
||||
secondaryEmails: (user.secondaryEmails || []).join(","),
|
||||
phone: user.phone || "",
|
||||
avatarUrl: user.avatarUrl || "",
|
||||
avatarUrl: user.customAvatarUrl ?? "",
|
||||
websiteUrl: user.websiteUrl || "",
|
||||
bio: user.bio || "",
|
||||
banned: Boolean(user.banned),
|
||||
@@ -402,6 +418,26 @@ export default function AdminPanel({ onReady }) {
|
||||
<span>开启后,用户自助注册必须填写有效邀请码(管理员创建用户不受影响)</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="full-span">
|
||||
<IconLabel icon={icons.ban} text="禁止注册的账户名" hint="(逗号分隔,自助注册时完全匹配阻止;留空则使用内置默认列表)" />
|
||||
<textarea
|
||||
value={forbiddenAccountsReg}
|
||||
onChange={(e) => setForbiddenAccountsReg(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="sb,mail,example"
|
||||
disabled={!token || regLoading}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<IconLabel icon={icons.coins} text="邀请码注册奖励" hint="(萌芽币,填写邀请码并完成邮箱验证后发放;0 表示不奖励)" />
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={inviteRegisterRewardReg}
|
||||
onChange={(e) => setInviteRegisterRewardReg(Number(e.target.value))}
|
||||
disabled={!token || regLoading}
|
||||
/>
|
||||
</label>
|
||||
<div className="actions compact">
|
||||
<button type="button" className="primary" onClick={saveRegPolicy} disabled={!token || regSaving || regLoading}>
|
||||
{regSaving ? "保存中…" : "保存注册策略"}
|
||||
@@ -410,7 +446,7 @@ export default function AdminPanel({ onReady }) {
|
||||
{regLoading ? "加载中…" : "刷新列表"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="hint">公开接口 <code className="inline-code">GET /api/public/registration-policy</code> 供前端判断是否显示邀请码输入框。</div>
|
||||
<div className="hint">公开接口 <code className="inline-code">GET /api/public/registration-policy</code> 供前端判断是否显示邀请码输入框及邀请注册奖励说明。</div>
|
||||
{regError && <div className="error">{regError}</div>}
|
||||
{regMessage && <div className="success">{regMessage}</div>}
|
||||
|
||||
|
||||
70
sproutgate-frontend/src/components/AvatarImg.jsx
Normal file
70
sproutgate-frontend/src/components/AvatarImg.jsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { avatarImageCandidates, placeholderAvatarUrl } from "../avatar";
|
||||
|
||||
/**
|
||||
* 用户头像:支持自选 URL(含 GIF 动图)、API 解析后的 avatarUrl、QQ 数字邮箱时的多 CDN 回退。
|
||||
* 圆角裁切做在外层容器上,避免部分浏览器在圆角 img 上只播 GIF 第一帧。
|
||||
*/
|
||||
export default function AvatarImg({
|
||||
user,
|
||||
placeholderSize = 120,
|
||||
alt = "avatar",
|
||||
className = "",
|
||||
previewable = false,
|
||||
onPreviewImage,
|
||||
previewLabel
|
||||
}) {
|
||||
const chain = avatarImageCandidates(user, placeholderSize);
|
||||
const [idx, setIdx] = useState(0);
|
||||
const src = chain[Math.min(idx, chain.length - 1)] || placeholderAvatarUrl(placeholderSize);
|
||||
|
||||
useEffect(() => {
|
||||
setIdx(0);
|
||||
}, [
|
||||
user?.account,
|
||||
user?.customAvatarUrl,
|
||||
user?.avatarUrl,
|
||||
user?.email,
|
||||
(user?.secondaryEmails || []).join(",")
|
||||
]);
|
||||
|
||||
const handleError = () => {
|
||||
if (idx < chain.length - 1) setIdx((i) => i + 1);
|
||||
};
|
||||
|
||||
const handlePreview = () => {
|
||||
if (!onPreviewImage) return;
|
||||
const primary = chain[0] || "";
|
||||
onPreviewImage(primary, previewLabel || alt);
|
||||
};
|
||||
|
||||
const shellClass = ["avatar-shell", className].filter(Boolean).join(" ");
|
||||
|
||||
const previewProps = previewable
|
||||
? {
|
||||
role: "button",
|
||||
tabIndex: 0,
|
||||
"aria-label": previewLabel || alt || "查看头像",
|
||||
onClick: handlePreview,
|
||||
onKeyDown: (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handlePreview();
|
||||
}
|
||||
}
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<span className={shellClass} {...previewProps}>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="avatar-img"
|
||||
onError={handleError}
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
207
sproutgate-frontend/src/components/OAuthConsentScreen.jsx
Normal file
207
sproutgate-frontend/src/components/OAuthConsentScreen.jsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
buildAuthDenyCallbackUrl,
|
||||
formatOAuthRedirectLabel,
|
||||
formatWebsiteLabel
|
||||
} from "../config";
|
||||
import icons from "../icons";
|
||||
import { MailtoEmail } from "./common";
|
||||
import AvatarImg from "./AvatarImg";
|
||||
|
||||
const DEFAULT_PERMISSIONS = [
|
||||
{
|
||||
title: "账户标识",
|
||||
desc: "获取账户名(account),用于在该应用中唯一识别你的萌芽身份。"
|
||||
},
|
||||
{
|
||||
title: "基本资料",
|
||||
desc: "获取昵称、头像、邮箱、等级、萌芽币、个人主页与简介等(以「验证令牌」「当前用户」等接口实际返回为准)。"
|
||||
},
|
||||
{
|
||||
title: "访问令牌",
|
||||
desc: "向你选择的应用颁发 JWT;应用仅在令牌有效期内、经你同意后代表你请求萌芽账户认证中心接口,不会获得你的登录密码。"
|
||||
},
|
||||
{
|
||||
title: "应用接入记录",
|
||||
desc: "在「应用接入」中记录该应用的 client_id 与名称,便于你在用户中心查看已与哪些应用建立关联。"
|
||||
}
|
||||
];
|
||||
|
||||
function scopeLabel(s) {
|
||||
const lower = String(s || "").toLowerCase().trim();
|
||||
const map = {
|
||||
openid: "OpenID(账户标识)",
|
||||
profile: "个人资料(昵称、头像等展示信息)",
|
||||
email: "邮箱地址",
|
||||
offline_access: "离线访问(延长令牌有效期的扩展能力,若应用支持)"
|
||||
};
|
||||
return map[lower] || s;
|
||||
}
|
||||
|
||||
export default function OAuthConsentScreen({
|
||||
authFlow,
|
||||
user,
|
||||
onAllow,
|
||||
onSwitchAccount,
|
||||
onPreviewImage
|
||||
}) {
|
||||
const [permsOpen, setPermsOpen] = useState(true);
|
||||
const appLabel = useMemo(() => {
|
||||
const name = (authFlow?.clientName || "").trim();
|
||||
const id = (authFlow?.clientId || "").trim();
|
||||
if (name) return name;
|
||||
if (id) return id;
|
||||
return "第三方应用";
|
||||
}, [authFlow]);
|
||||
|
||||
const redirectLabel = formatOAuthRedirectLabel(authFlow?.redirectUri);
|
||||
const redirectUrl = (authFlow?.redirectUri || "").trim();
|
||||
|
||||
const extraScopes = useMemo(() => {
|
||||
const raw = authFlow?.scopes || [];
|
||||
if (!raw.length) return [];
|
||||
return raw.map(scopeLabel);
|
||||
}, [authFlow]);
|
||||
|
||||
const handleDeny = () => {
|
||||
const url = buildAuthDenyCallbackUrl(authFlow?.redirectUri, authFlow?.state || "");
|
||||
if (url) window.location.replace(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="oauth-consent">
|
||||
<div className="oauth-consent-brand">
|
||||
<div className="oauth-consent-icon-wrap" aria-hidden>
|
||||
<svg className="oauth-consent-lock" viewBox="0 0 24 24" width="28" height="28">
|
||||
<rect x="5" y="10" width="14" height="11" rx="2" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M8 10V8a4 4 0 0 1 8 0v2" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="oauth-consent-app-name">{appLabel}</h1>
|
||||
<p className="oauth-consent-lead">请求访问你的<strong>萌芽统一账户</strong></p>
|
||||
</div>
|
||||
|
||||
<div className="oauth-consent-card oauth-consent-card--identity">
|
||||
<div className="oauth-consent-identity">
|
||||
<AvatarImg
|
||||
user={user}
|
||||
placeholderSize={56}
|
||||
alt="avatar"
|
||||
className="oauth-consent-avatar previewable-image"
|
||||
previewable
|
||||
onPreviewImage={onPreviewImage}
|
||||
previewLabel={user?.username || user?.account || "avatar"}
|
||||
/>
|
||||
<div className="oauth-consent-id-text">
|
||||
<div className="oauth-consent-display">{user?.username || user?.account || "用户"}</div>
|
||||
<div className="oauth-consent-as">
|
||||
以 <span className="mono oauth-consent-account">@{user?.account}</span> 的身份授权
|
||||
</div>
|
||||
{user?.email ? (
|
||||
<div className="oauth-consent-email">
|
||||
<MailtoEmail address={user.email} className="profile-external-link">{user.email}</MailtoEmail>
|
||||
</div>
|
||||
) : (
|
||||
<div className="muted oauth-consent-email">未绑定邮箱</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="oauth-consent-card oauth-consent-card--detail">
|
||||
<div className="oauth-consent-section-label">应用信息</div>
|
||||
<ul className="oauth-consent-meta">
|
||||
<li>
|
||||
<span className="icon oauth-consent-meta-icon">{icons.link}</span>
|
||||
<div>
|
||||
<div className="oauth-consent-meta-key">回跳地址</div>
|
||||
{redirectUrl.match(/^https?:\/\//i) ? (
|
||||
<a
|
||||
href={redirectUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="oauth-consent-meta-link"
|
||||
>
|
||||
{redirectLabel}
|
||||
</a>
|
||||
) : (
|
||||
<span className="mono oauth-consent-meta-val">{redirectLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
{(authFlow?.clientId || "").trim() ? (
|
||||
<li>
|
||||
<span className="icon oauth-consent-meta-icon">{icons.apps}</span>
|
||||
<div>
|
||||
<div className="oauth-consent-meta-key">应用 ID(client_id)</div>
|
||||
<span className="mono oauth-consent-meta-val">{authFlow.clientId.trim()}</span>
|
||||
</div>
|
||||
</li>
|
||||
) : null}
|
||||
{(authFlow?.clientName || "").trim() ? (
|
||||
<li>
|
||||
<span className="icon oauth-consent-meta-icon">{icons.username}</span>
|
||||
<div>
|
||||
<div className="oauth-consent-meta-key">应用名称</div>
|
||||
<span className="oauth-consent-meta-val">{authFlow.clientName.trim()}</span>
|
||||
</div>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
|
||||
<div className="oauth-consent-section-label oauth-consent-section-label--perm">将获取以下信息与权限</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`oauth-consent-perm-toggle${permsOpen ? " is-open" : ""}`}
|
||||
onClick={() => setPermsOpen((v) => !v)}
|
||||
aria-expanded={permsOpen}
|
||||
>
|
||||
<span className="oauth-consent-perm-toggle-text">查看详情</span>
|
||||
<span className="oauth-consent-chevron" aria-hidden>▾</span>
|
||||
</button>
|
||||
{permsOpen && (
|
||||
<ul className="oauth-consent-perms">
|
||||
{DEFAULT_PERMISSIONS.map((p) => (
|
||||
<li key={p.title}>
|
||||
<span className="oauth-consent-perm-icon">{icons.token}</span>
|
||||
<div>
|
||||
<div className="oauth-consent-perm-title">{p.title}</div>
|
||||
<div className="oauth-consent-perm-desc">{p.desc}</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{extraScopes.length > 0 ? (
|
||||
<li>
|
||||
<span className="oauth-consent-perm-icon">{icons.level}</span>
|
||||
<div>
|
||||
<div className="oauth-consent-perm-title">应用请求的 scope</div>
|
||||
<ul className="oauth-consent-scope-list">
|
||||
{extraScopes.map((s) => (
|
||||
<li key={s}>{s}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="oauth-consent-actions">
|
||||
<button type="button" className="primary oauth-consent-allow" onClick={onAllow}>
|
||||
允许
|
||||
</button>
|
||||
<button type="button" className="ghost oauth-consent-deny" onClick={handleDeny}>
|
||||
拒绝
|
||||
</button>
|
||||
<button type="button" className="oauth-consent-switch text-button" onClick={onSwitchAccount}>
|
||||
使用其他账号登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="oauth-consent-footnote">
|
||||
点击「允许」后,你将返回应用 <span className="mono">{formatWebsiteLabel(redirectUrl) || redirectLabel}</span>,并在浏览器地址的 Hash 中携带访问令牌。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
sproutgate-frontend/src/components/PublicUserListPage.jsx
Normal file
95
sproutgate-frontend/src/components/PublicUserListPage.jsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { API_BASE, formatUserRegisteredAt } from "../config";
|
||||
import icons from "../icons";
|
||||
import AvatarImg from "./AvatarImg";
|
||||
|
||||
export default function PublicUserListPage({ onReady, onPreviewImage }) {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/public/users`);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "加载用户列表失败");
|
||||
if (!cancelled) {
|
||||
setUsers(Array.isArray(data.users) ? data.users : []);
|
||||
setTotal(Number(data.total) || 0);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err.message);
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
onReady?.();
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [onReady]);
|
||||
|
||||
return (
|
||||
<section className="panel public-user-list-page">
|
||||
<div className="public-user-list-head">
|
||||
<h2 className="public-user-list-title">公开用户</h2>
|
||||
<p className="public-user-list-lead">
|
||||
按注册时间从早到晚排列(共 {total} 位)
|
||||
</p>
|
||||
</div>
|
||||
{loading && <div className="unified-page-loading">加载中…</div>}
|
||||
{!loading && error && (
|
||||
<div className="error public-user-list-error" role="alert">{error}</div>
|
||||
)}
|
||||
{!loading && !error && users.length === 0 && (
|
||||
<div className="hint">暂无公开用户</div>
|
||||
)}
|
||||
{!loading && !error && users.length > 0 && (
|
||||
<ul className="public-user-list">
|
||||
{users.map((u) => {
|
||||
const name = (u.username || "").trim() || u.account;
|
||||
const profileUser = {
|
||||
account: u.account,
|
||||
username: u.username,
|
||||
avatarUrl: u.avatarUrl
|
||||
};
|
||||
return (
|
||||
<li key={u.account}>
|
||||
<a className="public-user-list-row" href={`/user/${encodeURIComponent(u.account)}`}>
|
||||
<AvatarImg
|
||||
user={profileUser}
|
||||
placeholderSize={56}
|
||||
alt={name}
|
||||
className="public-user-list-avatar previewable-image"
|
||||
previewable
|
||||
onPreviewImage={onPreviewImage}
|
||||
previewLabel={name}
|
||||
/>
|
||||
<div className="public-user-list-meta">
|
||||
<span className="public-user-list-name">{name}</span>
|
||||
<span className="public-user-list-account mono">{u.account}</span>
|
||||
<span className="public-user-list-sub">
|
||||
<span className="icon stat-item-icon">{icons.level}</span>
|
||||
Lv.{u.level ?? 0}
|
||||
<span className="public-user-list-sub-sep" aria-hidden />
|
||||
<span className="icon stat-item-icon">{icons.coins}</span>
|
||||
{u.sproutCoins ?? 0}
|
||||
<span className="public-user-list-sub-sep" aria-hidden />
|
||||
注册 {formatUserRegisteredAt(u.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="public-user-list-chevron" aria-hidden>›</span>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
36
sproutgate-frontend/src/components/RandomSiteBackground.jsx
Normal file
36
sproutgate-frontend/src/components/RandomSiteBackground.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { RAND_BG_API_ORIGIN } from "../config";
|
||||
|
||||
/**
|
||||
* 固定全视口随机背景:拉取 randbg JSON 后铺图,高斯模糊强度为 UI 参考半径的 20%(见 CSS 变量)。
|
||||
*/
|
||||
export default function RandomSiteBackground() {
|
||||
const [url, setUrl] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
const endpoint = `${RAND_BG_API_ORIGIN}/api/random?format=json&mode=auto`;
|
||||
const res = await fetch(endpoint, { credentials: "omit" });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const next = typeof data.url === "string" ? data.url.trim() : "";
|
||||
if (!cancelled && next) setUrl(next);
|
||||
} catch {
|
||||
/* 失败时仅保留 body 纯色底 */
|
||||
}
|
||||
};
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
if (!url) return null;
|
||||
|
||||
return (
|
||||
<div className="app-rand-bg" aria-hidden>
|
||||
<img src={url} alt="" className="app-rand-bg__img" decoding="async" />
|
||||
<div className="app-rand-bg__veil" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,12 +6,15 @@ import {
|
||||
clearAuthClientContext,
|
||||
fetchClientVisitMeta,
|
||||
formatAuthBanMessage,
|
||||
persistAuthClientFromFlow
|
||||
normalizeSelfServiceAccountInput,
|
||||
persistAuthClientFromFlow,
|
||||
randomSelfServiceAccount
|
||||
} from "../config";
|
||||
import UserPortalAuthSection from "./userPortal/UserPortalAuthSection";
|
||||
import UserPortalProfileSection from "./userPortal/UserPortalProfileSection";
|
||||
import OAuthConsentScreen from "./OAuthConsentScreen";
|
||||
|
||||
export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
|
||||
export default function UserPortal({ onReady, authFlow, onPreviewImage, oauthStandalone = false }) {
|
||||
const [account, setAccount] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [user, setUser] = useState(null);
|
||||
@@ -23,6 +26,9 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
|
||||
account: "", password: "", username: "", email: "", code: "", inviteCode: ""
|
||||
});
|
||||
const [registrationRequireInvite, setRegistrationRequireInvite] = useState(false);
|
||||
const [inviteRegisterReward, setInviteRegisterReward] = useState(10);
|
||||
const [registrationAccountMin, setRegistrationAccountMin] = useState(3);
|
||||
const [registrationAccountMax, setRegistrationAccountMax] = useState(32);
|
||||
const [registerSent, setRegisterSent] = useState(false);
|
||||
const [registerExpiresAt, setRegisterExpiresAt] = useState("");
|
||||
const [registerLoading, setRegisterLoading] = useState(false);
|
||||
@@ -79,6 +85,9 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
|
||||
localStorage.removeItem("sproutgate_token");
|
||||
localStorage.removeItem("sproutgate_token_expires_at");
|
||||
setUser(null);
|
||||
setProfileForm({
|
||||
username: "", phone: "", avatarUrl: "", websiteUrl: "", bio: "", password: ""
|
||||
});
|
||||
};
|
||||
|
||||
const bearerHeaders = (tokenValue) => ({
|
||||
@@ -160,6 +169,12 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!cancelled && res.ok) {
|
||||
setRegistrationRequireInvite(Boolean(data.requireInviteCode));
|
||||
const ir = Number(data.inviteRegisterRewardCoins);
|
||||
if (Number.isFinite(ir) && ir >= 0) setInviteRegisterReward(ir);
|
||||
const am = Number(data.selfServiceAccountMax);
|
||||
const ami = Number(data.selfServiceAccountMin);
|
||||
if (Number.isFinite(am) && am >= 3 && am <= 32) setRegistrationAccountMax(am);
|
||||
if (Number.isFinite(ami) && ami >= 3 && ami <= 32) setRegistrationAccountMin(ami);
|
||||
}
|
||||
} catch {
|
||||
/* 忽略,默认不要求邀请码 */
|
||||
@@ -168,18 +183,18 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// 仅在外部 user 更新且资料弹窗关闭时同步表单,避免 /api/auth/me 二次请求在编辑期间把表单项覆盖回服务器值(QQ 用户会把已填写的自定义头像链接清空)
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setProfileForm({
|
||||
username: user.username || "",
|
||||
phone: user.phone || "",
|
||||
avatarUrl: user.avatarUrl || "",
|
||||
websiteUrl: user.websiteUrl || "",
|
||||
bio: user.bio || "",
|
||||
password: ""
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
if (!user || profileEditorOpen) return;
|
||||
setProfileForm({
|
||||
username: user.username || "",
|
||||
phone: user.phone || "",
|
||||
avatarUrl: user.customAvatarUrl ?? "",
|
||||
websiteUrl: user.websiteUrl || "",
|
||||
bio: user.bio || "",
|
||||
password: ""
|
||||
});
|
||||
}, [user, profileEditorOpen]);
|
||||
|
||||
const handleLogin = async (event) => {
|
||||
event.preventDefault();
|
||||
@@ -216,7 +231,7 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
|
||||
syncCurrentUser(data.token).catch(() => {});
|
||||
setAccount("");
|
||||
setPassword("");
|
||||
if (isAuthFlow) {
|
||||
if (isAuthFlow && !oauthStandalone) {
|
||||
redirectToAuthCallback(data.token, data.user, data.expiresAt || "");
|
||||
return;
|
||||
}
|
||||
@@ -228,10 +243,8 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem("sproutgate_token");
|
||||
localStorage.removeItem("sproutgate_token_expires_at");
|
||||
clearAuthClientContext();
|
||||
setUser(null);
|
||||
clearTokenAndUser();
|
||||
setProfileEditorOpen(false);
|
||||
setProfileEditorFocus("");
|
||||
setSecondaryEditorOpen(false);
|
||||
@@ -262,13 +275,23 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
|
||||
};
|
||||
|
||||
const handleRegisterChange = (field, value) => {
|
||||
if (field === "account") {
|
||||
value = normalizeSelfServiceAccountInput(value, registrationAccountMax);
|
||||
}
|
||||
setRegisterForm((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const resetRegisterFlow = () => {
|
||||
setRegisterSent(false);
|
||||
setRegisterExpiresAt("");
|
||||
setRegisterForm({ account: "", password: "", username: "", email: "", code: "", inviteCode: "" });
|
||||
setRegisterForm({
|
||||
account: randomSelfServiceAccount(10),
|
||||
password: "",
|
||||
username: "",
|
||||
email: "",
|
||||
code: "",
|
||||
inviteCode: ""
|
||||
});
|
||||
};
|
||||
|
||||
const handleSendCode = async (event) => {
|
||||
@@ -281,6 +304,11 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
|
||||
setRegisterLoading(false);
|
||||
return;
|
||||
}
|
||||
if (registerForm.account.length < registrationAccountMin) {
|
||||
setRegisterError(`账户至少 ${registrationAccountMin} 位,仅小写字母与数字`);
|
||||
setRegisterLoading(false);
|
||||
return;
|
||||
}
|
||||
if (registrationRequireInvite && !String(registerForm.inviteCode || "").trim()) {
|
||||
setRegisterError("请输入邀请码");
|
||||
setRegisterLoading(false);
|
||||
@@ -330,7 +358,12 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "验证失败");
|
||||
setRegisterMessage("注册成功,请使用账号登录");
|
||||
const coins = Number(data.user?.sproutCoins);
|
||||
setRegisterMessage(
|
||||
Number.isFinite(coins) && coins > 0
|
||||
? `注册成功,已获得 ${coins} 萌芽币,请使用账号登录`
|
||||
: "注册成功,请使用账号登录"
|
||||
);
|
||||
setRegisterSent(false);
|
||||
setRegisterForm({ account: "", password: "", username: "", email: "", code: "", inviteCode: "" });
|
||||
setMode("login");
|
||||
@@ -542,6 +575,16 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
|
||||
const openProfileEditor = (field = "") => {
|
||||
setProfileError("");
|
||||
setProfileMessage("");
|
||||
if (user) {
|
||||
setProfileForm({
|
||||
username: user.username || "",
|
||||
phone: user.phone || "",
|
||||
avatarUrl: user.customAvatarUrl ?? "",
|
||||
websiteUrl: user.websiteUrl || "",
|
||||
bio: user.bio || "",
|
||||
password: ""
|
||||
});
|
||||
}
|
||||
setProfileEditorFocus(field);
|
||||
setProfileEditorOpen(true);
|
||||
};
|
||||
@@ -613,14 +656,26 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
|
||||
}
|
||||
};
|
||||
|
||||
if (oauthStandalone && !authFlow?.redirectUri?.trim()) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="card form oauth-authorize-invalid">
|
||||
<h2>无效授权链接</h2>
|
||||
<p className="hint">缺少回跳地址(redirect_uri 或 return_url)。请从第三方应用重新发起登录。</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const showOAuthConsent = oauthStandalone && isAuthFlow && Boolean(user);
|
||||
const showProfile = Boolean(user) && !showOAuthConsent;
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<UserPortalAuthSection
|
||||
isAuthFlow={isAuthFlow}
|
||||
oauthStandalone={oauthStandalone}
|
||||
user={user}
|
||||
onPreviewImage={onPreviewImage}
|
||||
handleContinueAuth={handleContinueAuth}
|
||||
handleSwitchAuthAccount={handleSwitchAuthAccount}
|
||||
mode={mode}
|
||||
setMode={setMode}
|
||||
account={account}
|
||||
@@ -643,6 +698,9 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
|
||||
setRegisterError={setRegisterError}
|
||||
setRegisterMessage={setRegisterMessage}
|
||||
registrationRequireInvite={registrationRequireInvite}
|
||||
inviteRegisterReward={inviteRegisterReward}
|
||||
registrationAccountMin={registrationAccountMin}
|
||||
registrationAccountMax={registrationAccountMax}
|
||||
resetRegisterFlow={resetRegisterFlow}
|
||||
resetForm={resetForm}
|
||||
handleResetChange={handleResetChange}
|
||||
@@ -656,7 +714,16 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
|
||||
setResetError={setResetError}
|
||||
setResetMessage={setResetMessage}
|
||||
/>
|
||||
{user && (
|
||||
{showOAuthConsent && (
|
||||
<OAuthConsentScreen
|
||||
authFlow={authFlow}
|
||||
user={user}
|
||||
onAllow={handleContinueAuth}
|
||||
onSwitchAccount={handleSwitchAuthAccount}
|
||||
onPreviewImage={onPreviewImage}
|
||||
/>
|
||||
)}
|
||||
{showProfile && (
|
||||
<UserPortalProfileSection
|
||||
user={user}
|
||||
onPreviewImage={onPreviewImage}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import React from "react";
|
||||
import icons from "../../icons";
|
||||
import { IconLabel, MailtoEmail } from "../common";
|
||||
import { IconLabel } from "../common";
|
||||
|
||||
export default function UserPortalAuthSection({
|
||||
isAuthFlow,
|
||||
oauthStandalone = false,
|
||||
user,
|
||||
onPreviewImage,
|
||||
handleContinueAuth,
|
||||
handleSwitchAuthAccount,
|
||||
mode,
|
||||
setMode,
|
||||
account,
|
||||
@@ -30,6 +28,9 @@ export default function UserPortalAuthSection({
|
||||
setRegisterError,
|
||||
setRegisterMessage,
|
||||
registrationRequireInvite,
|
||||
inviteRegisterReward = 10,
|
||||
registrationAccountMin = 3,
|
||||
registrationAccountMax = 32,
|
||||
resetRegisterFlow,
|
||||
resetForm,
|
||||
handleResetChange,
|
||||
@@ -45,45 +46,16 @@ export default function UserPortalAuthSection({
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{isAuthFlow && user && (
|
||||
<div className="card form">
|
||||
<h2>第三方登录授权</h2>
|
||||
<div className="hint">外部应用正在请求使用当前萌芽统一账户认证登录。</div>
|
||||
<div className="profile-header" style={{ marginTop: "12px" }}>
|
||||
<img
|
||||
src={user.avatarUrl || "https://dummyimage.com/120x120/ddd/fff&text=Avatar"}
|
||||
alt="avatar"
|
||||
className="previewable-image"
|
||||
onClick={() => onPreviewImage?.(user.avatarUrl || "", user.username || user.account || "avatar")}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
/>
|
||||
<div>
|
||||
<h3>{user.username || user.account}</h3>
|
||||
<p>{user.account}</p>
|
||||
<p>
|
||||
{user.email ? (
|
||||
<MailtoEmail address={user.email} className="profile-external-link">{user.email}</MailtoEmail>
|
||||
) : (
|
||||
"未填写邮箱"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button type="button" className="primary" onClick={handleContinueAuth}>继续授权</button>
|
||||
<button type="button" className="ghost" onClick={handleSwitchAuthAccount}>切换账号</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!user && mode === "login" && (
|
||||
<form className="card form auth-card" onSubmit={handleLogin}>
|
||||
<div className="auth-form-head">
|
||||
<h2>登录</h2>
|
||||
<p className="auth-form-lead">使用萌芽统一账户进入用户中心</p>
|
||||
{isAuthFlow && (
|
||||
<div className="hint auth-form-hint">你正在通过统一登录为外部应用授权,登录后将自动返回。</div>
|
||||
{isAuthFlow && oauthStandalone && (
|
||||
<div className="hint auth-form-hint">登录成功后,需在下一页确认向第三方应用授予的权限,再返回应用。</div>
|
||||
)}
|
||||
{isAuthFlow && !oauthStandalone && (
|
||||
<div className="hint auth-form-hint">你正在通过统一登录为外部应用授权。</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="auth-form-body">
|
||||
@@ -139,8 +111,17 @@ export default function UserPortalAuthSection({
|
||||
</div>
|
||||
<div className="auth-form-body">
|
||||
<label className="auth-field">
|
||||
<IconLabel icon={icons.account} text="账户" />
|
||||
<input value={registerForm.account} onChange={(e) => handleRegisterChange("account", e.target.value)} placeholder="请输入账户" />
|
||||
<IconLabel
|
||||
icon={icons.account}
|
||||
text="账户"
|
||||
hint={`(${registrationAccountMin}–${registrationAccountMax} 位小写字母与数字)`}
|
||||
/>
|
||||
<input
|
||||
value={registerForm.account}
|
||||
onChange={(e) => handleRegisterChange("account", e.target.value)}
|
||||
placeholder="已随机生成,可改写"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label className="auth-field">
|
||||
<IconLabel icon={icons.password} text="密码" />
|
||||
@@ -170,6 +151,9 @@ export default function UserPortalAuthSection({
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{!registerSent && inviteRegisterReward > 0 && (
|
||||
<div className="hint">使用有效邀请码完成注册后,将获得 {inviteRegisterReward} 萌芽币。</div>
|
||||
)}
|
||||
{registerSent && (
|
||||
<label className="auth-field">
|
||||
<IconLabel icon={icons.token} text="邮箱验证码" />
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React from "react";
|
||||
import { marked, formatWebsiteLabel, formatUserRegisteredAt, formatIsoDateTimeReadable } from "../../config";
|
||||
import { avatarStatusLabel } from "../../avatar";
|
||||
import icons from "../../icons";
|
||||
import { IconLabel, MailtoEmail, StatItem, InfoRow } from "../common";
|
||||
import AvatarImg from "../AvatarImg";
|
||||
|
||||
export default function UserPortalProfileSection({
|
||||
user,
|
||||
@@ -39,13 +41,14 @@ export default function UserPortalProfileSection({
|
||||
return (
|
||||
<div className="card profile">
|
||||
<div className="profile-header">
|
||||
<img
|
||||
src={user.avatarUrl || "https://dummyimage.com/120x120/ddd/fff&text=Avatar"}
|
||||
<AvatarImg
|
||||
user={user}
|
||||
placeholderSize={120}
|
||||
alt="avatar"
|
||||
className="previewable-image"
|
||||
onClick={() => onPreviewImage?.(user.avatarUrl || "", user.username || user.account || "avatar")}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
previewable
|
||||
onPreviewImage={onPreviewImage}
|
||||
previewLabel={user.username || user.account || "avatar"}
|
||||
/>
|
||||
<div>
|
||||
<h2>{user.username || user.account}</h2>
|
||||
@@ -79,7 +82,7 @@ export default function UserPortalProfileSection({
|
||||
)}
|
||||
</InfoRow>
|
||||
<InfoRow icon={icons.phone} label="手机号" value={user.phone || "未填写"} actionLabel="修改" onAction={() => openProfileEditor("phone")} />
|
||||
<InfoRow icon={icons.avatar} label="头像" value={user.avatarUrl ? "已设置" : "未填写"} actionLabel="修改" onAction={() => openProfileEditor("avatarUrl")} />
|
||||
<InfoRow icon={icons.avatar} label="头像" value={avatarStatusLabel(user)} actionLabel="修改" onAction={() => openProfileEditor("avatarUrl")} />
|
||||
<InfoRow icon={icons.link} label="个人主页" actionLabel="修改" onAction={() => openProfileEditor("websiteUrl")}>
|
||||
{user.websiteUrl ? (
|
||||
<a
|
||||
|
||||
Reference in New Issue
Block a user