Files
tyx_AI_xhs/apps/web/src/credits-page.tsx
T

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>
);
}