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

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
+6
View File
@@ -1216,6 +1216,12 @@ export const adminApi = {
const q = qs.toString();
return request<Paginated<AdminUser>>(`/api/admin/users/${q ? `?${q}` : ""}`);
},
createUser(payload: { username: string; password: string; initial_credits?: string }) {
return request<AdminUser>("/api/admin/users/", { method: "POST", body: JSON.stringify(payload) });
},
adjustUserCredit(id: string, payload: { amount: string; reason?: string }) {
return request<AdminUser>(`/api/admin/users/${id}/credits/`, { method: "POST", body: JSON.stringify(payload) });
},
toggleUser(id: string) {
return request<AdminUser>(`/api/admin/users/${id}/toggle/`, { method: "POST" });
},
+6
View File
@@ -1238,6 +1238,12 @@ body.sidebar-collapsed .user::after { display: none; }
.field-label .req { color: var(--accent-crimson); margin-left: 2px; }
.field-hint { font-size: 12px; color: var(--black-alpha-48); }
.field-hint.is-error { color: var(--accent-crimson); }
/* 标签内联注解(取值范围 / 单位 / 可留空):跟 .req 一样附在 .field-label 里,mono 灰弱化 */
.field-label .lbl-note { color: var(--black-alpha-48); font-weight: 400; margin-left: 2px; font-family: var(--font-mono); }
/* 数字输入:去掉浏览器上下箭头 + 等宽数字,避免输入时字宽跳动 */
.num-input::-webkit-outer-spin-button,
.num-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.num-input { -moz-appearance: textfield; font-variant-numeric: tabular-nums; }
.input, .textarea, .select {
height: 36px;
padding: 0 14px;
@@ -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="重置密码">
+1 -7
View File
@@ -389,19 +389,13 @@
绝不复用 styles.css 旧暖米调色板的同名类(.role-choice/.cred-card )
*/
/* 共用:字段必填星标 / 标签副注 */
.invite-modal .field-label .req { color: var(--accent-crimson); margin-left: 2px; }
.invite-modal .field-label .lbl-note { color: var(--black-alpha-48); font-weight: 400; margin-left: 2px; font-family: var(--font-mono); }
/* .req / .lbl-note / .num-input 已提到 design-restraint.css 作共享类,这里不再重写 */
/* 共用:输入框 + 行内随机生成按钮 */
.invite-modal .input-with-gen { display: flex; gap: 8px; }
.invite-modal .input-with-gen .input { flex: 1; min-width: 0; }
.invite-modal .input-with-gen .btn-sm { height: 38px; padding: 0 12px; flex: 0 0 auto; }
.invite-modal .pw-input { letter-spacing: .04em; }
/* 数字输入去掉 spinner(对齐设计稿) */
.invite-modal .num-input::-webkit-outer-spin-button,
.invite-modal .num-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.invite-modal .num-input { -moz-appearance: textfield; font-variant-numeric: tabular-nums; }
/* ─── 角色双卡(创建账户 / 编辑成员共用)─── */
.invite-modal .role-choices { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
+5
View File
@@ -83,6 +83,11 @@ export type AdminUser = {
is_platform_admin: boolean;
date_joined: string;
teams: { team_id: string; team_name: string; role: string }[];
// 钱包:积分账户挂在团队上,后台按用户视角拍平。wallet_shared=true 表示这是多人共享池
balance: string;
wallet_team: string | null;
wallet_team_name: string | null;
wallet_shared: boolean;
};
export type AdminQualityWord = {
id: string;