完善初始化更新

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,650 @@
import React, { useEffect, useMemo, useState } from "react";
import { API_BASE, emptyForm, formatIsoDateTimeReadable, parseEmailList } from "../config";
import icons from "../icons";
import { IconLabel, MailtoEmail, TableCell } from "./common";
export default function AdminPanel({ onReady }) {
const queryToken = useMemo(() => {
const params = new URLSearchParams(window.location.search);
return params.get("token") || "";
}, []);
const [token, setToken] = useState(queryToken || localStorage.getItem("sproutgate_admin_token") || "");
const [users, setUsers] = useState([]);
const [form, setForm] = useState(emptyForm);
const [selectedAccount, setSelectedAccount] = useState("");
const [editorOpen, setEditorOpen] = useState(false);
const [editorMode, setEditorMode] = useState("create");
const [loading, setLoading] = useState(false);
const [configLoading, setConfigLoading] = useState(false);
const [configSaving, setConfigSaving] = useState(false);
const [error, setError] = useState("");
const [configError, setConfigError] = useState("");
const [message, setMessage] = useState("");
const [configMessage, setConfigMessage] = useState("");
const [checkInConfig, setCheckInConfig] = useState({ rewardCoins: 1 });
const [readySent, setReadySent] = useState(false);
const [regLoading, setRegLoading] = useState(false);
const [regSaving, setRegSaving] = useState(false);
const [regInvCreating, setRegInvCreating] = useState(false);
const [regError, setRegError] = useState("");
const [regMessage, setRegMessage] = useState("");
const [requireInviteReg, setRequireInviteReg] = useState(false);
const [inviteList, setInviteList] = useState([]);
const [newInvNote, setNewInvNote] = useState("");
const [newInvMax, setNewInvMax] = useState(0);
const [newInvExp, setNewInvExp] = useState("");
useEffect(() => {
if (token) {
localStorage.setItem("sproutgate_admin_token", token);
loadUsers();
loadCheckInConfig();
loadRegistration();
} else if (onReady && !readySent) {
onReady();
setReadySent(true);
}
}, [token, onReady, readySent]);
const loadUsers = async () => {
if (!token) return;
setLoading(true);
setError("");
try {
const res = await fetch(`${API_BASE}/api/admin/users`, {
headers: { "X-Admin-Token": token }
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "加载用户失败");
setUsers(data.users || []);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
if (onReady && !readySent) {
onReady();
setReadySent(true);
}
}
};
const loadCheckInConfig = async () => {
if (!token) return;
setConfigLoading(true);
setConfigError("");
try {
const res = await fetch(`${API_BASE}/api/admin/check-in/config`, {
headers: { "X-Admin-Token": token }
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "加载签到配置失败");
setCheckInConfig({ rewardCoins: Number(data.rewardCoins) || 1 });
} catch (err) {
setConfigError(err.message);
} finally {
setConfigLoading(false);
}
};
const loadRegistration = async () => {
if (!token) return;
setRegLoading(true);
setRegError("");
try {
const res = await fetch(`${API_BASE}/api/admin/registration`, {
headers: { "X-Admin-Token": token }
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "加载注册策略失败");
setRequireInviteReg(Boolean(data.requireInviteCode));
setInviteList(data.invites || []);
} catch (err) {
setRegError(err.message);
} finally {
setRegLoading(false);
}
};
const saveRegPolicy = async () => {
if (!token) return;
setRegMessage("");
setRegError("");
setRegSaving(true);
try {
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 })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "保存失败");
setRequireInviteReg(Boolean(data.requireInviteCode));
setRegMessage("注册策略已保存");
} catch (err) {
setRegError(err.message);
} finally {
setRegSaving(false);
}
};
const handleCreateInvite = async () => {
if (!token) return;
setRegMessage("");
setRegError("");
let expiresAt = "";
if (newInvExp.trim()) {
const d = new Date(newInvExp);
if (Number.isNaN(d.getTime())) {
setRegError("过期时间无效");
return;
}
expiresAt = d.toISOString();
}
setRegInvCreating(true);
try {
const res = await fetch(`${API_BASE}/api/admin/registration/invites`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Admin-Token": token },
body: JSON.stringify({
note: newInvNote.trim(),
maxUses: Number(newInvMax) || 0,
expiresAt
})
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "生成失败");
const inv = data.invite;
setInviteList((prev) => [...prev, inv]);
setRegMessage(`已生成邀请码:${inv.code}(请复制保存)`);
setNewInvNote("");
setNewInvMax(0);
setNewInvExp("");
} catch (err) {
setRegError(err.message);
} finally {
setRegInvCreating(false);
}
};
const handleDeleteInvite = async (code) => {
if (!token || !code) return;
if (!window.confirm(`确认删除邀请码 ${code} 吗?`)) return;
setRegMessage("");
setRegError("");
try {
const res = await fetch(
`${API_BASE}/api/admin/registration/invites/${encodeURIComponent(code)}`,
{ method: "DELETE", headers: { "X-Admin-Token": token } }
);
const data = await res.json();
if (!res.ok) throw new Error(data.error || "删除失败");
setInviteList((prev) => prev.filter((x) => x.code !== code));
setRegMessage("已删除邀请码");
} catch (err) {
setRegError(err.message);
}
};
const selectUser = (user) => {
setSelectedAccount(user.account);
setForm({
account: user.account,
password: "",
username: user.username || "",
email: user.email || "",
level: user.level ?? 0,
sproutCoins: user.sproutCoins || 0,
secondaryEmails: (user.secondaryEmails || []).join(","),
phone: user.phone || "",
avatarUrl: user.avatarUrl || "",
websiteUrl: user.websiteUrl || "",
bio: user.bio || "",
banned: Boolean(user.banned),
banReason: user.banReason || ""
});
setMessage("");
setError("");
setEditorMode("edit");
setEditorOpen(true);
};
const clearSelection = () => {
setSelectedAccount("");
setForm(emptyForm);
};
const openCreateUser = () => {
clearSelection();
setEditorMode("create");
setMessage("");
setError("");
setEditorOpen(true);
};
const closeEditor = () => {
setEditorOpen(false);
clearSelection();
setError("");
};
const handleChange = (field, value) => {
setForm((prev) => ({ ...prev, [field]: value }));
};
const handleCheckInConfigChange = (value) => {
setCheckInConfig({ rewardCoins: value });
};
const handleSaveCheckInConfig = async () => {
setConfigMessage("");
setConfigError("");
const rewardCoins = Number(checkInConfig.rewardCoins) || 0;
if (rewardCoins <= 0) { setConfigError("奖励萌芽币必须大于 0"); return; }
try {
setConfigSaving(true);
const res = await fetch(`${API_BASE}/api/admin/check-in/config`, {
method: "PUT",
headers: { "Content-Type": "application/json", "X-Admin-Token": token },
body: JSON.stringify({ rewardCoins })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "保存签到配置失败");
setCheckInConfig({ rewardCoins: Number(data.rewardCoins) || rewardCoins });
setConfigMessage("签到配置已保存");
} catch (err) {
setConfigError(err.message);
} finally {
setConfigSaving(false);
}
};
const handleCreate = async () => {
setMessage("");
setError("");
if (!form.account || !form.password) { setError("新建用户需要账户和密码"); return; }
try {
const res = await fetch(`${API_BASE}/api/admin/users`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Admin-Token": token },
body: JSON.stringify({
account: form.account,
password: form.password,
username: form.username,
email: form.email,
level: Number(form.level) || 0,
sproutCoins: Number(form.sproutCoins) || 0,
secondaryEmails: parseEmailList(form.secondaryEmails),
phone: form.phone,
avatarUrl: form.avatarUrl,
websiteUrl: form.websiteUrl,
bio: form.bio
})
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "创建失败");
setMessage("创建成功");
closeEditor();
loadUsers();
} catch (err) {
setError(err.message);
}
};
const handleUpdate = async () => {
if (!selectedAccount) { setError("请选择需要更新的账户"); return; }
setMessage("");
setError("");
const payload = {
username: form.username,
email: form.email,
level: Number(form.level) || 0,
sproutCoins: Number(form.sproutCoins) || 0,
secondaryEmails: parseEmailList(form.secondaryEmails),
phone: form.phone,
avatarUrl: form.avatarUrl,
websiteUrl: form.websiteUrl,
bio: form.bio,
banned: Boolean(form.banned),
banReason: (form.banReason || "").trim()
};
if (form.password) payload.password = form.password;
try {
const res = await fetch(`${API_BASE}/api/admin/users/${encodeURIComponent(selectedAccount)}`, {
method: "PUT",
headers: { "Content-Type": "application/json", "X-Admin-Token": token },
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "更新失败");
setMessage("更新成功");
setForm((prev) => ({ ...prev, password: "" }));
closeEditor();
loadUsers();
} catch (err) {
setError(err.message);
}
};
const handleDelete = async (account) => {
if (!account) return;
if (!window.confirm(`确认删除账户 ${account} 吗?`)) return;
setMessage("");
setError("");
try {
const res = await fetch(`${API_BASE}/api/admin/users/${encodeURIComponent(account)}`, {
method: "DELETE",
headers: { "X-Admin-Token": token }
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "删除失败");
setMessage("删除成功");
if (account === selectedAccount) clearSelection();
loadUsers();
} catch (err) {
setError(err.message);
}
};
const editingUser = users.find((u) => u.account === selectedAccount);
const editingAuthClients = editingUser?.authClients || [];
return (
<section className="panel">
<div className="admin-console form">
<div className="panel-title">管理员控制台</div>
<div className="admin-section">
<h2 className="admin-section-heading">管理员 Token</h2>
<label>
<IconLabel icon={icons.token} text="Token" />
<input value={token} onChange={(e) => setToken(e.target.value.trim())} placeholder="请输入管理员 Token" />
</label>
<button className="ghost" onClick={loadUsers} disabled={!token || loading}>
{loading ? "加载中..." : "刷新用户列表"}
</button>
</div>
<div className="admin-section">
<h2 className="admin-section-heading">签到设置</h2>
<label>
<IconLabel icon={icons.coins} text="签到奖励(萌芽币)" />
<input
type="number"
min="1"
value={checkInConfig.rewardCoins}
onChange={(e) => handleCheckInConfigChange(e.target.value)}
disabled={!token || configLoading}
/>
</label>
<div className="hint">用户每天首次签到会获得这里设置的奖励</div>
{configError && <div className="error">{configError}</div>}
{configMessage && <div className="success">{configMessage}</div>}
<div className="actions">
<button className="primary" onClick={handleSaveCheckInConfig} disabled={!token || configSaving}>
{configSaving ? "保存中..." : "保存设置"}
</button>
</div>
</div>
<div className="admin-section">
<h2 className="admin-section-heading">注册与邀请码</h2>
<label className="admin-ban-row">
<IconLabel icon={icons.token} text="强制邀请码" />
<span className="admin-ban-toggle">
<input
type="checkbox"
checked={requireInviteReg}
onChange={(e) => setRequireInviteReg(e.target.checked)}
disabled={!token || regLoading}
/>
<span>开启后用户自助注册必须填写有效邀请码管理员创建用户不受影响</span>
</span>
</label>
<div className="actions compact">
<button type="button" className="primary" onClick={saveRegPolicy} disabled={!token || regSaving || regLoading}>
{regSaving ? "保存中…" : "保存注册策略"}
</button>
<button type="button" className="ghost" onClick={loadRegistration} disabled={!token || regLoading}>
{regLoading ? "加载中…" : "刷新列表"}
</button>
</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>}
<h3 className="admin-subheading">生成新邀请码</h3>
<label>
<IconLabel icon={icons.username} text="备注(可选)" />
<input value={newInvNote} onChange={(e) => setNewInvNote(e.target.value)} placeholder="例如:内测批次 A" disabled={!token || regInvCreating} />
</label>
<label>
<IconLabel icon={icons.level} text="最大使用次数" hint="0 表示不限)" />
<input
type="number"
min="0"
value={newInvMax}
onChange={(e) => setNewInvMax(e.target.value)}
disabled={!token || regInvCreating}
/>
</label>
<label>
<IconLabel icon={icons.calendar} text="过期时间(可选)" />
<input
type="datetime-local"
value={newInvExp}
onChange={(e) => setNewInvExp(e.target.value)}
disabled={!token || regInvCreating}
/>
</label>
<div className="actions">
<button type="button" className="primary" onClick={handleCreateInvite} disabled={!token || regInvCreating}>
{regInvCreating ? "生成中…" : "生成邀请码"}
</button>
</div>
<h3 className="admin-subheading">已有邀请码</h3>
{inviteList.length === 0 ? (
<div className="hint">暂无邀请码</div>
) : (
<div className="admin-invite-list">
{inviteList.map((inv) => (
<div key={inv.code} className="admin-invite-row">
<div>
<span className="mono admin-invite-code">{inv.code}</span>
{inv.note ? <span className="muted"> · {inv.note}</span> : null}
<div className="hint admin-invite-meta">
已用 {inv.uses ?? 0}
{inv.maxUses > 0 ? ` / 上限 ${inv.maxUses}` : " / 不限次数"}
{inv.expiresAt ? ` · 过期 ${formatIsoDateTimeReadable(inv.expiresAt)}` : ""}
</div>
</div>
<button type="button" className="danger ghost" onClick={() => handleDeleteInvite(inv.code)}>
删除
</button>
</div>
))}
</div>
)}
</div>
<div className="admin-section">
<div className="list-header">
<h2 className="admin-section-heading admin-section-heading-inline">用户列表</h2>
<div className="actions compact">
<button className="primary" onClick={openCreateUser}>添加用户</button>
</div>
</div>
{message && <div className="success">{message}</div>}
{users.length === 0 && <div className="hint">暂无用户</div>}
<div className="table admin-table">
<div className="table-row header">
<TableCell icon={icons.account}>账户</TableCell>
<TableCell icon={icons.username}>用户名</TableCell>
<TableCell icon={icons.email}>邮箱</TableCell>
<TableCell icon={icons.level}>等级</TableCell>
<TableCell icon={icons.coins}>萌芽币</TableCell>
<TableCell icon={icons.ban}>状态</TableCell>
<TableCell icon={icons.visitIp}>最近 IP</TableCell>
<TableCell icon={icons.visitGeo}>最近位置</TableCell>
<span>操作</span>
</div>
{users.map((u) => (
<div className="table-row" key={u.account}>
<TableCell icon={icons.account} onClick={() => selectUser(u)}>{u.account}</TableCell>
<TableCell icon={icons.username}>{u.username || "-"}</TableCell>
<TableCell icon={icons.email}>
<span className="admin-email-cell">
{u.email ? (
<MailtoEmail address={u.email} className="profile-external-link">{u.email}</MailtoEmail>
) : (
<span>-</span>
)}
{(u.secondaryEmails || []).length > 0 && (
<>
<span className="muted"> / </span>
{(u.secondaryEmails || []).map((em, idx) => (
<React.Fragment key={em}>
{idx > 0 ? <span className="muted">, </span> : null}
<MailtoEmail address={em} className="profile-external-link">{em}</MailtoEmail>
</React.Fragment>
))}
</>
)}
</span>
</TableCell>
<TableCell icon={icons.level}>{u.level ?? 0} </TableCell>
<TableCell icon={icons.coins}>{u.sproutCoins}</TableCell>
<TableCell icon={icons.ban}>
{u.banned ? (
<span className="admin-user-banned" title={u.banReason || "已封禁"}>封禁</span>
) : (
"正常"
)}
</TableCell>
<TableCell icon={icons.visitIp}><span className="mono">{u.lastVisitIp || "-"}</span></TableCell>
<TableCell icon={icons.visitGeo}>{u.lastVisitDisplayLocation || "-"}</TableCell>
<span className="row-actions">
<button className="ghost" onClick={() => selectUser(u)}>编辑</button>
<button className="danger" onClick={() => handleDelete(u.account)}>删除</button>
</span>
</div>
))}
</div>
</div>
</div>
{editorOpen && (
<div className="modal-backdrop" onClick={closeEditor}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<div>
<h2>{editorMode === "edit" ? "编辑用户" : "新建用户配置"}</h2>
<p>{editorMode === "edit" ? "修改账户资料后保存" : "点击保存后创建新用户"}</p>
</div>
<button className="ghost modal-close" onClick={closeEditor} type="button">关闭</button>
</div>
<div className="modal-body">
<label>
<IconLabel icon={icons.account} text="账户" />
<input value={form.account} onChange={(e) => handleChange("account", e.target.value)} placeholder="唯一账户" disabled={editorMode === "edit"} />
</label>
<label>
<IconLabel icon={icons.password} text="密码" hint={editorMode === "edit" ? "(留空不修改)" : ""} />
<input type="password" value={form.password} onChange={(e) => handleChange("password", e.target.value)} placeholder={editorMode === "edit" ? "输入新密码" : "初始密码"} />
</label>
<label>
<IconLabel icon={icons.username} text="用户名" />
<input value={form.username} onChange={(e) => handleChange("username", e.target.value)} />
</label>
<label>
<IconLabel icon={icons.email} text="邮箱" />
<input value={form.email} onChange={(e) => handleChange("email", e.target.value)} />
</label>
<label>
<IconLabel icon={icons.level} text="等级" />
<input type="number" value={form.level} onChange={(e) => handleChange("level", e.target.value)} />
</label>
<label>
<IconLabel icon={icons.coins} text="萌芽币" />
<input type="number" value={form.sproutCoins} onChange={(e) => handleChange("sproutCoins", e.target.value)} />
</label>
<label>
<IconLabel icon={icons.secondaryEmail} text="辅助邮箱(逗号分隔)" />
<input value={form.secondaryEmails} onChange={(e) => handleChange("secondaryEmails", e.target.value)} placeholder="demo2@example.com, demo3@example.com" />
</label>
<label>
<IconLabel icon={icons.phone} text="手机号" />
<input value={form.phone} onChange={(e) => handleChange("phone", e.target.value)} />
</label>
<label>
<IconLabel icon={icons.avatar} text="个人头像(链接)" />
<input value={form.avatarUrl} onChange={(e) => handleChange("avatarUrl", e.target.value)} />
</label>
<label className="full-span">
<IconLabel icon={icons.link} text="个人主页网站http/https" />
<input value={form.websiteUrl} onChange={(e) => handleChange("websiteUrl", e.target.value)} placeholder="留空表示无" />
</label>
<label className="full-span">
<IconLabel icon={icons.bio} text="个人简介(支持 Markdown" />
<textarea value={form.bio} onChange={(e) => handleChange("bio", e.target.value)} rows={4} />
</label>
{editorMode === "edit" && (
<>
<label className="admin-ban-row">
<IconLabel icon={icons.ban} text="封禁账户" />
<span className="admin-ban-toggle">
<input
type="checkbox"
checked={Boolean(form.banned)}
onChange={(e) => handleChange("banned", e.target.checked)}
/>
<span>禁止登录与使用需登录的接口</span>
</span>
</label>
<label className="full-span">
<IconLabel icon={icons.ban} text="封禁理由(对用户登录错误提示可见)" />
<textarea
value={form.banReason}
onChange={(e) => handleChange("banReason", e.target.value)}
rows={3}
placeholder="填写封禁原因;解封请取消勾选「封禁账户」并保存"
disabled={!form.banned}
/>
</label>
</>
)}
{editorMode === "edit" && editingAuthClients.length > 0 && (
<div className="full-span admin-readonly-auth-clients">
<IconLabel icon={icons.apps} text="应用接入记录(只读)" />
<ul className="admin-auth-client-list">
{[...editingAuthClients]
.sort((a, b) => new Date(b.lastSeenAt || 0) - new Date(a.lastSeenAt || 0))
.map((row) => (
<li key={row.clientId}>
<strong>{row.clientId}</strong>
{row.displayName ? <span className="muted"> · {row.displayName}</span> : null}
<div className="muted admin-auth-client-meta">
首次 {formatIsoDateTimeReadable(row.firstSeenAt)} · 最近 {formatIsoDateTimeReadable(row.lastSeenAt)}
</div>
</li>
))}
</ul>
</div>
)}
{error && <div className="error">{error}</div>}
{message && <div className="success">{message}</div>}
</div>
<div className="modal-actions">
<button className="primary" onClick={editorMode === "edit" ? handleUpdate : handleCreate} type="button">
{editorMode === "edit" ? "保存修改" : "创建用户"}
</button>
<button className="ghost" onClick={closeEditor} type="button">取消</button>
</div>
</div>
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,127 @@
import React, { useEffect, useState } from "react";
import { API_BASE, marked, formatWebsiteLabel, formatUserRegisteredAt } from "../config";
import icons from "../icons";
import { InfoRow, StatItem } from "./common";
export default function PublicUserPage({ account, onReady, onPreviewImage }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
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; };
}, [account, onReady]);
const avatarUrl = user?.avatarUrl || "https://dummyimage.com/160x160/ddd/fff&text=Avatar";
const displayName = user?.username || user?.account || "未命名用户";
return (
<section className="panel">
{loading && <div className="unified-page-loading">加载公开主页中</div>}
{!loading && error && (
<div className="unified-page-miss">
<h2>未找到用户</h2>
<div className="error">{error}</div>
<div className="actions">
<a className="primary" href="/">返回首页</a>
</div>
</div>
)}
{!loading && user && (
<div className="card profile">
<div className="profile-header">
<img
src={avatarUrl}
alt={displayName}
className="previewable-image"
onClick={() => onPreviewImage?.(avatarUrl, displayName)}
role="button"
tabIndex={0}
/>
<div>
<h2>{displayName}</h2>
</div>
</div>
<div className="profile-section-title profile-section-title--lead">基本信息</div>
<div className="profile-info-rows">
<InfoRow icon={icons.account} label="账户" value={user.account} />
<InfoRow icon={icons.username} label="用户名" value={user.username || "未填写"} />
<InfoRow icon={icons.calendar} label="注册时间" value={formatUserRegisteredAt(user.createdAt)} />
{user.websiteUrl ? (
<InfoRow icon={icons.link} label="个人主页">
<a
href={user.websiteUrl}
target="_blank"
rel="noopener noreferrer"
className="profile-external-link"
>
{formatWebsiteLabel(user.websiteUrl)}
</a>
</InfoRow>
) : null}
</div>
<div className="profile-section-title">统计信息</div>
<div className="profile-stats-flow">
<StatItem icon={icons.level} label="等级" value={`${user.level ?? 0}`} />
<StatItem icon={icons.coins} label="萌芽币" value={user.sproutCoins ?? 0} />
<StatItem icon={icons.calendar} label="签到天数" value={`${user.checkInDays ?? 0}`} />
<StatItem icon={icons.statLightning} label="连续签到" value={`${user.checkInStreak ?? 0}`} />
<StatItem icon={icons.statClock} label="访问天数" value={`${user.visitDays ?? 0}`} />
<StatItem icon={icons.statRepeat} label="连续访问" value={`${user.visitStreak ?? 0}`} />
</div>
<div className="profile-section-title">活动记录</div>
<div className="profile-activity-row">
<span>最后签到 <strong>{user.lastCheckInAt || user.lastCheckInDate || "未签到"}</strong></span>
<span>最后访问 <strong>{user.lastVisitAt || user.lastVisitDate || "未访问"}</strong></span>
</div>
<div className="profile-activity-row profile-visit-meta">
<span>
最后访问 IP{" "}
<strong className="mono">{user.lastVisitIp || "暂无"}</strong>
</span>
<span>
最后位置 <strong>{user.lastVisitDisplayLocation || "暂无"}</strong>
</span>
</div>
<div className="profile-section-title">个人简介</div>
<div className="markdown profile-markdown">
<div
className="markdown-body"
dangerouslySetInnerHTML={{ __html: marked.parse(user.bio || "暂无简介") }}
/>
</div>
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,27 @@
import React from "react";
import { LOGO_192_SRC } from "../config";
export default function SplashScreen() {
return (
<div className="splash">
<div className="splash-glow" aria-hidden="true" />
<div className="splash-content">
<div className="splash-logo-wrap">
<div className="splash-rings" aria-hidden="true">
<span />
<span />
<span />
</div>
<img className="splash-logo" src={LOGO_192_SRC} alt="SproutGate" width={120} height={120} decoding="async" />
</div>
<div className="splash-title">萌芽账户认证中心</div>
<div className="splash-subtitle">加载中</div>
<div className="splash-dots" aria-label="加载中">
<span />
<span />
<span />
</div>
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,73 @@
import React from "react";
import { mailtoHref } from "../config";
export function IconLabel({ icon, text, hint }) {
return (
<span className="label-text">
<span className="icon">{icon}</span>
<span>{text}</span>
{hint && <span className="hint">{hint}</span>}
</span>
);
}
export function InfoLabel({ icon, text }) {
return (
<span className="info-label">
<span className="icon">{icon}</span>
<span>{text}</span>
</span>
);
}
export function TableCell({ icon, children, onClick }) {
return (
<span className="table-cell" onClick={onClick}>
<span className="icon">{icon}</span>
<span>{children}</span>
</span>
);
}
export function StatItem({ icon, label, value }) {
return (
<span className="stat-item">
<span className="stat-item-lead">
{icon ? <span className="icon stat-item-icon">{icon}</span> : null}
<span className="stat-item-label">{label}</span>
</span>
<strong className="stat-item-value">{value}</strong>
</span>
);
}
/** 邮箱展示为可点击的 mailto 链接(非法地址则回退为纯文本) */
export function MailtoEmail({ address, className, children }) {
const href = mailtoHref(address);
const text = children ?? address;
if (!href) {
return <span className={className}>{text}</span>;
}
return (
<a href={href} className={className || "mailto-email-link"}>
{text}
</a>
);
}
export function InfoRow({ icon, label, value, actionLabel, onAction, children }) {
return (
<div className="info-row">
<span className="info-row-label">
<span className="icon">{icon}</span>
{label}
</span>
<span className="info-row-value">{children ?? value}</span>
{actionLabel && (
<button type="button" className="text-button" onClick={onAction}>
{actionLabel}
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,245 @@
import React from "react";
import icons from "../../icons";
import { IconLabel, MailtoEmail } from "../common";
export default function UserPortalAuthSection({
isAuthFlow,
user,
onPreviewImage,
handleContinueAuth,
handleSwitchAuthAccount,
mode,
setMode,
account,
setAccount,
password,
setPassword,
loading,
error,
setError,
handleLogin,
registerForm,
handleRegisterChange,
registerSent,
registerExpiresAt,
registerLoading,
registerError,
registerMessage,
handleSendCode,
handleVerifyRegister,
setRegisterError,
setRegisterMessage,
registrationRequireInvite,
resetRegisterFlow,
resetForm,
handleResetChange,
resetSent,
resetExpiresAt,
resetLoading,
resetError,
resetMessage,
handleSendReset,
handleResetPassword,
setResetError,
setResetMessage
}) {
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>
)}
</div>
<div className="auth-form-body">
<label className="auth-field">
<IconLabel icon={icons.account} text="账户" />
<input
value={account}
onChange={(e) => setAccount(e.target.value)}
placeholder="请输入账户"
autoComplete="username"
name="account"
/>
</label>
<label className="auth-field">
<IconLabel icon={icons.password} text="密码" />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
autoComplete="current-password"
name="password"
/>
</label>
{error && <div className="error auth-form-alert" role="alert">{error}</div>}
</div>
<div className="auth-form-actions">
<button type="submit" className="primary auth-submit" disabled={loading}>
{loading ? "登录中…" : "登录"}
</button>
</div>
<div className="auth-form-footer">
<button
type="button"
className="auth-footer-link"
onClick={() => { setMode("register"); setError(""); resetRegisterFlow?.(); setRegisterError(""); setRegisterMessage(""); }}
>
注册账号
</button>
<span className="auth-footer-sep" aria-hidden />
<button type="button" className="auth-footer-link" onClick={() => { setMode("reset"); setError(""); }}>
忘记密码
</button>
</div>
</form>
)}
{!user && mode === "register" && (
<form className="card form auth-card" onSubmit={registerSent ? handleVerifyRegister : handleSendCode}>
<div className="auth-form-head">
<h2>注册账号</h2>
<p className="auth-form-lead">创建萌芽统一账户验证码将发送至你的邮箱</p>
</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="请输入账户" />
</label>
<label className="auth-field">
<IconLabel icon={icons.password} text="密码" />
<input type="password" value={registerForm.password} onChange={(e) => handleRegisterChange("password", e.target.value)} placeholder="请输入密码" />
</label>
<label className="auth-field">
<IconLabel icon={icons.username} text="用户名" />
<input value={registerForm.username} onChange={(e) => handleRegisterChange("username", e.target.value)} placeholder="可选" />
</label>
<label className="auth-field">
<IconLabel icon={icons.email} text="邮箱" />
<input value={registerForm.email} onChange={(e) => handleRegisterChange("email", e.target.value)} placeholder="用于接收验证码" />
</label>
{(registrationRequireInvite || !registerSent) && (
<label className="auth-field">
<IconLabel
icon={icons.token}
text="邀请码"
hint={registrationRequireInvite ? "(必填)" : "(选填)"}
/>
<input
value={registerForm.inviteCode || ""}
onChange={(e) => handleRegisterChange("inviteCode", e.target.value)}
placeholder={registrationRequireInvite ? "请输入管理员发放的邀请码" : "有邀请码可填写,无则留空"}
disabled={registerSent}
autoCapitalize="characters"
/>
</label>
)}
{registerSent && (
<label className="auth-field">
<IconLabel icon={icons.token} text="邮箱验证码" />
<input value={registerForm.code} onChange={(e) => handleRegisterChange("code", e.target.value)} placeholder="6 位验证码" />
</label>
)}
{registerExpiresAt && <div className="hint">验证码有效期至{registerExpiresAt}</div>}
{registerError && <div className="error auth-form-alert" role="alert">{registerError}</div>}
{registerMessage && <div className="success auth-form-alert">{registerMessage}</div>}
</div>
<div className="auth-form-actions">
<button type="submit" className="primary auth-submit" disabled={registerLoading}>
{registerLoading ? "处理中…" : registerSent ? "完成注册" : "发送验证码"}
</button>
<button
type="button"
className="auth-footer-back"
onClick={() => { setMode("login"); resetRegisterFlow?.(); setRegisterError(""); setRegisterMessage(""); }}
>
返回登录
</button>
</div>
</form>
)}
{!user && mode === "reset" && (
<form className="card form auth-card" onSubmit={resetSent ? handleResetPassword : handleSendReset}>
<div className="auth-form-head">
<h2>重置密码</h2>
<p className="auth-form-lead">通过注册邮箱接收验证码设置新密码</p>
</div>
<div className="auth-form-body">
<label className="auth-field">
<IconLabel icon={icons.account} text="账户" />
<input value={resetForm.account} onChange={(e) => handleResetChange("account", e.target.value)} placeholder="请输入账户" />
</label>
<label className="auth-field">
<IconLabel icon={icons.email} text="邮箱" />
<input value={resetForm.email} onChange={(e) => handleResetChange("email", e.target.value)} placeholder="请输入注册邮箱" />
</label>
{resetSent && (
<>
<label className="auth-field">
<IconLabel icon={icons.token} text="邮箱验证码" />
<input value={resetForm.code} onChange={(e) => handleResetChange("code", e.target.value)} placeholder="6 位验证码" />
</label>
<label className="auth-field">
<IconLabel icon={icons.password} text="新密码" />
<input type="password" value={resetForm.newPassword} onChange={(e) => handleResetChange("newPassword", e.target.value)} placeholder="输入新密码" />
</label>
</>
)}
{resetExpiresAt && <div className="hint">验证码有效期至{resetExpiresAt}</div>}
{resetError && <div className="error auth-form-alert" role="alert">{resetError}</div>}
{resetMessage && <div className="success auth-form-alert">{resetMessage}</div>}
</div>
<div className="auth-form-actions">
<button type="submit" className="primary auth-submit" disabled={resetLoading}>
{resetLoading ? "处理中…" : resetSent ? "确认重置" : "发送重置邮件"}
</button>
<button
type="button"
className="auth-footer-back"
onClick={() => { setMode("login"); setResetError(""); setResetMessage(""); }}
>
返回登录
</button>
</div>
</form>
)}
</>
);
}

View File

@@ -0,0 +1,282 @@
import React from "react";
import { marked, formatWebsiteLabel, formatUserRegisteredAt, formatIsoDateTimeReadable } from "../../config";
import icons from "../../icons";
import { IconLabel, MailtoEmail, StatItem, InfoRow } from "../common";
export default function UserPortalProfileSection({
user,
onPreviewImage,
handleLogout,
openProfileEditor,
openSecondaryEditor,
profileEditorOpen,
closeProfileEditor,
handleProfileSave,
profileForm,
handleProfileChange,
profileLoading,
profileError,
profileMessage,
profileModalTitle,
secondaryEditorOpen,
closeSecondaryEditor,
handleSendSecondary,
handleVerifySecondary,
secondaryForm,
handleSecondaryChange,
secondarySent,
secondaryLoading,
secondaryError,
secondaryMessage,
secondaryExpiresAt,
checkInReward,
checkInToday,
checkInLoading,
checkInError,
checkInMessage,
handleCheckIn
}) {
return (
<div className="card profile">
<div className="profile-header">
<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>
<h2>{user.username || user.account}</h2>
<p className="profile-header-email">
{user.email ? (
<MailtoEmail address={user.email} className="profile-external-link">
{user.email}
</MailtoEmail>
) : (
"未填写邮箱"
)}
</p>
<div className="profile-header-actions">
<a className="ghost" href={`/user/${encodeURIComponent(user.account)}`}>公开主页</a>
<button type="button" className="ghost" onClick={handleLogout}>退出登录</button>
</div>
</div>
</div>
<div className="profile-section-title profile-section-title--lead">基本信息</div>
<div className="profile-info-rows">
<InfoRow icon={icons.account} label="账户" value={user.account} />
<InfoRow icon={icons.username} label="用户名" value={user.username || "未填写"} actionLabel="修改" onAction={() => openProfileEditor("username")} />
<InfoRow icon={icons.calendar} label="注册时间" value={formatUserRegisteredAt(user.createdAt)} />
<InfoRow icon={icons.email} label="邮箱">
{user.email ? (
<MailtoEmail address={user.email} className="profile-external-link">
{user.email}
</MailtoEmail>
) : (
"未填写"
)}
</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.link} label="个人主页" actionLabel="修改" onAction={() => openProfileEditor("websiteUrl")}>
{user.websiteUrl ? (
<a
href={user.websiteUrl}
target="_blank"
rel="noopener noreferrer"
className="profile-external-link"
>
{formatWebsiteLabel(user.websiteUrl)}
</a>
) : (
<span className="muted">未填写</span>
)}
</InfoRow>
<InfoRow
icon={icons.secondaryEmail}
label="辅助邮箱"
actionLabel={(user.secondaryEmails || []).length ? "管理" : "添加"}
onAction={openSecondaryEditor}
>
{(user.secondaryEmails || []).length === 0 ? (
<span className="muted">暂无</span>
) : (
(user.secondaryEmails || []).map((email) => (
<MailtoEmail key={email} address={email} className="tag tag-mailto">
{email}
</MailtoEmail>
))
)}
</InfoRow>
<InfoRow icon={icons.bio} label="简介" value={user.bio ? "已填写" : "暂无简介"} actionLabel="修改" onAction={() => openProfileEditor("bio")} />
</div>
<div className="profile-section-title">统计信息</div>
<div className="profile-stats-flow">
<StatItem icon={icons.level} label="等级" value={`${user.level ?? 0}`} />
<StatItem icon={icons.coins} label="萌芽币" value={user.sproutCoins ?? 0} />
<StatItem icon={icons.calendar} label="签到天数" value={`${user.checkInDays ?? 0}`} />
<StatItem icon={icons.statLightning} label="连续签到" value={`${user.checkInStreak ?? 0}`} />
<StatItem icon={icons.statClock} label="访问天数" value={`${user.visitDays ?? 0}`} />
<StatItem icon={icons.statRepeat} label="连续访问" value={`${user.visitStreak ?? 0}`} />
</div>
<div className="profile-section-title">活动记录</div>
<div className="profile-activity-row">
<span>最后签到 <strong>{user.lastCheckInAt || user.lastCheckInDate || "未签到"}</strong></span>
<span>最后访问 <strong>{user.lastVisitAt || user.lastVisitDate || "未访问"}</strong></span>
</div>
<div className="profile-activity-row profile-visit-meta">
<span>
最后访问 IP{" "}
<strong className="mono">{user.lastVisitIp || "暂无"}</strong>
</span>
<span>
最后位置 <strong>{user.lastVisitDisplayLocation || "暂无"}</strong>
</span>
</div>
<div className="profile-section-title">应用接入记录</div>
<p className="profile-auth-clients-hint muted">
第三方应用在登录页 URL 中携带 <code className="inline-code">client_id</code>可选 <code className="inline-code">client_name</code>或在调用 <code className="inline-code">POST /api/auth/verify</code><code className="inline-code">GET /api/auth/me</code> 时传入请求头 <code className="inline-code">X-Auth-Client</code>即可在此累计展示
</p>
{(!user.authClients || user.authClients.length === 0) ? (
<div className="profile-auth-clients-empty muted">暂无接入记录</div>
) : (
<ul className="profile-auth-clients">
{[...user.authClients]
.sort((a, b) => {
const ta = new Date(a.lastSeenAt || 0).getTime();
const tb = new Date(b.lastSeenAt || 0).getTime();
return tb - ta;
})
.map((row) => (
<li key={row.clientId} className="profile-auth-client-row">
<span className="profile-auth-client-id">
<span className="icon profile-auth-client-icon" aria-hidden="true">{icons.apps}</span>
<strong>{row.clientId}</strong>
{row.displayName ? <span className="muted profile-auth-client-dname">{row.displayName}</span> : null}
</span>
<span className="profile-auth-client-times muted">
首次 {formatIsoDateTimeReadable(row.firstSeenAt)} · 最近 {formatIsoDateTimeReadable(row.lastSeenAt)}
</span>
</li>
))}
</ul>
)}
{profileEditorOpen && (
<div className="modal-backdrop" onClick={closeProfileEditor}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<form onSubmit={handleProfileSave}>
<div className="modal-header">
<div>
<h2>{profileModalTitle}</h2>
<p>填写完成后保存修改</p>
</div>
<button type="button" className="ghost modal-close" onClick={closeProfileEditor}>关闭</button>
</div>
<div className="modal-body">
<label>
<IconLabel icon={icons.username} text="用户名" />
<input value={profileForm.username} onChange={(e) => handleProfileChange("username", e.target.value)} />
</label>
<label>
<IconLabel icon={icons.phone} text="手机号" />
<input value={profileForm.phone} onChange={(e) => handleProfileChange("phone", e.target.value)} />
</label>
<label>
<IconLabel icon={icons.avatar} text="个人头像(链接)" />
<input value={profileForm.avatarUrl} onChange={(e) => handleProfileChange("avatarUrl", e.target.value)} />
</label>
<label className="full-span">
<IconLabel icon={icons.link} text="个人主页网站" hint="http/https可省略协议" />
<input
value={profileForm.websiteUrl}
onChange={(e) => handleProfileChange("websiteUrl", e.target.value)}
placeholder="https://example.com 或 example.com"
/>
</label>
<label className="full-span">
<IconLabel icon={icons.bio} text="个人简介(支持 Markdown" />
<textarea value={profileForm.bio} onChange={(e) => handleProfileChange("bio", e.target.value)} rows={4} />
</label>
<label className="full-span">
<IconLabel icon={icons.password} text="密码" hint="(留空不修改)" />
<input type="password" value={profileForm.password} onChange={(e) => handleProfileChange("password", e.target.value)} placeholder="输入新密码" />
</label>
{profileError && <div className="error full-span">{profileError}</div>}
{profileMessage && <div className="success full-span">{profileMessage}</div>}
</div>
<div className="modal-actions">
<button className="primary" disabled={profileLoading} type="submit">{profileLoading ? "保存中..." : "保存修改"}</button>
<button type="button" className="ghost" onClick={closeProfileEditor}>取消</button>
</div>
</form>
</div>
</div>
)}
{secondaryEditorOpen && (
<div className="modal-backdrop" onClick={closeSecondaryEditor}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<form onSubmit={secondarySent ? handleVerifySecondary : handleSendSecondary}>
<div className="modal-header">
<div>
<h2>辅助邮箱</h2>
<p>发送验证码并完成验证后辅助邮箱会更新到当前账户</p>
</div>
<button type="button" className="ghost modal-close" onClick={closeSecondaryEditor}>关闭</button>
</div>
<div className="modal-body">
<label>
<IconLabel icon={icons.secondaryEmail} text="辅助邮箱" />
<input value={secondaryForm.email} onChange={(e) => handleSecondaryChange("email", e.target.value)} placeholder="请输入辅助邮箱" />
</label>
{secondarySent && (
<label>
<IconLabel icon={icons.token} text="邮箱验证码" />
<input value={secondaryForm.code} onChange={(e) => handleSecondaryChange("code", e.target.value)} placeholder="6 位验证码" />
</label>
)}
{secondaryExpiresAt && <div className="hint full-span">验证码有效期至{secondaryExpiresAt}</div>}
{secondaryError && <div className="error full-span">{secondaryError}</div>}
{secondaryMessage && <div className="success full-span">{secondaryMessage}</div>}
</div>
<div className="modal-actions">
<button className="primary" disabled={secondaryLoading} type="submit">{secondarySent ? "确认验证" : "发送验证码"}</button>
<button type="button" className="ghost" onClick={closeSecondaryEditor}>取消</button>
</div>
</form>
</div>
</div>
)}
<div className="profile-section-title">个人简介</div>
<div className="markdown profile-markdown">
<div
className="markdown-body"
dangerouslySetInnerHTML={{ __html: marked.parse(user.bio || "暂无简介") }}
/>
</div>
<div className="profile-section-title">每日签到</div>
<div className="profile-checkin-block">
<div className="hint">每天可签到一次签到奖励 {checkInReward} 萌芽币</div>
<div className="tag-list">
<span className="tag">{checkInToday ? "今日已签到" : "今日未签到"}</span>
<span className="tag">上次签到{user.lastCheckInAt || user.lastCheckInDate || "未签到"}</span>
</div>
{checkInError && <div className="error">{checkInError}</div>}
{checkInMessage && <div className="success">{checkInMessage}</div>}
<div className="actions profile-checkin-actions">
<button type="button" className="primary" onClick={handleCheckIn} disabled={checkInLoading || checkInToday}>
{checkInLoading ? "签到中..." : checkInToday ? "今日已签到" : `立即签到 +${checkInReward}`}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,187 @@
import { marked } from "marked";
const DEFAULT_DEV_API_BASE = "http://localhost:8080";
const DEFAULT_PROD_API_BASE = "https://auth.api.shumengya.top";
export const API_BASE = (() => {
const configuredBase = import.meta.env.VITE_API_BASE?.trim();
const fallbackBase = import.meta.env.DEV ? DEFAULT_DEV_API_BASE : DEFAULT_PROD_API_BASE;
return (configuredBase || fallbackBase).replace(/\/+$/, "");
})();
/** `public/logo192.png`,含 Vite `base` 前缀,避免子路径部署时顶栏/开屏裂图 */
export const LOGO_192_SRC = `${import.meta.env.BASE_URL}logo192.png`;
/** 浏览器侧 IP/地理(与后端写入「最后访问」字段配合使用) */
export const CLIENT_GEO_LOOKUP_URL = "https://cf-ip-geo.smyhub.com/api";
export function formatVisitDisplayLocation(payload) {
const g = payload?.geo;
if (!g || typeof g !== "object") return "";
const parts = [g.countryName, g.regionName, g.cityName].filter(
(x) => typeof x === "string" && x.trim()
);
return parts.join(" ");
}
export async function fetchClientVisitMeta(timeoutMs = 2800) {
const ctrl = new AbortController();
const id = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(CLIENT_GEO_LOOKUP_URL, {
credentials: "omit",
signal: ctrl.signal
});
clearTimeout(id);
if (!res.ok) return { ip: "", displayLocation: "" };
const data = await res.json();
const ip = typeof data.ip === "string" ? data.ip.trim() : "";
const displayLocation = formatVisitDisplayLocation(data);
return { ip, displayLocation };
} catch {
clearTimeout(id);
return { ip: "", displayLocation: "" };
}
}
marked.setOptions({ breaks: true });
export { marked };
export const emptyForm = {
account: "",
password: "",
username: "",
email: "",
level: 0,
sproutCoins: 0,
secondaryEmails: "",
phone: "",
avatarUrl: "",
websiteUrl: "",
bio: "",
banned: false,
banReason: ""
};
/** 后端封禁响应:`error` + 可选 `banReason`(登录/me 等 403 */
export function formatAuthBanMessage(data) {
const base = (data && data.error && String(data.error).trim()) || "account is banned";
const reason = data && data.banReason != null && String(data.banReason).trim();
return reason ? `${base}${reason}` : base;
}
/** 展示用:从完整 URL 得到「主机 + 路径」短文案 */
export function formatWebsiteLabel(url) {
if (!url || typeof url !== "string") return "";
try {
const u = new URL(url);
const path = u.pathname === "/" ? "" : u.pathname;
return u.host + path;
} catch {
return url;
}
}
/** 用户 `createdAt`RFC3339→ 本地可读注册时间 */
export function formatUserRegisteredAt(iso) {
if (!iso || typeof iso !== "string") return "未知";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return new Intl.DateTimeFormat("zh-CN", {
dateStyle: "long",
timeStyle: "short"
}).format(d);
}
/** RFC3339 / 后端存储时间 → 本地可读(用于应用接入记录等) */
export function formatIsoDateTimeReadable(iso) {
if (!iso || typeof iso !== "string") return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return new Intl.DateTimeFormat("zh-CN", {
dateStyle: "medium",
timeStyle: "medium"
}).format(d);
}
export const parseEmailList = (value) =>
value.split(",").map((item) => item.trim()).filter(Boolean);
/** 可点击发信:`mailto:`,排除明显非法地址 */
export function mailtoHref(address) {
const a = typeof address === "string" ? address.trim() : "";
if (!a || /\s/.test(a) || a.includes("<") || a.includes(">")) return null;
const at = a.indexOf("@");
if (at < 1 || at === a.length - 1) return null;
return `mailto:${a}`;
}
export function getPublicAccountFromPathname(pathname) {
if (pathname !== "/user" && !pathname.startsWith("/user/")) return "";
const raw = pathname === "/user" ? "" : pathname.slice("/user/".length).split("/")[0];
if (!raw) return "";
try {
return decodeURIComponent(raw).trim();
} catch {
return raw.trim();
}
}
export function getAuthFlowFromSearch(search) {
const params = new URLSearchParams(search);
const redirectUri = (params.get("redirect_uri") || params.get("return_url") || "").trim();
return {
redirectUri,
state: (params.get("state") || "").trim(),
prompt: (params.get("prompt") || "").trim(),
clientId: (params.get("client_id") || "").trim(),
clientName: (params.get("client_name") || "").trim()
};
}
const AUTH_CLIENT_ID_KEY = "sproutgate_auth_client_id";
const AUTH_CLIENT_NAME_KEY = "sproutgate_auth_client_name";
/** 从统一登录 URL 上的 `client_id` / `client_name` 写入 session后续请求带 `X-Auth-Client` 头以便记录接入应用 */
export function persistAuthClientFromFlow(authFlow) {
if (!authFlow?.clientId) return;
try {
sessionStorage.setItem(AUTH_CLIENT_ID_KEY, authFlow.clientId);
if (authFlow.clientName) sessionStorage.setItem(AUTH_CLIENT_NAME_KEY, authFlow.clientName);
} catch {
/* ignore */
}
}
export function authClientFetchHeaders() {
try {
const id = (sessionStorage.getItem(AUTH_CLIENT_ID_KEY) || "").trim();
const name = (sessionStorage.getItem(AUTH_CLIENT_NAME_KEY) || "").trim();
const h = {};
if (id) h["X-Auth-Client"] = id;
if (name) h["X-Auth-Client-Name"] = name;
return h;
} catch {
return {};
}
}
export function clearAuthClientContext() {
try {
sessionStorage.removeItem(AUTH_CLIENT_ID_KEY);
sessionStorage.removeItem(AUTH_CLIENT_NAME_KEY);
} catch {
/* ignore */
}
}
export function buildAuthCallbackUrl(redirectUri, payload) {
const url = new URL(redirectUri, window.location.href);
const hashParams = new URLSearchParams(url.hash ? url.hash.slice(1) : "");
Object.entries(payload).forEach(([key, value]) => {
if (value === undefined || value === null || value === "") return;
hashParams.set(key, String(value));
});
url.hash = hashParams.toString();
return url.toString();
}

View File

@@ -0,0 +1,132 @@
import React from "react";
const icons = {
account: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="8" r="4" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M4 20c1.5-3.5 5-5 8-5s6.5 1.5 8 5" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
),
password: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="5" y="10" width="14" height="10" rx="2" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M8 10V7a4 4 0 0 1 8 0v3" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
),
username: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="9" cy="8" r="3" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M3 20c1-3 4-4.5 6-4.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M14 7h7M14 11h7M14 15h5" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
),
email: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="6" width="18" height="12" rx="2" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M3 8l9 6 9-6" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
),
coins: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<ellipse cx="12" cy="7" rx="7" ry="3.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M5 7v5c0 1.9 3.1 3.5 7 3.5s7-1.6 7-3.5V7" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M5 12v5c0 1.9 3.1 3.5 7 3.5s7-1.6 7-3.5v-5" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
),
phone: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="7" y="3" width="10" height="18" rx="2" fill="none" stroke="currentColor" strokeWidth="1.8" />
<circle cx="12" cy="17" r="1" fill="currentColor" />
</svg>
),
avatar: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="5" width="18" height="14" rx="2" fill="none" stroke="currentColor" strokeWidth="1.8" />
<circle cx="9" cy="10" r="2" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M3 17l5-4 4 3 4-5 5 6" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
),
bio: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 4h8l4 4v12H6z" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M14 4v4h4" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M8 12h8M8 16h6" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
),
link: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M10 13a5 5 0 0 1 0-7l1-1a5 5 0 0 1 7 7l-1 1" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M14 11a5 5 0 0 1 0 7l-1 1a5 5 0 0 1-7-7l1-1" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
),
token: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="7" cy="12" r="4" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M11 12h10M17 12v4M20 12v2" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
),
level: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M9 15h6M9 9h6M9 12h6" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
),
calendar: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="4" y="5" width="16" height="15" rx="2" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M4 9h16M8 3v4M16 3v4" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M8 13h3M13 13h3M8 16h3" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
),
secondaryEmail: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="4" y="6" width="14" height="10" rx="2" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M4 8l7 4 7-4" fill="none" stroke="currentColor" strokeWidth="1.8" />
<rect x="8" y="10" width="12" height="10" rx="2" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
),
visitIp: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M3 12h18M12 3a15 15 0 0 1 0 18" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
),
visitGeo: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 21s7-5.6 7-11a7 7 0 1 0-14 0c0 5.4 7 11 7 11z" fill="none" stroke="currentColor" strokeWidth="1.8" />
<circle cx="12" cy="10" r="2.2" fill="currentColor" />
</svg>
),
ban: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M6 18L18 6" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
),
apps: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="4" y="4" width="7" height="7" rx="1.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
<rect x="13" y="4" width="7" height="7" rx="1.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
<rect x="4" y="13" width="7" height="7" rx="1.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
<rect x="13" y="13" width="7" height="7" rx="1.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
),
statLightning: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M13 2L4 14h7l-1 8 9-12h-7l1-8z" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
</svg>
),
statClock: (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M12 7v6l4 2" fill="none" stroke="currentColor" strokeWidth="1.8" />
</svg>
),
statRepeat: (
<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>
)
};
export default icons;

File diff suppressed because it is too large Load Diff