完善初始化更新

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

View File

@@ -0,0 +1,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>
);
}