Files
SproutGate/sproutgate-frontend/src/components/AdminPanel.jsx
2026-03-20 20:42:33 +08:00

651 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}