119 lines
4.9 KiB
Vue
119 lines
4.9 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, ref } from "vue";
|
|
import { usageAPI } from "@/api";
|
|
import EmptyState from "@/components/common/EmptyState.vue";
|
|
import PageHeader from "@/components/common/PageHeader.vue";
|
|
import type { AdminBillingSummary } from "@/types";
|
|
|
|
const summary = ref<AdminBillingSummary | null>(null);
|
|
const loading = ref(true);
|
|
const error = ref<string | null>(null);
|
|
|
|
const totals = computed(() => summary.value?.totals ?? {});
|
|
const users = computed(() => summary.value?.users ?? []);
|
|
|
|
onMounted(refresh);
|
|
|
|
async function refresh(): Promise<void> {
|
|
loading.value = true;
|
|
const response = await usageAPI.adminBillingSummary();
|
|
if (!response.ok) {
|
|
error.value = response.error ?? `HTTP ${response.status}`;
|
|
summary.value = null;
|
|
} else {
|
|
summary.value = response.data;
|
|
error.value = null;
|
|
}
|
|
loading.value = false;
|
|
}
|
|
|
|
function formatNumber(value: unknown): string {
|
|
const number = Number(value ?? 0);
|
|
if (!Number.isFinite(number)) return "0";
|
|
return new Intl.NumberFormat("zh-CN").format(number);
|
|
}
|
|
|
|
function formatDate(value: unknown): string {
|
|
const text = String(value ?? "").trim();
|
|
if (!text) return "-";
|
|
const date = new Date(text);
|
|
if (Number.isNaN(date.getTime())) return text;
|
|
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<section class="route-stack">
|
|
<PageHeader eyebrow="Admin" title="用户与账务" description="来自 PK01 PostgreSQL ledger 的用户余额、用量和 reservation 概览。" />
|
|
<EmptyState v-if="loading" title="正在加载用户账务" description="正在通过 cloud-api 代理读取 user-billing admin summary。" />
|
|
<EmptyState v-else-if="error" title="管理员账务接口不可用" :description="error" />
|
|
<EmptyState v-else-if="!summary" title="暂无用户账务" description="当前运行面未返回 admin billing summary。" />
|
|
<template v-else>
|
|
<div class="overview-grid usage-metrics">
|
|
<article class="metric-card">
|
|
<span>用户</span>
|
|
<strong>{{ formatNumber(totals.users ?? summary.count) }}</strong>
|
|
</article>
|
|
<article class="metric-card">
|
|
<span>平台 credit</span>
|
|
<strong>{{ formatNumber(totals.availableCredits) }}</strong>
|
|
</article>
|
|
<article class="metric-card">
|
|
<span>活跃预留</span>
|
|
<strong>{{ formatNumber(totals.activeReservations) }}</strong>
|
|
</article>
|
|
</div>
|
|
|
|
<section class="data-panel usage-panel admin-billing-panel">
|
|
<header class="panel-header">
|
|
<h2>用户账务</h2>
|
|
<small>{{ summary.state?.stateAuthority ?? "pk01-postgres" }} / {{ summary.state?.redis?.role ?? "cache-only" }}</small>
|
|
</header>
|
|
<table v-if="users.length" class="data-table usage-table admin-billing-table">
|
|
<thead>
|
|
<tr>
|
|
<th>User</th>
|
|
<th>Status</th>
|
|
<th>Credits</th>
|
|
<th>Usage</th>
|
|
<th>API keys</th>
|
|
<th>Reservations</th>
|
|
<th>Last activity</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="item in users" :key="item.user?.id ?? item.user?.username">
|
|
<td>
|
|
<strong>{{ item.user?.displayName || item.user?.username || item.user?.id }}</strong>
|
|
<small>{{ item.user?.username }} · {{ item.planId ?? "default" }}</small>
|
|
</td>
|
|
<td>
|
|
<span class="status-pill" :data-status="item.user?.status">{{ item.user?.role }} / {{ item.user?.status }}</span>
|
|
</td>
|
|
<td>
|
|
<strong>{{ formatNumber(item.credits?.available) }}</strong>
|
|
<small>{{ formatNumber(item.credits?.balance) }} balance · {{ formatNumber(item.credits?.reserved) }} reserved</small>
|
|
</td>
|
|
<td>
|
|
<strong>{{ formatNumber(item.usage?.totalCredits) }}</strong>
|
|
<small>{{ formatNumber(item.usage?.recordCount) }} records · {{ formatNumber(item.usage?.totalQuantity) }} units</small>
|
|
</td>
|
|
<td>
|
|
<strong>{{ formatNumber(item.apiKeys?.activeCount) }}</strong>
|
|
<small>{{ formatNumber(item.apiKeys?.totalCount) }} total</small>
|
|
</td>
|
|
<td>
|
|
<strong>{{ formatNumber(item.reservations?.activeCount) }}</strong>
|
|
<small v-if="item.reservations?.active?.length"><code>{{ item.reservations.active[0]?.serviceId }}</code></small>
|
|
<small v-else>none</small>
|
|
</td>
|
|
<td>{{ formatDate(item.usage?.lastUsedAt ?? item.apiKeys?.lastUsedAt ?? item.user?.createdAt) }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p v-else class="muted-box">暂无用户记录。</p>
|
|
</section>
|
|
</template>
|
|
</section>
|
|
</template>
|