|
|
|
@@ -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="重置密码">
|
|
|
|
|