feat: complete TASK-WP2-04 credit ledger
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
.admin-users-page {
|
||||
min-height: 100vh;
|
||||
color: #111111;
|
||||
background: #f6f6f4;
|
||||
}
|
||||
|
||||
.admin-product-header {
|
||||
display: flex;
|
||||
min-height: 64px;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
padding: 0 max(3vw, 32px);
|
||||
border-bottom: 1px solid #8c8c86;
|
||||
background: #111111;
|
||||
}
|
||||
|
||||
.admin-product-header > a,
|
||||
.admin-product-header nav a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #ffffff;
|
||||
font-weight: 900;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.admin-product-header nav {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.admin-product-header nav a {
|
||||
min-width: 84px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.admin-product-header nav a[aria-current="page"] {
|
||||
color: #111111;
|
||||
background: #f2f500;
|
||||
}
|
||||
|
||||
.admin-users-page > main {
|
||||
width: min(1180px, calc(100% - 64px));
|
||||
margin: 0 auto;
|
||||
padding: 42px 0 80px;
|
||||
}
|
||||
|
||||
.admin-users-heading {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding-bottom: 22px;
|
||||
border-bottom: 1px solid #999993;
|
||||
}
|
||||
|
||||
.admin-users-heading p {
|
||||
margin: 0 0 4px;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-users-heading h1 {
|
||||
margin: 0;
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.admin-users-heading button,
|
||||
.adjustment-actions button {
|
||||
min-height: 44px;
|
||||
padding: 9px 16px;
|
||||
border: 1px solid #111111;
|
||||
border-radius: 0;
|
||||
background: #f2f500;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.admin-credit-summary {
|
||||
display: grid;
|
||||
grid-template-columns: 1.6fr 1fr 1fr;
|
||||
margin-top: 28px;
|
||||
border-block: 1px solid #85857f;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.admin-credit-summary > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 140px;
|
||||
align-content: center;
|
||||
gap: 8px;
|
||||
padding: 22px;
|
||||
border-right: 1px solid #b3b3ad;
|
||||
}
|
||||
|
||||
.admin-credit-summary > div:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.admin-credit-summary span {
|
||||
color: #65655f;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-credit-summary strong {
|
||||
font-family: Arial Black, "Segoe UI", sans-serif;
|
||||
font-size: 38px;
|
||||
}
|
||||
|
||||
.admin-credit-summary code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-users-empty,
|
||||
.admin-users-error,
|
||||
.admin-users-notice {
|
||||
padding: 18px;
|
||||
border-left: 5px solid #85857f;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.admin-users-error {
|
||||
border-color: #d14a3b;
|
||||
background: #fff1ef;
|
||||
}
|
||||
|
||||
.admin-users-notice {
|
||||
border-color: #287b45;
|
||||
background: #edf8f0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.credit-adjustment-overlay {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgb(17 17 17 / 62%);
|
||||
}
|
||||
|
||||
.credit-adjustment-dialog {
|
||||
width: min(560px, 100%);
|
||||
padding: 28px;
|
||||
border: 2px solid #111111;
|
||||
background: #ffffff;
|
||||
box-shadow: 10px 10px 0 #f2f500;
|
||||
}
|
||||
|
||||
.credit-adjustment-dialog > p {
|
||||
margin: 0 0 6px;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.credit-adjustment-dialog h2 {
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
|
||||
.adjustment-direction {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.adjustment-direction label {
|
||||
display: flex;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 1px solid #85857f;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.adjustment-direction label + label {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.credit-adjustment-dialog form {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.credit-adjustment-dialog form > label {
|
||||
margin-top: 6px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.credit-adjustment-dialog input[type="number"],
|
||||
.credit-adjustment-dialog textarea {
|
||||
min-height: 44px;
|
||||
padding: 10px;
|
||||
border: 1px solid #85857f;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.credit-adjustment-dialog textarea {
|
||||
min-height: 92px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.adjustment-preview {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin-top: 10px;
|
||||
padding: 14px;
|
||||
border-left: 5px solid #111111;
|
||||
background: #e7e7e2;
|
||||
}
|
||||
|
||||
.adjustment-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.adjustment-actions button:last-child {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.adjustment-actions button:disabled {
|
||||
color: #777770;
|
||||
background: #dfdfda;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.is-negative {
|
||||
color: #a52e24;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.admin-users-page > main {
|
||||
width: 100%;
|
||||
padding-right: 16px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.admin-credit-summary,
|
||||
.adjustment-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-credit-summary > div {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #b3b3ad;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
.credits-page {
|
||||
width: min(1280px, calc(100% - 64px));
|
||||
margin: 0 auto;
|
||||
padding: 44px 0 80px;
|
||||
}
|
||||
|
||||
.credits-heading {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px solid #9c9c96;
|
||||
}
|
||||
|
||||
.credits-heading p {
|
||||
margin: 0 0 4px;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.credits-heading h1 {
|
||||
margin: 0;
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.credits-heading time {
|
||||
color: #676761;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.credit-summary {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr minmax(260px, 1.2fr);
|
||||
align-items: stretch;
|
||||
margin: 28px 0;
|
||||
border-block: 1px solid #85857f;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.credit-summary > div {
|
||||
display: grid;
|
||||
min-height: 142px;
|
||||
align-content: center;
|
||||
gap: 4px;
|
||||
padding: 22px;
|
||||
border-right: 1px solid #b3b3ad;
|
||||
}
|
||||
|
||||
.credit-summary span,
|
||||
.credit-summary small {
|
||||
color: #65655f;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.credit-summary strong {
|
||||
font-family: Arial Black, "Segoe UI", sans-serif;
|
||||
font-size: 42px;
|
||||
}
|
||||
|
||||
.credit-summary > p {
|
||||
display: grid;
|
||||
margin: 0;
|
||||
align-content: center;
|
||||
padding: 22px;
|
||||
background: #f2f500;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.is-negative {
|
||||
color: #a52e24;
|
||||
}
|
||||
|
||||
.is-positive {
|
||||
color: #287b45;
|
||||
}
|
||||
|
||||
.credits-error {
|
||||
padding: 12px 14px;
|
||||
border-left: 5px solid #d14a3b;
|
||||
background: #fff1ef;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.credit-ledger > header {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.credit-ledger h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.credit-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.credit-filters label {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
color: #5d5d57;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.credit-filters select,
|
||||
.credit-filters input {
|
||||
min-height: 40px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #85857f;
|
||||
border-radius: 0;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.credit-table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid #85857f;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.credit-table-wrap table {
|
||||
width: 100%;
|
||||
min-width: 880px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.credit-table-wrap th,
|
||||
.credit-table-wrap td {
|
||||
padding: 13px 14px;
|
||||
border-bottom: 1px solid #d0d0ca;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.credit-table-wrap th {
|
||||
background: #e7e7e2;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.credit-table-wrap td {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.credit-table-wrap td small {
|
||||
display: block;
|
||||
max-width: 260px;
|
||||
margin-top: 4px;
|
||||
overflow-wrap: anywhere;
|
||||
color: #666660;
|
||||
}
|
||||
|
||||
.ledger-empty {
|
||||
display: grid;
|
||||
min-height: 280px;
|
||||
place-items: center;
|
||||
border: 1px dashed #969690;
|
||||
}
|
||||
|
||||
.ledger-more,
|
||||
.ledger-retry {
|
||||
min-height: 44px;
|
||||
margin-top: 14px;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #111111;
|
||||
border-radius: 0;
|
||||
background: #ffffff;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.credits-page {
|
||||
width: 100%;
|
||||
padding-right: 16px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.credits-heading,
|
||||
.credit-ledger > header {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.credit-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.credit-summary > div {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #b3b3ad;
|
||||
}
|
||||
|
||||
.credit-filters {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { LocalOnlyFooter, ProductHeader } from "./project-pages.js";
|
||||
|
||||
import "./credits-page.css";
|
||||
|
||||
type EntryType = "registration_grant" | "generation_reserve" | "generation_commit" | "generation_release" | "admin_adjustment";
|
||||
|
||||
interface CreditBalance {
|
||||
available_balance: number;
|
||||
reserved_balance: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface LedgerEntry {
|
||||
amount: number;
|
||||
available_after: number;
|
||||
available_before: number;
|
||||
created_at: string;
|
||||
entry_id: string;
|
||||
entry_type: EntryType;
|
||||
model_id: string | null;
|
||||
reason: string | null;
|
||||
reference_id: string | null;
|
||||
reference_type: "registration" | "generation" | "admin_adjustment" | null;
|
||||
reserved_after: number;
|
||||
reserved_before: number;
|
||||
status: "succeeded" | "frozen" | "committed" | "released";
|
||||
}
|
||||
|
||||
interface LedgerResponse {
|
||||
credits: Pick<CreditBalance, "available_balance" | "reserved_balance">;
|
||||
entries: LedgerEntry[];
|
||||
next_cursor: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
const entryLabels: Record<EntryType, string> = {
|
||||
admin_adjustment: "管理员调整",
|
||||
generation_commit: "生成结算",
|
||||
generation_release: "生成释放",
|
||||
generation_reserve: "生成冻结",
|
||||
registration_grant: "注册赠点",
|
||||
};
|
||||
|
||||
const statusLabels = {
|
||||
committed: "已结算",
|
||||
frozen: "已冻结",
|
||||
released: "已释放",
|
||||
succeeded: "已完成",
|
||||
} as const;
|
||||
|
||||
async function readJson<T>(url: string) {
|
||||
const response = await fetch(url, { credentials: "same-origin" });
|
||||
if (response.status === 401) {
|
||||
window.dispatchEvent(new Event("dada:session-invalid"));
|
||||
throw new Error("session_invalid");
|
||||
}
|
||||
if (!response.ok) throw new Error("request_failed");
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function localTime(value: string) {
|
||||
return new Intl.DateTimeFormat("zh-CN", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
|
||||
}
|
||||
|
||||
export function CreditsPage() {
|
||||
const [balance, setBalance] = useState<CreditBalance>();
|
||||
const [entries, setEntries] = useState<LedgerEntry[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [eventType, setEventType] = useState<EntryType | "">("");
|
||||
const [from, setFrom] = useState("");
|
||||
const [to, setTo] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
const load = useCallback(async (cursor?: string) => {
|
||||
const query = new URLSearchParams({ limit: "20" });
|
||||
if (cursor) query.set("cursor", cursor);
|
||||
if (eventType) query.set("event_type", eventType);
|
||||
if (from) query.set("from", new Date(`${from}T00:00:00`).toISOString());
|
||||
if (to) query.set("to", new Date(`${to}T23:59:59.999`).toISOString());
|
||||
if (cursor) setLoadingMore(true); else setLoading(true);
|
||||
setFailed(false);
|
||||
try {
|
||||
const [nextBalance, ledger] = await Promise.all([
|
||||
readJson<CreditBalance>("/api/v1/me/credits"),
|
||||
readJson<LedgerResponse>(`/api/v1/me/credit-ledger?${query}`),
|
||||
]);
|
||||
setBalance(nextBalance);
|
||||
setEntries((current) => cursor ? [...current, ...ledger.entries] : ledger.entries);
|
||||
setNextCursor(ledger.next_cursor);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || error.message !== "session_invalid") setFailed(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [eventType, from, to]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
return (
|
||||
<div className="product-page">
|
||||
<ProductHeader current="credits" />
|
||||
<main className="credits-page">
|
||||
<header className="credits-heading">
|
||||
<div><p>ACCOUNT CREDIT</p><h1>点数明细</h1></div>
|
||||
{balance ? <time dateTime={balance.updated_at}>更新于 {localTime(balance.updated_at)}</time> : null}
|
||||
</header>
|
||||
{balance ? (
|
||||
<section className="credit-summary" aria-label="点数余额">
|
||||
<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><small>已冻结 {balance.reserved_balance} 点</small></div>
|
||||
<p>可用点数不足时,请联系管理员调整点数。</p>
|
||||
</section>
|
||||
) : null}
|
||||
{failed ? <p className="credits-error" role="alert">点数读取失败。{balance ? "当前保留上次余额,提交时仍以服务端实时校验为准。" : "请重新读取。"}</p> : null}
|
||||
<section className="credit-ledger" aria-labelledby="ledger-title">
|
||||
<header>
|
||||
<h2 id="ledger-title">变动记录</h2>
|
||||
<div className="credit-filters">
|
||||
<label>事件类型
|
||||
<select value={eventType} onChange={(event) => setEventType(event.target.value as EntryType | "")}>
|
||||
<option value="">全部事件</option>
|
||||
{Object.entries(entryLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>开始日期<input type="date" value={from} onChange={(event) => setFrom(event.target.value)} /></label>
|
||||
<label>结束日期<input type="date" value={to} onChange={(event) => setTo(event.target.value)} /></label>
|
||||
</div>
|
||||
</header>
|
||||
{loading && entries.length === 0 ? <p className="ledger-empty" aria-live="polite">正在读取点数记录</p> : null}
|
||||
{!loading && entries.length === 0 && !failed ? <p className="ledger-empty">暂无点数变动</p> : null}
|
||||
{entries.length > 0 ? (
|
||||
<div className="credit-table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>时间</th><th>事件</th><th>状态</th><th>变动</th><th>关联</th><th>余额</th></tr></thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.entry_id}>
|
||||
<td><time dateTime={entry.created_at}>{localTime(entry.created_at)}</time></td>
|
||||
<td><strong>{entryLabels[entry.entry_type]}</strong>{entry.reason ? <small>{entry.reason}</small> : null}</td>
|
||||
<td>{statusLabels[entry.status]}</td>
|
||||
<td className={entry.amount < 0 ? "is-negative" : "is-positive"}>变动 {entry.amount > 0 ? `+${entry.amount}` : entry.amount}</td>
|
||||
<td>{entry.reference_type === "generation" ? "生成任务" : entry.reference_type === "admin_adjustment" ? "管理操作" : "注册"}</td>
|
||||
<td>可用 {entry.available_after} / 冻结 {entry.reserved_after}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
{nextCursor ? <button className="ledger-more" disabled={loadingMore} onClick={() => void load(nextCursor)} type="button">{loadingMore ? "加载中" : "加载更多"}</button> : null}
|
||||
{failed ? <button className="ledger-retry" onClick={() => void load()} type="button">重新读取</button> : null}
|
||||
</section>
|
||||
</main>
|
||||
<LocalOnlyFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
// Generated from openapi/openapi.json. Do not edit by hand.
|
||||
|
||||
import type { AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, AccountSettingsResponse, AdminSessionResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, LogoutResponse, ProjectPurgeResponse, ProjectRenameResponse, ProjectRenameRequest, ProjectRestoreResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js";
|
||||
import type { CreditAdjustmentResponse, CreditAdjustmentRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, AccountSettingsResponse, AdminSessionResponse, CreditBalanceResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, LogoutResponse, ProjectPurgeResponse, ProjectRenameResponse, ProjectRenameRequest, ProjectRestoreResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js";
|
||||
|
||||
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
|
||||
|
||||
export async function adjustAdminUserCredits(body: CreditAdjustmentRequest, options: ClientOptions = {}): Promise<CreditAdjustmentResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/users/{userId}/credit-adjustments`, { body: JSON.stringify(body), method: "POST", headers });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<CreditAdjustmentResponse>;
|
||||
}
|
||||
|
||||
export async function checkBrowserSupport(body: {
|
||||
"brands": Array<{
|
||||
"brand": string;
|
||||
@@ -95,6 +104,13 @@ export async function getAdminSession(options: ClientOptions = {}): Promise<Admi
|
||||
return response.json() as Promise<AdminSessionResponse>;
|
||||
}
|
||||
|
||||
export async function getAdminUserCredits(options: ClientOptions = {}): Promise<CreditBalanceResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/users/{userId}/credits`, { method: "GET", headers: options.headers ?? {} });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<CreditBalanceResponse>;
|
||||
}
|
||||
|
||||
export async function getBootstrap(options: ClientOptions = {}): Promise<{
|
||||
"app_version": string;
|
||||
"dependencies": Array<{
|
||||
@@ -138,6 +154,20 @@ export function getEvents(options: Pick<ClientOptions, "baseUrl"> = {}): string
|
||||
return `${options.baseUrl ?? ""}/api/v1/events`;
|
||||
}
|
||||
|
||||
export async function getMyCreditLedger(options: ClientOptions = {}): Promise<CreditLedgerResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/me/credit-ledger`, { method: "GET", headers: options.headers ?? {} });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<CreditLedgerResponse>;
|
||||
}
|
||||
|
||||
export async function getMyCredits(options: ClientOptions = {}): Promise<CreditBalanceResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/me/credits`, { method: "GET", headers: options.headers ?? {} });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<CreditBalanceResponse>;
|
||||
}
|
||||
|
||||
export async function getProject(options: ClientOptions = {}): Promise<ProjectDetailResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}`, { method: "GET", headers: options.headers ?? {} });
|
||||
|
||||
@@ -56,6 +56,10 @@ export type AdminAuthenticatedUser = {
|
||||
"user_id": string;
|
||||
};
|
||||
|
||||
export type AdminCreditParams = {
|
||||
"userId": string;
|
||||
};
|
||||
|
||||
export type AdminLoginCompleteRequest = {
|
||||
"registration_id": string;
|
||||
"verification_code": string;
|
||||
@@ -197,6 +201,67 @@ export type CanvasState = {
|
||||
|
||||
export type CorrelationId = string;
|
||||
|
||||
export type CreditAdjustmentHeaders = {
|
||||
"idempotency-key": string;
|
||||
"x-csrf-token": string;
|
||||
};
|
||||
|
||||
export type CreditAdjustmentRequest = {
|
||||
"adjustment_id": string;
|
||||
"amount": number | number;
|
||||
"reason": string;
|
||||
};
|
||||
|
||||
export type CreditAdjustmentResponse = {
|
||||
"adjustment_id": string;
|
||||
"available_balance": number;
|
||||
"reserved_balance": number;
|
||||
"status": "adjusted";
|
||||
};
|
||||
|
||||
export type CreditBalanceResponse = {
|
||||
"available_balance": number;
|
||||
"reserved_balance": number;
|
||||
"updated_at": string;
|
||||
};
|
||||
|
||||
export type CreditEntryStatus = "succeeded" | "frozen" | "committed" | "released";
|
||||
|
||||
export type CreditEntryType = "registration_grant" | "generation_reserve" | "generation_commit" | "generation_release" | "admin_adjustment";
|
||||
|
||||
export type CreditLedgerEntry = {
|
||||
"amount": number;
|
||||
"available_after": number;
|
||||
"available_before": number;
|
||||
"created_at": string;
|
||||
"entry_id": string;
|
||||
"entry_type": CreditEntryType;
|
||||
"model_id": string | null;
|
||||
"reason": string | null;
|
||||
"reference_id": string | null;
|
||||
"reference_type": CreditReferenceType | null;
|
||||
"reserved_after": number;
|
||||
"reserved_before": number;
|
||||
"status": CreditEntryStatus;
|
||||
};
|
||||
|
||||
export type CreditLedgerQuery = {
|
||||
"cursor"?: string;
|
||||
"event_type"?: CreditEntryType;
|
||||
"from"?: string;
|
||||
"limit"?: number;
|
||||
"to"?: string;
|
||||
};
|
||||
|
||||
export type CreditLedgerResponse = {
|
||||
"credits": CreditSummary;
|
||||
"entries": Array<CreditLedgerEntry>;
|
||||
"next_cursor": string | null;
|
||||
"updated_at": string;
|
||||
};
|
||||
|
||||
export type CreditReferenceType = "registration" | "generation" | "admin_adjustment";
|
||||
|
||||
export type CreditSummary = {
|
||||
"available_balance": number;
|
||||
"reserved_balance": number;
|
||||
|
||||
@@ -5,6 +5,8 @@ import { registerPublicAssetServiceWorker } from "./public-asset-cache.js";
|
||||
import { AdminAuthPage } from "./admin-auth.js";
|
||||
import { UserAuthPage } from "./user-auth.js";
|
||||
import { AccountSettingsPage } from "./account-settings.js";
|
||||
import { AdminUsersPage } from "./admin-users.js";
|
||||
import { CreditsPage } from "./credits-page.js";
|
||||
import { ProjectDetailPage, ProjectsPage, WorkspacePage } from "./project-pages.js";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
@@ -24,9 +26,11 @@ function renderAuthenticationEntry() {
|
||||
const projectDetail = window.location.pathname.match(/^\/app\/projects\/([0-9a-f-]{36})$/i);
|
||||
let authenticationPage;
|
||||
if (window.location.pathname === "/app/settings") authenticationPage = <AccountSettingsPage key={authRevision} />;
|
||||
else if (window.location.pathname === "/app/credits") authenticationPage = <CreditsPage key={authRevision} />;
|
||||
else if (projectDetail?.[1]) authenticationPage = <ProjectDetailPage key={authRevision} projectId={projectDetail[1]} />;
|
||||
else if (window.location.pathname === "/app/projects") authenticationPage = <ProjectsPage key={authRevision} />;
|
||||
else if (window.location.pathname === "/app") authenticationPage = <WorkspacePage key={authRevision} />;
|
||||
else if (window.location.pathname === "/admin/users") authenticationPage = <AdminUsersPage key={authRevision} />;
|
||||
else if (window.location.pathname.startsWith("/admin")) authenticationPage = <AdminAuthPage key={authRevision} />;
|
||||
else authenticationPage = <UserAuthPage key={authRevision} />;
|
||||
appRoot.render(
|
||||
|
||||
@@ -117,21 +117,21 @@ async function downloadConflictPng(canvasState: CanvasState, projectName: string
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
function ProductHeader({ current }: { current: "workspace" | "projects" }) {
|
||||
export function ProductHeader({ current }: { current: "workspace" | "projects" | "credits" }) {
|
||||
return (
|
||||
<header className="product-header">
|
||||
<a className="product-brand" href="/app">DADA</a>
|
||||
<nav aria-label="主导航">
|
||||
<a aria-current={current === "workspace" ? "page" : undefined} href="/app">创作</a>
|
||||
<a aria-current={current === "projects" ? "page" : undefined} href="/app/projects">项目</a>
|
||||
<a href="/app/credits">点数</a>
|
||||
<a aria-current={current === "credits" ? "page" : undefined} href="/app/credits">点数</a>
|
||||
<a href="/app/settings">设置</a>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function LocalOnlyFooter() {
|
||||
export function LocalOnlyFooter() {
|
||||
return <footer className="local-only-footer">测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。</footer>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user