feat: complete TASK-WP2-04 credit ledger

This commit is contained in:
suyx
2026-08-02 23:27:10 +08:00
parent 9b4860cfc2
commit 4551d73e76
24 changed files with 3002 additions and 6 deletions
+153
View File
@@ -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>
);
}