优化后台用户管理和发积分

This commit is contained in:
Azmat@qq.com
2026-09-20 16:30:45 +08:00
parent fb3b2904ae
commit 690eb3d843
12 changed files with 470 additions and 22 deletions
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { Building, KeyRound, Percent, X } from "lucide-react";
import { Building, Coins, KeyRound, Percent, UserPlus, X } from "lucide-react";
import { adminApi } from "../../api";
import { Pager } from "../../components/pager";
import { IconKitSvg } from "../../components/IconKitSvg";
@@ -247,6 +247,17 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
const [pwdTarget, setPwdTarget] = useState<AdminUser | null>(null);
const [pwd, setPwd] = useState("");
const [saving, setSaving] = useState(false);
// 直接开户:不发邀请码,后端建号时自动配一个个人团队当钱包(团队体系保留,后台只按用户视角管)
const [createOpen, setCreateOpen] = useState(false);
const [newName, setNewName] = useState("");
const [newPwd, setNewPwd] = useState("");
const [newCredits, setNewCredits] = useState("");
const [creating, setCreating] = useState(false);
// 发积分:金额可正可负,落一条调额流水
const [creditTarget, setCreditTarget] = useState<AdminUser | null>(null);
const [creditAmt, setCreditAmt] = useState("");
const [creditReason, setCreditReason] = useState("");
const [creditSaving, setCreditSaving] = useState(false);
const load = useCallback(async () => {
setLoading(true);
@@ -293,12 +304,68 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
}
}
function closeCreate() {
setCreateOpen(false);
setNewName("");
setNewPwd("");
setNewCredits("");
}
async function doCreate() {
if (creating) return;
const name = newName.trim();
if (!name) { notify("error", "请填写用户名"); return; }
if (newPwd.trim().length < 8) { notify("error", "密码至少 8 位"); return; }
const credits = newCredits.trim();
if (credits && (!Number.isFinite(Number(credits)) || Number(credits) < 0)) { notify("error", "初始积分需为非负数"); return; }
setCreating(true);
try {
await adminApi.createUser({ username: name, password: newPwd.trim(), initial_credits: credits || "0" });
notify("success", `已创建用户 ${name}`);
closeCreate();
setPage(1);
await load();
} catch (e) {
notify("error", e instanceof Error ? e.message : "创建失败");
} finally {
setCreating(false);
}
}
function closeCredit() {
setCreditTarget(null);
setCreditAmt("");
setCreditReason("");
}
async function doCredit() {
if (!creditTarget || creditSaving) return;
const amt = Number(creditAmt);
if (!Number.isFinite(amt) || amt === 0) { notify("error", "请填写非 0 的积分数量"); return; }
setCreditSaving(true);
try {
await adminApi.adjustUserCredit(creditTarget.id, { amount: creditAmt.trim(), reason: creditReason.trim() });
notify("success", `已为 ${creditTarget.username} ${amt > 0 ? "发放" : "扣减"} ${Math.abs(amt)} 积分`);
closeCredit();
await load();
} catch (e) {
notify("error", e instanceof Error ? e.message : "调额失败");
} finally {
setCreditSaving(false);
}
}
return (
<>
<div className="page-head">
<div>
<h1>用户</h1>
<div className="sub"><span className="mono">{count} 个用户</span> · 跨团队 · 启停 / 强制改密</div>
<div className="sub"><span className="mono">{count} 个用户</span> · 直接开户 · 发积分 / 启停 / 强制改密</div>
</div>
<div className="actions">
<button className="btn btn-primary" type="button" onClick={() => setCreateOpen(true)}>
<UserPlus size={15} />新建用户
</button>
</div>
</div>
@@ -319,7 +386,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
<div className="admin-table-wrap">
<table className="t admin-table">
<thead>
<tr><th>用户名</th><th>所属团队</th><th>状态</th><th>注册时间</th><th className="col-actions">操作</th></tr>
<tr><th>用户名</th><th>积分余额</th><th>所属团队</th><th>状态</th><th>注册时间</th><th className="col-actions">操作</th></tr>
</thead>
<tbody>
{users.map((u) => (
@@ -328,6 +395,9 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
{u.username}
{u.is_platform_admin && <span className="pill info admin-inline-pill"><span className="dot" />超管</span>}
</td>
<td className="num mono">
{u.wallet_team ? `${pts(u.balance)} 积分` : <span className="muted">—</span>}
</td>
<td>
{u.teams.length === 0
? <span className="muted">—</span>
@@ -340,6 +410,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
<span className="muted">—</span>
) : (
<>
{u.wallet_team && <button className="btn btn-sm btn-ghost" type="button" onClick={() => setCreditTarget(u)}>发积分</button>}
<button className="btn btn-sm btn-ghost" type="button" onClick={() => setPwdTarget(u)}>改密</button>
<button className={`btn btn-sm btn-ghost${u.status === "active" ? " danger" : ""}`} type="button" onClick={() => toggle(u)}>
{u.status === "active" ? "停用" : "启用"}
@@ -355,6 +426,71 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
</div>
)}
{createOpen && (
<div className="modal-bg show">
<div className="modal" role="dialog" aria-modal="true" aria-label="新建用户">
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h">
<div className="ic-m"><UserPlus size={16} /></div>
<div className="ti">新建用户</div>
<button className="x modal-x" type="button" aria-label="关闭" onClick={closeCreate}><X size={14} /></button>
</div>
<div className="modal-b">
<p className="admin-modal-desc">直接开户,不用发邀请码。建好即可用该用户名密码登录,并拥有一个独立的积分钱包。</p>
<div className="field">
<label className="field-label" htmlFor="new-username">用户名 <span className="req">*</span></label>
<input id="new-username" className="input" type="text" placeholder="登录用的用户名" value={newName} onChange={(e) => setNewName(e.target.value)} />
</div>
<div className="field">
<label className="field-label" htmlFor="new-password">初始密码 <span className="req">*</span> <span className="lbl-note">(至少 8 位)</span></label>
<input id="new-password" className="input" type="text" placeholder="告知用户后建议其自行修改" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} />
</div>
<div className="field">
<label className="field-label" htmlFor="new-credits">初始积分 <span className="lbl-note">(可留空,默认 0)</span></label>
<input id="new-credits" className="input num-input" type="number" inputMode="numeric" min="0" placeholder="例: 1000" value={newCredits} onChange={(e) => setNewCredits(e.target.value)} />
</div>
</div>
<div className="modal-f">
<button className="btn" type="button" onClick={closeCreate}>取消</button>
<button className="btn btn-primary" type="button" disabled={creating} onClick={() => void doCreate()}>{creating ? "创建中…" : "创建用户"}</button>
</div>
</div>
</div>
)}
{creditTarget && (
<div className="modal-bg show" onClick={(e) => { if (e.target === e.currentTarget) closeCredit(); }}>
<div className="modal" role="dialog" aria-modal="true" aria-label="发积分">
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h">
<div className="ic-m"><Coins size={16} /></div>
<div className="ti">发积分<span>{creditTarget.username}</span></div>
<button className="x modal-x" type="button" aria-label="关闭" onClick={closeCredit}><X size={14} /></button>
</div>
<div className="modal-b">
<p className="admin-modal-desc">
当前余额 <b style={{ color: "var(--accent-black)" }}>{pts(creditTarget.balance)} 积分</b>。填正数为发放,负数为扣减;扣减后余额不得为负。会落一条调额流水。
</p>
<div className="field">
<label className="field-label" htmlFor="credit-amount">积分数量 <span className="req">*</span> <span className="lbl-note">(正数发放 / 负数扣减)</span></label>
<input id="credit-amount" className="input num-input" type="number" inputMode="numeric" placeholder="例: 1000 或 -200" value={creditAmt} onChange={(e) => setCreditAmt(e.target.value)} />
{creditTarget.wallet_shared && (
<div className="field-hint">⚠ 该用户属于多人团队「{creditTarget.wallet_team_name}」,这笔积分会进团队共享池,同队成员都能使用。</div>
)}
</div>
<div className="field">
<label className="field-label" htmlFor="credit-reason">备注 <span className="lbl-note">(选填,写进流水便于对账)</span></label>
<input id="credit-reason" className="input" type="text" placeholder="例: 活动赠送 / 争议补偿" value={creditReason} onChange={(e) => setCreditReason(e.target.value)} />
</div>
</div>
<div className="modal-f">
<button className="btn" type="button" onClick={closeCredit}>取消</button>
<button className="btn btn-primary" type="button" disabled={creditSaving} onClick={() => void doCredit()}>{creditSaving ? "提交中…" : "确认调额"}</button>
</div>
</div>
</div>
)}
{pwdTarget && (
<div className="modal-bg show" onClick={(e) => { if (e.target === e.currentTarget) setPwdTarget(null); }}>
<div className="modal" role="dialog" aria-modal="true" aria-label="重置密码">