163 lines
7.1 KiB
TypeScript
163 lines
7.1 KiB
TypeScript
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>
|
|
);
|
|
}
|