Files
pikasTech-HWLAB/web/hwlab-cloud-web/src/views/admin/UsersView.vue
T
2026-06-14 00:51:46 +08:00

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>