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
+250
View File
@@ -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;
}
}
+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>
);
}
+200
View File
@@ -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;
}
}
+162
View File
@@ -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>
);
}
+31 -1
View File
@@ -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 ?? {} });
+65
View File
@@ -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;
+4
View File
@@ -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(
+3 -3
View File
@@ -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>;
}