651 lines
26 KiB
JavaScript
651 lines
26 KiB
JavaScript
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>
|
||
);
|
||
}
|