335 lines
19 KiB
TypeScript
335 lines
19 KiB
TypeScript
"use client";
|
||
|
||
import { FormEvent, useEffect, useState } from "react";
|
||
import {
|
||
adjustQuota,
|
||
changeUserStatus,
|
||
createUser,
|
||
deleteUser,
|
||
fetchCsrf,
|
||
grantMembership,
|
||
getUserDetail,
|
||
getUserQuota,
|
||
listUsers,
|
||
revokeMembership,
|
||
updateUserProfile,
|
||
type AdminUser,
|
||
type AdminUserDetail,
|
||
} from "@/lib/admin";
|
||
import { StatusCard } from "@/components/ui";
|
||
|
||
type UserRow = AdminUser;
|
||
|
||
export function UsersPanel() {
|
||
const [rows, setRows] = useState<UserRow[]>([]);
|
||
const [total, setTotal] = useState(0);
|
||
const [pages, setPages] = useState(1);
|
||
const [page, setPage] = useState(1);
|
||
const [email, setEmail] = useState("");
|
||
const [phone, setPhone] = useState("");
|
||
const [status, setStatus] = useState("");
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState("");
|
||
const [selected, setSelected] = useState<AdminUserDetail | null>(null);
|
||
const [notice, setNotice] = useState("");
|
||
const [showCreate, setShowCreate] = useState(false);
|
||
|
||
async function load(pageNum = page) {
|
||
setLoading(true);
|
||
setError("");
|
||
try {
|
||
const res = await listUsers({ email, phone, status, page: pageNum, limit: 20 });
|
||
setRows(res.items);
|
||
setTotal(res.total);
|
||
setPages(res.pages);
|
||
setPage(res.page);
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "加载失败");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
void load(1);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
async function openDetail(id: string) {
|
||
setError("");
|
||
try {
|
||
const detail = await getUserDetail(id);
|
||
setSelected(detail);
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "加载详情失败");
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="grid gap-5 md:grid-cols-3">
|
||
<StatusCard title="用户总数" description={`${total.toLocaleString()} 个`} />
|
||
<StatusCard title="当前页" description={`${rows.length} 条`} tone="info" />
|
||
<StatusCard title="总页数" description={`${pages} 页`} />
|
||
</div>
|
||
|
||
<button className="button-secondary" onClick={() => setShowCreate((value) => !value)} type="button">{showCreate ? "取消添加" : "手动添加用户"}</button>
|
||
{showCreate ? <CreateUserPanel onCreated={() => { setShowCreate(false); setNotice("用户已创建"); void load(1); }} /> : null}
|
||
|
||
<div className="rounded-2xl border border-line bg-panel/50">
|
||
<div className="grid gap-3 border-b border-line p-5 sm:grid-cols-[1fr_1fr_1fr_auto_auto]">
|
||
<input aria-label="按邮箱筛选" className="field-input" onChange={(e) => setEmail(e.target.value)} placeholder="按邮箱筛选" value={email} />
|
||
<input aria-label="按手机号筛选" className="field-input" onChange={(e) => setPhone(e.target.value)} placeholder="按手机号筛选" value={phone} />
|
||
<select aria-label="按状态筛选" className="field-input" onChange={(e) => setStatus(e.target.value)} value={status}>
|
||
<option value="">全部状态</option>
|
||
<option value="active">正常</option>
|
||
<option value="disabled">已禁用</option>
|
||
</select>
|
||
<button className="button-secondary" onClick={() => void load(1)} type="button">
|
||
查询
|
||
</button>
|
||
<button className="button-ghost" onClick={() => { setEmail(""); setPhone(""); setStatus(""); void load(1); }} type="button">
|
||
重置
|
||
</button>
|
||
</div>
|
||
|
||
{error ? <div className="m-5 rounded-lg border border-danger/30 bg-danger/10 p-3 text-sm text-danger">{error}</div> : null}
|
||
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-left text-sm">
|
||
<thead className="border-b border-line text-xs uppercase tracking-[0.14em] text-subtle">
|
||
<tr>
|
||
<th className="px-5 py-3">用户</th>
|
||
<th className="px-5 py-3">有效 Plan</th>
|
||
<th className="px-5 py-3">角色</th>
|
||
<th className="px-5 py-3">状态</th>
|
||
<th className="px-5 py-3">注册时间</th>
|
||
<th className="px-5 py-3"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-line">
|
||
{loading ? (
|
||
<tr><td className="px-5 py-8 text-center text-muted" colSpan={6}>加载中…</td></tr>
|
||
) : rows.length === 0 ? (
|
||
<tr><td className="px-5 py-8 text-center text-muted" colSpan={6}>无匹配用户</td></tr>
|
||
) : (
|
||
rows.map((row) => (
|
||
<tr className="hover:bg-panel/40" key={row.id}>
|
||
<td className="px-5 py-3">
|
||
<p className="font-semibold text-copy">{row.username}</p>
|
||
<p className="text-xs text-subtle">{row.email ?? "无邮箱"} · {row.phone ?? "无手机号"}</p>
|
||
</td>
|
||
<td className="px-5 py-3 text-cyan">{row.effective_plan.toUpperCase()}</td>
|
||
<td className="px-5 py-3">{row.role}</td>
|
||
<td className="px-5 py-3">
|
||
<span className={row.status === "active" ? "text-success" : "text-danger"}>{row.status === "active" ? "正常" : "已禁用"}</span>
|
||
</td>
|
||
<td className="px-5 py-3 text-subtle">{new Date(row.created_at).toLocaleDateString()}</td>
|
||
<td className="px-5 py-3">
|
||
<button className="text-sm font-semibold text-cyan hover:text-copy" onClick={() => void openDetail(row.id)} type="button">
|
||
详情
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between gap-3 border-t border-line p-4 text-sm">
|
||
<span className="text-subtle">第 {page} / {pages} 页</span>
|
||
<div className="flex gap-2">
|
||
<button className="button-ghost disabled:opacity-50" disabled={page <= 1} onClick={() => void load(Math.max(1, page - 1))} type="button">
|
||
上一页
|
||
</button>
|
||
<button className="button-ghost disabled:opacity-50" disabled={page >= pages} onClick={() => void load(page + 1)} type="button">
|
||
下一页
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{selected ? <UserDetailPanel detail={selected} onRefresh={() => void openDetail(selected.user.id)} setNotice={setNotice} onClose={() => setSelected(null)} /> : null}
|
||
{notice ? <div className="rounded-xl border border-success/30 bg-success/10 p-4 text-sm text-success" role="status">{notice}</div> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function UserDetailPanel({ detail, onRefresh, setNotice, onClose }: { detail: AdminUserDetail; onRefresh: () => void; setNotice: (msg: string) => void; onClose: () => void }) {
|
||
const [csrf, setCsrf] = useState("");
|
||
const [reason, setReason] = useState("");
|
||
const [vipDays, setVipDays] = useState("30");
|
||
const [amount, setAmount] = useState("100");
|
||
const [busy, setBusy] = useState(false);
|
||
const [message, setMessage] = useState("");
|
||
const [username, setUsername] = useState(detail.user.username);
|
||
const [email, setEmail] = useState(detail.user.email ?? "");
|
||
const [phone, setPhone] = useState(detail.user.phone ?? "");
|
||
const [emailVerified, setEmailVerified] = useState(detail.user.email_verified);
|
||
const [phoneVerified, setPhoneVerified] = useState(detail.user.phone_verified);
|
||
|
||
useEffect(() => {
|
||
void fetchCsrf().then(setCsrf);
|
||
}, []);
|
||
|
||
async function run(fn: () => Promise<unknown>, okMsg: string) {
|
||
if (!reason.trim()) { setMessage("请填写原因"); return; }
|
||
setBusy(true);
|
||
setMessage("");
|
||
try {
|
||
await fn();
|
||
setNotice(okMsg);
|
||
onRefresh();
|
||
} catch (e) {
|
||
setMessage(e instanceof Error ? e.message : "操作失败");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
function toggleStatus() {
|
||
const target: "active" | "disabled" = detail.user.status === "active" ? "disabled" : "active";
|
||
return run(() => changeUserStatus(detail.user.id, target, reason, csrf), target === "disabled" ? "已禁用并撤销会话" : "已恢复");
|
||
}
|
||
|
||
function doGrant() {
|
||
const days = Number(vipDays);
|
||
const expiresAt = days > 0 ? new Date(Date.now() + days * 86400000).toISOString() : null;
|
||
return run(() => grantMembership(detail.user.id, "vip", expiresAt, reason, csrf), "已授予 VIP");
|
||
}
|
||
|
||
function doRevoke() {
|
||
return run(() => revokeMembership(detail.user.id, reason, csrf), "已撤销 VIP");
|
||
}
|
||
|
||
function doAdjust() {
|
||
const amt = Number(amount);
|
||
if (!Number.isFinite(amt)) { setMessage("额度需为数字"); return; }
|
||
return run(() => adjustQuota(detail.user.id, amt, reason, `admin-${Date.now()}-${Math.random()}`, csrf), "额度已调整");
|
||
}
|
||
|
||
function saveProfile() {
|
||
if (!username.trim()) { setMessage("用户名不能为空"); return; }
|
||
if (!email.trim() && !phone.trim()) { setMessage("邮箱或手机号至少填写一项"); return; }
|
||
return run(() => updateUserProfile(detail.user.id, { username: username.trim(), email: email.trim() || null, phone: phone.trim() || null, email_verified: emailVerified, phone_verified: phoneVerified, reason: reason.trim() }, csrf), "用户信息已保存");
|
||
}
|
||
|
||
async function removeUser() {
|
||
if (!reason.trim()) { setMessage("请填写原因"); return; }
|
||
if (!window.confirm("确定删除该用户?删除后不可恢复。")) return;
|
||
setBusy(true);
|
||
try { await deleteUser(detail.user.id, reason, csrf); setNotice("用户已删除"); onClose(); } catch (e) { setMessage(e instanceof Error ? e.message : "删除失败"); } finally { setBusy(false); }
|
||
}
|
||
|
||
return (
|
||
<div className="rounded-2xl border border-line bg-panel/60 p-6">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<h3 className="text-xl font-semibold">{detail.user.username}</h3>
|
||
<p className="mt-1 text-sm text-subtle">有效 Plan:{detail.effective_plan.toUpperCase()} · 状态:{detail.user.status === "active" ? "正常" : "已禁用"}</p>
|
||
</div>
|
||
<button className="button-ghost" onClick={onClose} type="button">关闭</button>
|
||
</div>
|
||
|
||
<div className="mt-6 grid gap-5 md:grid-cols-3">
|
||
<div className="rounded-xl border border-line bg-panel/40 p-4">
|
||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-subtle">额度周期</p>
|
||
<p className="mt-2 text-sm text-muted">{new Date(detail.quota.period_start).toLocaleDateString()} → {new Date(detail.quota.period_end).toLocaleDateString()}</p>
|
||
<dl className="mt-3 space-y-2 text-sm">
|
||
<div className="flex justify-between"><dt className="text-subtle">上限</dt><dd>{detail.quota.limit.toLocaleString()}</dd></div>
|
||
<div className="flex justify-between"><dt className="text-subtle">已用</dt><dd>{detail.quota.used.toLocaleString()}</dd></div>
|
||
<div className="flex justify-between"><dt className="text-subtle">冻结</dt><dd>{detail.quota.reserved.toLocaleString()}</dd></div>
|
||
<div className="flex justify-between"><dt className="text-subtle">调整</dt><dd>{detail.quota.adjustment.toLocaleString()}</dd></div>
|
||
<div className="flex justify-between border-t border-line pt-2"><dt className="text-subtle">可用</dt><dd className="text-cyan">{detail.quota.available.toLocaleString()}</dd></div>
|
||
</dl>
|
||
</div>
|
||
<div className="rounded-xl border border-line bg-panel/40 p-4">
|
||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-subtle">当前会员</p>
|
||
{detail.effective_grant ? (
|
||
<div className="mt-2 text-sm text-muted">
|
||
<p className="text-cyan">{detail.effective_grant.plan.toUpperCase()}</p>
|
||
<p className="mt-1 text-subtle">至 {detail.effective_grant.expires_at ? new Date(detail.effective_grant.expires_at).toLocaleDateString() : "长期"}</p>
|
||
</div>
|
||
) : (
|
||
<p className="mt-2 text-sm text-muted">无有效会员</p>
|
||
)}
|
||
{detail.membership_history.length ? (
|
||
<ul className="mt-4 space-y-2 text-xs text-subtle">
|
||
{detail.membership_history.slice(0, 4).map((g) => (
|
||
<li key={g.id}>{g.plan.toUpperCase()} · {new Date(g.starts_at).toLocaleDateString()} → {g.expires_at ? new Date(g.expires_at).toLocaleDateString() : "长期"}{g.revoked_at ? " · 已撤销" : ""}</li>
|
||
))}
|
||
</ul>
|
||
) : null}
|
||
</div>
|
||
<div className="rounded-xl border border-line bg-panel/40 p-4">
|
||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-subtle">额度调整记录</p>
|
||
{detail.adjustment_records.length ? (
|
||
<ul className="mt-2 space-y-2 text-xs text-subtle">
|
||
{detail.adjustment_records.map((r) => (
|
||
<li key={r.id}>{r.amount > 0 ? "+" : ""}{r.amount.toLocaleString()} · {r.reason ?? "—"}</li>
|
||
))}
|
||
</ul>
|
||
) : (
|
||
<p className="mt-2 text-sm text-muted">暂无调整记录</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-6 rounded-xl border border-line bg-panel/40 p-4">
|
||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-subtle">用户信息</p>
|
||
<div className="mt-4 grid gap-4 md:grid-cols-3">
|
||
<div><label className="field-label" htmlFor="admin-username">用户名</label><input className="field-input" id="admin-username" onChange={(e) => setUsername(e.target.value)} value={username} /></div>
|
||
<div><label className="field-label" htmlFor="admin-email">邮箱</label><input className="field-input" id="admin-email" onChange={(e) => setEmail(e.target.value)} type="email" value={email} /></div>
|
||
<div><label className="field-label" htmlFor="admin-phone">手机号</label><input className="field-input" id="admin-phone" onChange={(e) => setPhone(e.target.value)} value={phone} /></div>
|
||
</div>
|
||
<div className="mt-4 flex flex-wrap gap-5 text-sm">
|
||
<label className="flex items-center gap-2"><input checked={emailVerified} onChange={(e) => setEmailVerified(e.target.checked)} type="checkbox" />邮箱已验证</label>
|
||
<label className="flex items-center gap-2"><input checked={phoneVerified} onChange={(e) => setPhoneVerified(e.target.checked)} type="checkbox" />手机已验证</label>
|
||
</div>
|
||
<button className="button-secondary mt-4 disabled:opacity-50" disabled={busy || !csrf} onClick={() => void saveProfile()} type="button">保存用户信息</button>
|
||
</div>
|
||
|
||
<div className="mt-6 space-y-4">
|
||
<div><label className="field-label" htmlFor="admin-reason">操作原因(必填)</label><textarea className="field-input" id="admin-reason" onChange={(e) => setReason(e.target.value)} placeholder="例如:运营调整 / 违规禁用" rows={2} value={reason} /></div>
|
||
{message ? <p className="rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning" role="status">{message}</p> : null}
|
||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||
<button className="button-secondary disabled:opacity-50" disabled={busy || !csrf} onClick={() => void toggleStatus()} type="button">
|
||
{detail.user.status === "active" ? "禁用用户" : "恢复用户"}
|
||
</button>
|
||
<div className="flex gap-2">
|
||
<input aria-label="VIP 天数" className="field-input w-20" onChange={(e) => setVipDays(e.target.value)} value={vipDays} />
|
||
<button className="button-secondary disabled:opacity-50" disabled={busy || !csrf} onClick={() => void doGrant()} type="button">授予 VIP</button>
|
||
</div>
|
||
<button className="button-ghost disabled:opacity-50" disabled={busy || !csrf} onClick={() => void doRevoke()} type="button">撤销 VIP</button>
|
||
<div className="flex gap-2">
|
||
<input aria-label="额度调整量" className="field-input w-24" onChange={(e) => setAmount(e.target.value)} value={amount} />
|
||
<button className="button-secondary disabled:opacity-50" disabled={busy || !csrf} onClick={() => void doAdjust()} type="button">调整额度</button>
|
||
<button className="button-ghost text-danger disabled:opacity-50" disabled={busy || !csrf} onClick={() => void removeUser()} type="button">删除用户</button>
|
||
</div>
|
||
</div>
|
||
<p className="text-xs text-subtle">调整额度不能使可用额度低于已用;同一操作不可重复提交。</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CreateUserPanel({ onCreated }: { onCreated: () => void }) {
|
||
const [csrf, setCsrf] = useState("");
|
||
const [message, setMessage] = useState("");
|
||
const [busy, setBusy] = useState(false);
|
||
useEffect(() => { void fetchCsrf().then(setCsrf); }, []);
|
||
|
||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
const data = new FormData(event.currentTarget);
|
||
setBusy(true); setMessage("");
|
||
try {
|
||
await createUser({ username: String(data.get("username") ?? ""), email: String(data.get("email") ?? "").trim() || null, phone: String(data.get("phone") ?? "").trim() || null, password: String(data.get("password") ?? ""), email_verified: data.get("email_verified") === "on", phone_verified: data.get("phone_verified") === "on", reason: String(data.get("reason") ?? "") }, csrf);
|
||
onCreated();
|
||
} catch (e) { setMessage(e instanceof Error ? e.message : "创建失败"); } finally { setBusy(false); }
|
||
}
|
||
|
||
return <form className="rounded-2xl border border-line bg-panel/50 p-5" onSubmit={submit}><p className="font-semibold">手动添加用户</p><div className="mt-4 grid gap-4 md:grid-cols-3"><input className="field-input" name="username" placeholder="用户名" required /><input className="field-input" name="email" placeholder="邮箱(可选)" type="email" /><input className="field-input" name="phone" placeholder="手机号(可选)" /><input className="field-input" minLength={8} name="password" placeholder="初始密码" required type="password" /><input className="field-input" name="reason" placeholder="创建原因(必填)" required /></div><div className="mt-4 flex flex-wrap gap-5 text-sm"><label className="flex items-center gap-2"><input name="email_verified" type="checkbox" />邮箱已验证</label><label className="flex items-center gap-2"><input name="phone_verified" type="checkbox" />手机已验证</label></div>{message ? <p className="mt-3 text-sm text-danger" role="status">{message}</p> : null}<button className="button-secondary mt-4 disabled:opacity-50" disabled={busy || !csrf} type="submit">创建用户</button></form>;
|
||
}
|