www_site/components/admin/users.tsx

336 lines
19 KiB
TypeScript
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.

"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 { BrandIcon } from "@/components/brand-icon";
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"><BrandIcon />{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">
<BrandIcon />
</button>
<button className="button-ghost" onClick={() => { setEmail(""); setPhone(""); setStatus(""); void load(1); }} type="button">
<BrandIcon />
</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">
<BrandIcon />
</button>
<button className="button-ghost disabled:opacity-50" disabled={page >= pages} onClick={() => void load(page + 1)} type="button">
<BrandIcon />
</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"><BrandIcon /></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"><BrandIcon /></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">
<BrandIcon />{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"><BrandIcon /> VIP</button>
</div>
<button className="button-ghost disabled:opacity-50" disabled={busy || !csrf} onClick={() => void doRevoke()} type="button"><BrandIcon /> 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"><BrandIcon /></button>
<button className="button-ghost text-danger disabled:opacity-50" disabled={busy || !csrf} onClick={() => void removeUser()} type="button"><BrandIcon /></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"><BrandIcon /></button></form>;
}