feat: 更新SproutGate前后端代码
This commit is contained in:
@@ -1,18 +1,57 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { API_BASE, getPublicAccountFromPathname, getAuthFlowFromSearch, LOGO_192_SRC } from "./config";
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
API_BASE,
|
||||
buildAuthDenyCallbackUrl,
|
||||
getPublicAccountFromPathname,
|
||||
getAuthFlowFromSearch,
|
||||
LOGO_192_SRC
|
||||
} from "./config";
|
||||
import SplashScreen from "./components/SplashScreen";
|
||||
import RandomSiteBackground from "./components/RandomSiteBackground";
|
||||
import PublicUserPage from "./components/PublicUserPage";
|
||||
import PublicUserListPage from "./components/PublicUserListPage";
|
||||
import UserPortal from "./components/UserPortal";
|
||||
import AdminPanel from "./components/AdminPanel";
|
||||
|
||||
function App() {
|
||||
const pathname = window.location.pathname;
|
||||
const search = window.location.search;
|
||||
const basePrefix = useMemo(() => {
|
||||
const baseUrl = import.meta.env.BASE_URL || "/";
|
||||
return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
||||
}, []);
|
||||
const authorizePathname = useMemo(() => {
|
||||
try {
|
||||
return new URL("authorize", `http://x${basePrefix}`).pathname;
|
||||
} catch {
|
||||
return "/authorize";
|
||||
}
|
||||
}, [basePrefix]);
|
||||
|
||||
const isAdmin = pathname.startsWith("/admin");
|
||||
const isPublicUserList = pathname === "/users";
|
||||
const isPublicUser = pathname === "/user" || pathname.startsWith("/user/");
|
||||
const isAuthorizeRoute = pathname === authorizePathname;
|
||||
const authFlow = useMemo(() => getAuthFlowFromSearch(search), [search]);
|
||||
const publicAccount = useMemo(() => getPublicAccountFromPathname(pathname), [pathname]);
|
||||
|
||||
const authDenyHref = useMemo(() => {
|
||||
const r = (authFlow?.redirectUri || "").trim();
|
||||
if (!r) return "";
|
||||
return buildAuthDenyCallbackUrl(r, authFlow?.state || "");
|
||||
}, [authFlow]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const params = new URLSearchParams(search);
|
||||
const hasRedirect = (params.get("redirect_uri") || params.get("return_url") || "").trim();
|
||||
if (!hasRedirect) return;
|
||||
if (isAuthorizeRoute) return;
|
||||
if (isAdmin || isPublicUserList || isPublicUser) return;
|
||||
const target = new URL("authorize", `${window.location.origin}${basePrefix}`);
|
||||
target.search = search;
|
||||
window.location.replace(target.href);
|
||||
}, [search, pathname, isAuthorizeRoute, isAdmin, isPublicUserList, isPublicUser, basePrefix]);
|
||||
|
||||
const [booting, setBooting] = useState(true);
|
||||
const markReady = useMemo(() => () => setBooting(false), []);
|
||||
|
||||
@@ -147,17 +186,50 @@ function App() {
|
||||
</div>
|
||||
);
|
||||
|
||||
const homeHref = import.meta.env.BASE_URL || "/";
|
||||
const usersHref = useMemo(() => {
|
||||
try {
|
||||
return new URL("users", `${window.location.origin}${basePrefix}`).pathname;
|
||||
} catch {
|
||||
return "/users";
|
||||
}
|
||||
}, [basePrefix]);
|
||||
|
||||
const headerNav = (
|
||||
<nav>
|
||||
<a href="/">用户中心</a>
|
||||
<a href={homeHref}>用户中心</a>
|
||||
<a href={usersHref}>用户列表</a>
|
||||
</nav>
|
||||
);
|
||||
|
||||
const headerNavAuthorize = (
|
||||
<nav className="oauth-authorize-nav">
|
||||
<a href={homeHref}>用户中心</a>
|
||||
{authDenyHref ? (
|
||||
<a href={authDenyHref} className="oauth-header-deny">拒绝 / 取消</a>
|
||||
) : null}
|
||||
</nav>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<RandomSiteBackground />
|
||||
{booting && <SplashScreen />}
|
||||
<div className="app">
|
||||
{isPublicUser ? (
|
||||
{isPublicUserList ? (
|
||||
<div className="unified-user-shell">
|
||||
<div className="unified-user-card">
|
||||
<div className="unified-user-card-accent" aria-hidden />
|
||||
<header className="unified-user-header">
|
||||
{headerBrand}
|
||||
{headerNav}
|
||||
</header>
|
||||
<div className="unified-user-main">
|
||||
<PublicUserListPage onReady={markReady} onPreviewImage={openImagePreview} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : isPublicUser ? (
|
||||
<div className="unified-user-shell">
|
||||
<div className="unified-user-card">
|
||||
<div className="unified-user-card-accent" aria-hidden />
|
||||
@@ -183,6 +255,24 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : isAuthorizeRoute ? (
|
||||
<div className="unified-user-shell oauth-authorize-shell">
|
||||
<div className="unified-user-card oauth-authorize-card">
|
||||
<div className="unified-user-card-accent" aria-hidden />
|
||||
<header className="unified-user-header oauth-authorize-header">
|
||||
{headerBrand}
|
||||
{headerNavAuthorize}
|
||||
</header>
|
||||
<div className="unified-user-main oauth-authorize-main">
|
||||
<UserPortal
|
||||
onReady={markReady}
|
||||
authFlow={authFlow}
|
||||
onPreviewImage={openImagePreview}
|
||||
oauthStandalone
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="unified-user-shell">
|
||||
<div className="unified-user-card">
|
||||
|
||||
61
sproutgate-frontend/src/avatar.js
Normal file
61
sproutgate-frontend/src/avatar.js
Normal file
@@ -0,0 +1,61 @@
|
||||
/** 纯数字 @qq.com(大小写不敏感)→ QQ 号码,用于与后端一致的展示逻辑 */
|
||||
|
||||
export function qqUinFromEmail(email) {
|
||||
if (!email || typeof email !== "string") return "";
|
||||
const e = email.trim().toLowerCase();
|
||||
const at = e.lastIndexOf("@");
|
||||
if (at <= 0 || at >= e.length - 1) return "";
|
||||
const local = e.slice(0, at);
|
||||
const domain = e.slice(at + 1);
|
||||
if (domain !== "qq.com" || !local) return "";
|
||||
if (!/^\d+$/.test(local)) return "";
|
||||
return local;
|
||||
}
|
||||
|
||||
export function qqUinFromUser(user) {
|
||||
if (!user || typeof user !== "object") return "";
|
||||
const primary = qqUinFromEmail(user.email);
|
||||
if (primary) return primary;
|
||||
for (const em of user.secondaryEmails || []) {
|
||||
const u = qqUinFromEmail(em);
|
||||
if (u) return u;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** 与后端 ResolvedAvatarURL 一致的三档 QQ CDN(依次作为 img onError 回退) */
|
||||
export function qqAvatarUrlCandidates(uin) {
|
||||
if (!uin) return [];
|
||||
return [
|
||||
`https://q.qlogo.cn/headimg_dl?dst_uin=${encodeURIComponent(uin)}&spec=640&img_type=jpg`,
|
||||
`https://q1.qlogo.cn/g?b=qq&nk=${encodeURIComponent(uin)}&s=100`,
|
||||
`https://q2.qlogo.cn/headimg_dl?dst_uin=${encodeURIComponent(uin)}&spec=100`
|
||||
];
|
||||
}
|
||||
|
||||
export function placeholderAvatarUrl(size) {
|
||||
return `https://dummyimage.com/${size}x${size}/ddd/fff&text=Avatar`;
|
||||
}
|
||||
|
||||
/** 用于 <img src>:自选链接优先,否则 QQ 邮箱推断的多 CDN,否则 API 下发的 avatarUrl,最后占位图 */
|
||||
export function avatarImageCandidates(user, placeholderSize = 120) {
|
||||
const custom = (user?.customAvatarUrl != null && String(user.customAvatarUrl).trim()) || "";
|
||||
if (custom) return [custom];
|
||||
const uin = qqUinFromUser(user);
|
||||
if (uin) return qqAvatarUrlCandidates(uin);
|
||||
const resolved = (user?.avatarUrl != null && String(user.avatarUrl).trim()) || "";
|
||||
if (resolved) return [resolved];
|
||||
return [placeholderAvatarUrl(placeholderSize)];
|
||||
}
|
||||
|
||||
export function resolveDisplayAvatarUrl(user, placeholderSize = 120) {
|
||||
const c = avatarImageCandidates(user, placeholderSize);
|
||||
return c[0] || placeholderAvatarUrl(placeholderSize);
|
||||
}
|
||||
|
||||
export function avatarStatusLabel(user) {
|
||||
const custom = (user?.customAvatarUrl != null && String(user.customAvatarUrl).trim()) || "";
|
||||
if (custom) return "已设置";
|
||||
if (qqUinFromUser(user)) return "QQ 邮箱头像";
|
||||
return "未填写";
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,32 @@ export const API_BASE = (() => {
|
||||
/** `public/logo192.png`,含 Vite `base` 前缀,避免子路径部署时顶栏/开屏裂图 */
|
||||
export const LOGO_192_SRC = `${import.meta.env.BASE_URL}logo192.png`;
|
||||
|
||||
/** 全站随机背景(`GET /api/random?format=json`,见 https://randbg.api.smyhub.com) */
|
||||
export const RAND_BG_API_ORIGIN = "https://randbg.api.smyhub.com";
|
||||
|
||||
const SELF_SERVICE_ACCOUNT_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
|
||||
/** 与自助注册规则一致:随机小写字母与数字(默认长度 10) */
|
||||
export function randomSelfServiceAccount(length = 10) {
|
||||
const n = Math.max(3, Math.min(32, Number(length) || 10));
|
||||
const buf = new Uint8Array(n);
|
||||
crypto.getRandomValues(buf);
|
||||
let s = "";
|
||||
for (let i = 0; i < n; i++) {
|
||||
s += SELF_SERVICE_ACCOUNT_ALPHABET[buf[i] % SELF_SERVICE_ACCOUNT_ALPHABET.length];
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 输入时规范为自助注册允许的字符(小写、数字),超长截断 */
|
||||
export function normalizeSelfServiceAccountInput(raw, maxLen = 32) {
|
||||
const m = Math.max(3, Math.min(32, maxLen));
|
||||
return String(raw || "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, "")
|
||||
.slice(0, m);
|
||||
}
|
||||
|
||||
/** 浏览器侧 IP/地理(与后端写入「最后访问」字段配合使用) */
|
||||
export const CLIENT_GEO_LOOKUP_URL = "https://cf-ip-geo.smyhub.com/api";
|
||||
|
||||
@@ -130,15 +156,35 @@ export function getPublicAccountFromPathname(pathname) {
|
||||
export function getAuthFlowFromSearch(search) {
|
||||
const params = new URLSearchParams(search);
|
||||
const redirectUri = (params.get("redirect_uri") || params.get("return_url") || "").trim();
|
||||
const scopeRaw = (params.get("scope") || "").trim();
|
||||
const scopes = scopeRaw
|
||||
? scopeRaw.split(/[\s+]+/).map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
return {
|
||||
redirectUri,
|
||||
state: (params.get("state") || "").trim(),
|
||||
prompt: (params.get("prompt") || "").trim(),
|
||||
clientId: (params.get("client_id") || "").trim(),
|
||||
clientName: (params.get("client_name") || "").trim()
|
||||
clientName: (params.get("client_name") || "").trim(),
|
||||
scopes,
|
||||
scopeRaw
|
||||
};
|
||||
}
|
||||
|
||||
/** 授权页展示用:回跳地址可读标签(主机 + 路径) */
|
||||
export function formatOAuthRedirectLabel(redirectUri) {
|
||||
if (!redirectUri || typeof redirectUri !== "string") return "—";
|
||||
const t = redirectUri.trim();
|
||||
if (!t) return "—";
|
||||
try {
|
||||
const u = new URL(t, typeof window !== "undefined" ? window.location.href : "https://example.com");
|
||||
const path = u.pathname === "/" ? "" : u.pathname;
|
||||
return `${u.host}${path}` || u.host;
|
||||
} catch {
|
||||
return t.length > 64 ? `${t.slice(0, 61)}…` : t;
|
||||
}
|
||||
}
|
||||
|
||||
const AUTH_CLIENT_ID_KEY = "sproutgate_auth_client_id";
|
||||
const AUTH_CLIENT_NAME_KEY = "sproutgate_auth_client_name";
|
||||
|
||||
@@ -185,3 +231,15 @@ export function buildAuthCallbackUrl(redirectUri, payload) {
|
||||
url.hash = hashParams.toString();
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/** 用户拒绝授权:回跳到应用地址,在 URL hash 中携带 OAuth 风格 error(与成功回跳一致使用 fragment) */
|
||||
export function buildAuthDenyCallbackUrl(redirectUri, state = "") {
|
||||
if (!redirectUri || typeof redirectUri !== "string") return "";
|
||||
const url = new URL(redirectUri, window.location.href);
|
||||
const hashParams = new URLSearchParams();
|
||||
hashParams.set("error", "access_denied");
|
||||
hashParams.set("error_description", "resource_owner_denied");
|
||||
if (state) hashParams.set("state", String(state));
|
||||
url.hash = hashParams.toString();
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
@@ -126,6 +126,17 @@ const icons = {
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M17 3h4v4M7 21H3v-4M3 12a9 9 0 0 1 14.3-7.2L21 8M21 12a9 9 0 0 1-14.3 7.2L3 16" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
</svg>
|
||||
),
|
||||
heart: (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M12 20s-7-4.6-9.6-9.1C-.3 8.3 1.8 4.2 6.5 4.2c2.4 0 4.6 1.1 5.5 2.8.9-1.7 3.1-2.8 5.5-2.8 4.7 0 6.8 4.1 4.1 6.7C19 15.4 12 20 12 20z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
};
|
||||
|
||||
|
||||
@@ -51,8 +51,42 @@ a {
|
||||
}
|
||||
|
||||
/* === Splash Screen === */
|
||||
/* 全站随机背景:高斯模糊 = --app-rand-bg-blur-ref × 20%(默认 24px → 4.8px) */
|
||||
.app-shell {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
isolation: isolate;
|
||||
--app-rand-bg-blur-ref: 6px;
|
||||
}
|
||||
|
||||
.app-rand-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-rand-bg__img {
|
||||
position: absolute;
|
||||
left: -8%;
|
||||
top: -8%;
|
||||
width: 116%;
|
||||
height: 116%;
|
||||
object-fit: cover;
|
||||
filter: blur(calc(var(--app-rand-bg-blur-ref) * 0.2));
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.app-rand-bg__veil {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(244, 244, 245, 0.78);
|
||||
}
|
||||
|
||||
.app-shell > .app {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.splash {
|
||||
@@ -337,6 +371,128 @@ nav a:hover {
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
/* === 公开用户列表 /users === */
|
||||
.public-user-list-page {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.public-user-list-head {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.public-user-list-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: #171717;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.public-user-list-lead {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.public-user-list-error {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.public-user-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.public-user-list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 12px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 12px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.public-user-list-row:hover {
|
||||
border-color: #d4d4d4;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06);
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.public-user-list-row:focus-visible {
|
||||
outline: 2px solid #404040;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.public-user-list-row .public-user-list-avatar.avatar-shell {
|
||||
flex-shrink: 0;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.public-user-list-row .public-user-list-avatar.avatar-shell img {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.public-user-list-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.public-user-list-name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.public-user-list-account {
|
||||
font-size: 13px;
|
||||
color: #737373;
|
||||
}
|
||||
|
||||
.public-user-list-sub {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px 10px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.public-user-list-sub .icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.public-user-list-sub-sep {
|
||||
display: inline-block;
|
||||
width: 1px;
|
||||
height: 12px;
|
||||
background: #d4d4d4;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.public-user-list-chevron {
|
||||
flex-shrink: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 300;
|
||||
color: #a3a3a3;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 外层已是卡片,内层登录表单不再叠第二张卡片 */
|
||||
.unified-user-main .auth-card {
|
||||
background: transparent;
|
||||
@@ -772,14 +928,28 @@ button:disabled {
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.profile-header img {
|
||||
/* 外层圆角裁切 + 内层平铺,避免 WebKit 下圆角直接加在 img 上时 GIF 只播首帧 */
|
||||
.profile-header .avatar-shell {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
overflow: hidden;
|
||||
border: 3px solid #e5e5e5;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
flex-shrink: 0;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.profile-header .avatar-shell .avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
image-rendering: auto;
|
||||
}
|
||||
|
||||
.profile-header > div {
|
||||
@@ -1050,6 +1220,51 @@ a.tag.tag-mailto:hover {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* 公开主页点赞 */
|
||||
.profile-like-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px 14px;
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.profile-like-btn {
|
||||
font-size: 14px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.profile-like-done {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.profile-like-error {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.profile-like-hint {
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
flex-basis: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profile-like-quota {
|
||||
width: 100%;
|
||||
flex-basis: 100%;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #525252;
|
||||
}
|
||||
|
||||
.profile-like-quota-exhausted {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.card.profile .stat-item:hover {
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -1509,6 +1724,8 @@ a.tag.tag-mailto:hover {
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
box-shadow: 0 8px 26px rgba(15, 23, 42, 0.07);
|
||||
transform: translateZ(0);
|
||||
image-rendering: auto;
|
||||
}
|
||||
|
||||
.previewable-image {
|
||||
@@ -1878,7 +2095,7 @@ select {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.profile-header img {
|
||||
.profile-header .avatar-shell {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
@@ -1987,3 +2204,317 @@ select {
|
||||
font-size: 21px;
|
||||
}
|
||||
}
|
||||
|
||||
/* —— OAuth 独立授权页 —— */
|
||||
.oauth-authorize-shell .oauth-authorize-main {
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.oauth-authorize-shell .panel {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.oauth-authorize-header .oauth-authorize-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px 18px;
|
||||
}
|
||||
|
||||
.oauth-header-deny {
|
||||
color: var(--muted, #64748b);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.oauth-authorize-invalid h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.oauth-consent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 4px 0 8px;
|
||||
color: #171717;
|
||||
}
|
||||
|
||||
.oauth-consent-brand {
|
||||
text-align: center;
|
||||
padding: 8px 12px 4px;
|
||||
}
|
||||
|
||||
.oauth-consent-icon-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
margin: 0 auto 12px;
|
||||
border-radius: 14px;
|
||||
background: #f4f4f5;
|
||||
border: 1px solid #e5e5e5;
|
||||
color: #404040;
|
||||
}
|
||||
|
||||
.oauth-consent-lock {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.oauth-consent-app-name {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px;
|
||||
line-height: 1.25;
|
||||
color: #111827;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.oauth-consent-lead {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: #57534e;
|
||||
}
|
||||
|
||||
.oauth-consent-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.oauth-consent-card--identity {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.oauth-consent-identity {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.oauth-consent-identity .oauth-consent-avatar,
|
||||
.oauth-consent-identity .oauth-consent-avatar img {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.oauth-consent-id-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.oauth-consent-display {
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.oauth-consent-as {
|
||||
font-size: 0.9rem;
|
||||
color: #57534e;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.oauth-consent-account {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.oauth-consent-email {
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.oauth-consent .muted {
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
.oauth-consent-section-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #737373;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.oauth-consent-section-label--perm {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.oauth-consent-meta {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.oauth-consent-meta > li {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.oauth-consent-meta-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
color: #525252;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.oauth-consent-meta-key {
|
||||
font-size: 0.78rem;
|
||||
color: #6b7280;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.oauth-consent-meta-val,
|
||||
.oauth-consent-meta-link {
|
||||
font-size: 0.92rem;
|
||||
word-break: break-all;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.oauth-consent-meta-link {
|
||||
color: #1d4ed8;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.oauth-consent-meta-link:hover {
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.oauth-consent-perm-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e5e5e5;
|
||||
background: #fafafa;
|
||||
color: #262626;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.oauth-consent-perm-toggle:hover {
|
||||
border-color: #d4d4d4;
|
||||
background: #f4f4f5;
|
||||
}
|
||||
|
||||
.oauth-consent-chevron {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.oauth-consent-perm-toggle.is-open .oauth-consent-chevron {
|
||||
transform: rotate(-180deg);
|
||||
}
|
||||
|
||||
.oauth-consent-perms {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.oauth-consent-perms > li {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.oauth-consent-perm-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
color: #525252;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.oauth-consent-perm-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
margin-bottom: 4px;
|
||||
color: #171717;
|
||||
}
|
||||
|
||||
.oauth-consent-perm-desc {
|
||||
font-size: 0.85rem;
|
||||
color: #57534e;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.oauth-consent-scope-list {
|
||||
margin: 6px 0 0;
|
||||
padding-left: 1.1em;
|
||||
font-size: 0.88rem;
|
||||
color: #44403c;
|
||||
}
|
||||
|
||||
.oauth-consent-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.oauth-consent-allow {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.oauth-consent-deny {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.oauth-consent-switch {
|
||||
align-self: center;
|
||||
margin-top: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.oauth-consent-footnote {
|
||||
font-size: 0.78rem;
|
||||
color: #737373;
|
||||
line-height: 1.5;
|
||||
margin: 8px 0 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.oauth-consent-actions {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.oauth-consent-allow {
|
||||
flex: 1 1 160px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.oauth-consent-deny {
|
||||
flex: 0 1 auto;
|
||||
width: auto;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.oauth-consent-switch {
|
||||
flex: 1 1 100%;
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user