feat: complete TASK-WP2-04 credit ledger
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useId, useMemo, useState, type FormEvent } from "react";
|
||||
|
||||
import "./admin-users.css";
|
||||
|
||||
interface AdminSession {
|
||||
csrf_token: string;
|
||||
}
|
||||
|
||||
interface CreditBalance {
|
||||
available_balance: number;
|
||||
reserved_balance: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
async function readJson<T>(url: string, init?: RequestInit) {
|
||||
const response = await fetch(url, { credentials: "same-origin", ...init });
|
||||
if (response.status === 401) throw new Error("admin_session_invalid");
|
||||
if (!response.ok) throw new Error("request_failed");
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function idempotencyKey() {
|
||||
return crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", "");
|
||||
}
|
||||
|
||||
export function AdminUsersPage() {
|
||||
const amountId = useId();
|
||||
const reasonId = useId();
|
||||
const userId = new URLSearchParams(window.location.search).get("userId") ?? "";
|
||||
const [session, setSession] = useState<AdminSession>();
|
||||
const [balance, setBalance] = useState<CreditBalance>();
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [adjusting, setAdjusting] = useState(false);
|
||||
const [direction, setDirection] = useState<"increase" | "decrease">("increase");
|
||||
const [amount, setAmount] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [adjustmentId, setAdjustmentId] = useState("");
|
||||
const [adjustmentKey, setAdjustmentKey] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [notice, setNotice] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!/^[0-9a-f-]{36}$/i.test(userId)) return;
|
||||
Promise.all([
|
||||
readJson<AdminSession>("/api/v1/admin-auth/session"),
|
||||
readJson<CreditBalance>(`/api/v1/admin/users/${userId}/credits`),
|
||||
]).then(([nextSession, nextBalance]) => {
|
||||
setSession(nextSession);
|
||||
setBalance(nextBalance);
|
||||
}).catch(() => setFailed(true));
|
||||
}, [userId]);
|
||||
|
||||
const signedAmount = useMemo(() => {
|
||||
const parsed = Number(amount);
|
||||
if (!Number.isSafeInteger(parsed) || parsed <= 0) return 0;
|
||||
return direction === "increase" ? parsed : -parsed;
|
||||
}, [amount, direction]);
|
||||
const projected = balance ? balance.available_balance + signedAmount : 0;
|
||||
|
||||
function openAdjustment() {
|
||||
setDirection("increase");
|
||||
setAmount("");
|
||||
setReason("");
|
||||
setAdjustmentId(crypto.randomUUID());
|
||||
setAdjustmentKey(idempotencyKey());
|
||||
setAdjusting(true);
|
||||
setNotice("");
|
||||
}
|
||||
|
||||
async function submitAdjustment(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!session || !balance || !adjustmentId || !adjustmentKey || signedAmount === 0 || !reason.trim() || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await readJson<CreditBalance & { adjustment_id: string; status: "adjusted" }>(
|
||||
`/api/v1/admin/users/${userId}/credit-adjustments`,
|
||||
{
|
||||
body: JSON.stringify({ adjustment_id: adjustmentId, amount: signedAmount, reason: reason.trim() }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Idempotency-Key": adjustmentKey,
|
||||
"X-CSRF-Token": session.csrf_token,
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
setBalance({
|
||||
available_balance: result.available_balance,
|
||||
reserved_balance: result.reserved_balance,
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
setAdjusting(false);
|
||||
setNotice("点数调整已完成并记录审计");
|
||||
} catch {
|
||||
setNotice("点数调整未完成,请检查输入后重试");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-users-page">
|
||||
<header className="admin-product-header">
|
||||
<a href="/admin">DADA ADMIN</a>
|
||||
<nav aria-label="后台导航"><a aria-current="page" href="/admin/users">用户</a><a href="/admin/audit">审计</a></nav>
|
||||
</header>
|
||||
<main>
|
||||
<header className="admin-users-heading">
|
||||
<div><p>USER OPERATIONS</p><h1>用户点数</h1></div>
|
||||
{balance ? <button onClick={openAdjustment} type="button">调整点数</button> : null}
|
||||
</header>
|
||||
{!userId ? <p className="admin-users-empty">从用户列表选择用户后调整点数。</p> : null}
|
||||
{failed ? <p className="admin-users-error" role="alert">用户点数暂时无法读取。</p> : null}
|
||||
{userId && !balance && !failed ? <p className="admin-users-empty" aria-live="polite">正在读取用户点数</p> : null}
|
||||
{balance ? (
|
||||
<section className="admin-credit-summary" aria-label="用户点数摘要">
|
||||
<div><span>内部用户标识</span><code>{userId}</code></div>
|
||||
<div><span>可用点数</span><strong className={balance.available_balance < 0 ? "is-negative" : undefined}>{balance.available_balance}</strong></div>
|
||||
<div><span>冻结点数</span><strong>{balance.reserved_balance}</strong></div>
|
||||
</section>
|
||||
) : null}
|
||||
{notice ? <p className="admin-users-notice" role="status">{notice}</p> : null}
|
||||
</main>
|
||||
{adjusting && balance ? (
|
||||
<div className="credit-adjustment-overlay">
|
||||
<section aria-labelledby="adjustment-title" aria-modal="true" className="credit-adjustment-dialog" role="dialog">
|
||||
<p>CREDIT ADJUSTMENT</p>
|
||||
<h2 id="adjustment-title">调整点数</h2>
|
||||
<div className="adjustment-direction" role="radiogroup" aria-label="调整方向">
|
||||
<label><input checked={direction === "increase"} name="direction" onChange={() => setDirection("increase")} type="radio" />增加</label>
|
||||
<label><input checked={direction === "decrease"} name="direction" onChange={() => setDirection("decrease")} type="radio" />扣减</label>
|
||||
</div>
|
||||
<form onSubmit={submitAdjustment}>
|
||||
<label htmlFor={amountId}>调整数量</label>
|
||||
<input id={amountId} inputMode="numeric" min="1" onChange={(event) => setAmount(event.target.value.replace(/\D/g, ""))} type="number" value={amount} />
|
||||
<label htmlFor={reasonId}>调整原因</label>
|
||||
<textarea id={reasonId} maxLength={500} onChange={(event) => setReason(event.target.value)} value={reason} />
|
||||
<div className="adjustment-preview">
|
||||
<span>调整前可用点数 {balance.available_balance}</span>
|
||||
<strong>调整后可用点数 {projected}</strong>
|
||||
<span>冻结点数保持 {balance.reserved_balance}</span>
|
||||
</div>
|
||||
<div className="adjustment-actions">
|
||||
<button disabled={signedAmount === 0 || !reason.trim() || submitting} type="submit">{submitting ? "处理中" : "确认调整"}</button>
|
||||
<button disabled={submitting} onClick={() => setAdjusting(false)} type="button">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user