feat(web): 全员总览——时间范围、健康带、来源分解、成员表与 key 明细
This commit is contained in:
@@ -21,6 +21,7 @@ export type AdminOverview = {
|
||||
key_id: number;
|
||||
token_name: string;
|
||||
member_id: number | null;
|
||||
member_name: string | null;
|
||||
status: string;
|
||||
data_cutoff: UnixSec | null;
|
||||
usage: UsageTotals;
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 健康带(交互-全员总览 §1.1):引擎轮次 · 全池数据截止 · 未关联订阅
|
||||
* 文案偏完整说明,不追求极简电报体。
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import type { EngineStatus } from "@new-api-key-usage/core";
|
||||
import type { AdminOverview } from "@/api/admin/overview.ts";
|
||||
import { formatMinute, hoursSince } from "@/utils/time.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
engine: EngineStatus | undefined;
|
||||
overview: AdminOverview | undefined;
|
||||
loading?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "go-failed-keys"): void;
|
||||
}>();
|
||||
|
||||
const lastRound = computed(() => props.engine?.last_round ?? null);
|
||||
const failedCount = computed(() => lastRound.value?.failed ?? 0);
|
||||
const cutoff = computed(() => props.overview?.data_cutoff ?? null);
|
||||
const cutoffHours = computed(() => hoursSince(cutoff.value));
|
||||
const cutoffStale = computed(
|
||||
() => cutoffHours.value != null && cutoffHours.value > 2,
|
||||
);
|
||||
const unassigned = computed(
|
||||
() => props.overview?.unassigned_subscription ?? null,
|
||||
);
|
||||
const unassignedBad = computed(
|
||||
() => (unassigned.value?.count ?? 0) > 0 || (unassigned.value?.quota ?? 0) > 0,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="health-band">
|
||||
<a-skeleton v-if="loading" active :paragraph="{ rows: 1 }" :title="false" />
|
||||
<template v-else>
|
||||
<div class="health-band__item">
|
||||
<div class="health-band__title">同步引擎</div>
|
||||
<div class="health-band__body">
|
||||
<template v-if="lastRound">
|
||||
最近一次同步轮次共处理
|
||||
<strong>{{ lastRound.keys_total }}</strong>
|
||||
枚 key,其中成功
|
||||
<strong>{{ lastRound.succeeded }}</strong>
|
||||
枚。
|
||||
<template v-if="failedCount > 0">
|
||||
有
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
class="health-band__fail-btn"
|
||||
@click="emit('go-failed-keys')"
|
||||
>
|
||||
{{ failedCount }} 枚拉取失败
|
||||
</a-button>
|
||||
,可到 Key 管理筛选「最近轮次失败」查看是哪几枚。
|
||||
</template>
|
||||
<template v-else>没有拉取失败。</template>
|
||||
</template>
|
||||
<span v-else class="health-band__muted">
|
||||
引擎尚未完成过同步轮次,或引擎进程未启动——此时用量数据可能还不完整。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="health-band__item">
|
||||
<div class="health-band__title">数据截止</div>
|
||||
<div class="health-band__body">
|
||||
<a-tooltip
|
||||
title="取全部 key 的 log_synced_at 中最慢的那一个,代表「全池同步到什么时刻」"
|
||||
>
|
||||
<span>全池同步截止于 <strong>{{ formatMinute(cutoff) }}</strong>(北京时间)。</span>
|
||||
</a-tooltip>
|
||||
<span v-if="cutoffStale" class="health-band__warn">
|
||||
截止时间落后当前超过 2 小时,同步可能已停滞——请检查引擎日志。
|
||||
</span>
|
||||
<span v-else-if="cutoff != null" class="health-band__ok">
|
||||
目前在正常滞后范围内。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="health-band__item">
|
||||
<div class="health-band__title">未关联订阅</div>
|
||||
<div class="health-band__body">
|
||||
<a-tooltip
|
||||
title="统计的是「来源为订阅、但日志上没有订阅编号」的用量;正常情况下应始终为 0"
|
||||
>
|
||||
<template v-if="unassignedBad">
|
||||
<span class="health-band__danger">
|
||||
本时段发现
|
||||
{{ unassigned?.count }} 条 / ${{ unassigned?.quota_usd }}
|
||||
订阅来源用量未关联到任何订阅编号
|
||||
</span>
|
||||
(说明支付记录缺登记,或上游计费行为发生了变化),需要人工核对。
|
||||
</template>
|
||||
<template v-else>
|
||||
本时段没有出现「订阅来源却关联不到订阅编号」的用量,属正常状态。
|
||||
</template>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.health-band {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.health-band__item {
|
||||
display: grid;
|
||||
grid-template-columns: 96px 1fr;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.health-band__title {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
.health-band__body {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.health-band__body strong {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.health-band__muted {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.health-band__ok {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.health-band__warn {
|
||||
color: #d48806;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.health-band__danger {
|
||||
color: #cf1322;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.health-band__fail-btn {
|
||||
padding: 0 4px;
|
||||
height: auto;
|
||||
line-height: inherit;
|
||||
color: #cf1322;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -47,7 +47,10 @@ defineProps<{
|
||||
|
||||
.page-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
/* 与标题块上缘对齐时,控件 32px 高与 label 竖向居中 */
|
||||
min-height: 32px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 额度双单位:原始 quota + 美元(契约公共约定 §5;前端只呈现不换算)
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import { formatInt } from "@/utils/format.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
quota: number | null | undefined;
|
||||
quotaUsd: string | null | undefined;
|
||||
}>();
|
||||
|
||||
const quotaText = computed(() => formatInt(props.quota));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="quota-text">
|
||||
<span class="quota-text__num">{{ quotaText }}</span>
|
||||
<span class="quota-text__usd">(${{ quotaUsd ?? "—" }})</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.quota-text {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quota-text__num {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quota-text__usd {
|
||||
margin-left: 6px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 来源分解分段条(交互共性 §3.7:订阅 / 充值)——前端现算比例(公共约定 §5)
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
|
||||
type SourceRow = {
|
||||
billing_source: string;
|
||||
count: number;
|
||||
quota: number;
|
||||
quota_usd: string;
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
rows: SourceRow[];
|
||||
}>();
|
||||
|
||||
const segments = computed(() => {
|
||||
const total = props.rows.reduce((s, r) => s + r.quota, 0);
|
||||
return props.rows.map((r) => {
|
||||
const pct = total > 0 ? (r.quota / total) * 100 : 0;
|
||||
const isSub = r.billing_source === "subscription";
|
||||
return {
|
||||
key: r.billing_source,
|
||||
label: isSub ? "订阅" : r.billing_source === "wallet" ? "充值" : r.billing_source,
|
||||
pct,
|
||||
count: r.count,
|
||||
quota_usd: r.quota_usd,
|
||||
color: isSub ? "#1677ff" : "#faad14",
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const hasData = computed(() => segments.value.some((s) => s.pct > 0));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="source-bar">
|
||||
<div v-if="!hasData" class="source-bar__empty">
|
||||
本时段内没有任何消费调用,因此没有可分解的来源。
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="source-bar__track">
|
||||
<div
|
||||
v-for="seg in segments"
|
||||
:key="seg.key"
|
||||
class="source-bar__seg"
|
||||
:style="{ width: `${seg.pct}%`, background: seg.color }"
|
||||
:title="`${seg.label}来源:占额度 ${seg.pct.toFixed(1)}%,调用 ${seg.count} 次,$${seg.quota_usd}`"
|
||||
/>
|
||||
</div>
|
||||
<div class="source-bar__legend">
|
||||
<span v-for="seg in segments" :key="seg.key" class="source-bar__item">
|
||||
<i class="source-bar__dot" :style="{ background: seg.color }" />
|
||||
<span class="source-bar__label">{{ seg.label }}</span>
|
||||
<span class="source-bar__meta">
|
||||
调用 {{ seg.count }} 次,消耗 ${{ seg.quota_usd }}
|
||||
(占 {{ seg.pct.toFixed(1) }}%)
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.source-bar__track {
|
||||
display: flex;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.source-bar__seg {
|
||||
height: 100%;
|
||||
min-width: 2px;
|
||||
}
|
||||
|
||||
.source-bar__legend {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.source-bar__item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.source-bar__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 9999px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
.source-bar__label {
|
||||
font-weight: 600;
|
||||
min-width: 2.5em;
|
||||
}
|
||||
|
||||
.source-bar__meta {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.source-bar__empty {
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import type { Dayjs } from "dayjs";
|
||||
import dayjs from "dayjs";
|
||||
import { computed } from "vue";
|
||||
import { recentDaysRange } from "@/utils/time.ts";
|
||||
|
||||
type RangeValue = { from: string; to: string };
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: RangeValue;
|
||||
/** 外部预置(如「本订阅期间」);空则只保留内建快捷项 */
|
||||
extraPresets?: Array<{ label: string; from: string; to: string }>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: RangeValue): void;
|
||||
}>();
|
||||
|
||||
const pickerValue = computed<[Dayjs, Dayjs] | null>(() => {
|
||||
if (!props.modelValue.from || !props.modelValue.to) return null;
|
||||
return [dayjs(props.modelValue.from), dayjs(props.modelValue.to)];
|
||||
});
|
||||
|
||||
const presets = computed(() => {
|
||||
const builtIn = [
|
||||
{
|
||||
label: "最近 7 天",
|
||||
value: rangeToDayjs(recentDaysRange(7)),
|
||||
},
|
||||
{
|
||||
label: "最近 30 天",
|
||||
value: rangeToDayjs(recentDaysRange(30)),
|
||||
},
|
||||
];
|
||||
const extra = (props.extraPresets ?? []).map((p) => ({
|
||||
label: p.label,
|
||||
value: rangeToDayjs({ from: p.from, to: p.to }),
|
||||
}));
|
||||
return [...builtIn, ...extra];
|
||||
});
|
||||
|
||||
function rangeToDayjs(range: RangeValue): [Dayjs, Dayjs] {
|
||||
return [dayjs(range.from), dayjs(range.to)];
|
||||
}
|
||||
|
||||
function onChange(dates: Dayjs[] | null) {
|
||||
if (!dates || dates.length !== 2 || !dates[0] || !dates[1]) return;
|
||||
emit("update:modelValue", {
|
||||
from: dates[0].toISOString(),
|
||||
to: dates[1].toISOString(),
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-range-picker
|
||||
:value="pickerValue"
|
||||
:presets="presets"
|
||||
format="YYYY-MM-DD"
|
||||
:placeholder="['开始日期', '结束日期']"
|
||||
@change="onChange"
|
||||
/>
|
||||
</template>
|
||||
@@ -45,3 +45,23 @@ export function recentDaysRange(days: number): { from: string; to: string } {
|
||||
const from = to.subtract(days, "day");
|
||||
return { from: from.toISOString(), to: to.toISOString() };
|
||||
}
|
||||
|
||||
/** 统计口径标注:「2026-09-02 ~ 10-01」(北京时间日) */
|
||||
export function formatRangeCaption(fromIso: string, toIso: string): string {
|
||||
if (!fromIso || !toIso) return "—";
|
||||
const from = dayjs(fromIso).tz(DISPLAY_TZ).format("YYYY-MM-DD");
|
||||
const to = dayjs(toIso).tz(DISPLAY_TZ).format("YYYY-MM-DD");
|
||||
return `${from} ~ ${to}`;
|
||||
}
|
||||
|
||||
/** 「数据截止」到分钟(交互共性 §3.3) */
|
||||
export function formatMinute(unixSec: number | null | undefined): string {
|
||||
if (unixSec == null) return "—";
|
||||
return dayjs.unix(unixSec).tz(DISPLAY_TZ).format("YYYY-MM-DD HH:mm");
|
||||
}
|
||||
|
||||
/** 距今小时数(用于数据截止停滞判定) */
|
||||
export function hoursSince(unixSec: number | null | undefined): number | null {
|
||||
if (unixSec == null) return null;
|
||||
return (Date.now() / 1000 - unixSec) / 3600;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,314 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 全员总览(交互设计-全员总览):健康带 · 时间范围 · 按成员 · 按 key
|
||||
*/
|
||||
import { computed, h, ref } from "vue";
|
||||
import { Tooltip } from "antdv-next";
|
||||
import { QuestionCircleOutlined } from "@antdv-next/icons";
|
||||
import { useRouter } from "vue-router";
|
||||
import PageHeader from "@/components/common/page-header.vue";
|
||||
import TimeRangePicker from "@/components/common/time-range-picker.vue";
|
||||
import QuotaText from "@/components/common/quota-text.vue";
|
||||
import SourceBar from "@/components/common/source-bar.vue";
|
||||
import HealthBand from "@/components/admin/health-band.vue";
|
||||
import { useAdminOverview, useEngine, usePayments } from "@/queries/admin.ts";
|
||||
import { recentDaysRange, formatMinute } from "@/utils/time.ts";
|
||||
import { formatInt } from "@/utils/format.ts";
|
||||
|
||||
const router = useRouter();
|
||||
const range = ref(recentDaysRange(30));
|
||||
|
||||
const overviewQuery = useAdminOverview(() => range.value);
|
||||
const engineQuery = useEngine();
|
||||
const paymentsQuery = usePayments();
|
||||
|
||||
const overview = computed(() => overviewQuery.data.value);
|
||||
const engine = computed(() => engineQuery.data.value);
|
||||
const loading = computed(
|
||||
() => overviewQuery.isPending.value || engineQuery.isPending.value,
|
||||
);
|
||||
|
||||
/** 「本订阅期间」等预置:有 period 的订阅记录才出现(纯用量模式自动隐藏) */
|
||||
const extraPresets = computed(() => {
|
||||
const list = paymentsQuery.data.value ?? [];
|
||||
return list
|
||||
.filter((p) => p.kind === "subscription" && p.period_start != null)
|
||||
.map((p) => ({
|
||||
label: `订阅 #${p.subscription_id ?? p.id} 期间`,
|
||||
from: new Date((p.period_start ?? 0) * 1000).toISOString(),
|
||||
to: new Date(
|
||||
(p.period_end ?? Math.floor(Date.now() / 1000)) * 1000,
|
||||
).toISOString(),
|
||||
}))
|
||||
.slice(0, 4);
|
||||
});
|
||||
|
||||
const byMember = computed(() => overview.value?.by_member ?? []);
|
||||
const byKey = computed(() => overview.value?.by_key ?? []);
|
||||
const bySource = computed(() => overview.value?.by_source ?? []);
|
||||
|
||||
const totalUsage = computed(() => {
|
||||
const rows = byMember.value;
|
||||
const count = rows.reduce((s, r) => s + r.usage.count, 0);
|
||||
const quota = rows.reduce((s, r) => s + r.usage.quota, 0);
|
||||
// 比例展示用 quota;美元仅示意合计(后端已逐行换算,这里加总展示仅供参考)
|
||||
return { count, quota };
|
||||
});
|
||||
|
||||
function memberPercent(quota: number): number {
|
||||
const total = totalUsage.value.quota;
|
||||
return total > 0 ? Math.round((quota / total) * 1000) / 10 : 0;
|
||||
}
|
||||
|
||||
function onMemberClick(memberId: number | null) {
|
||||
void router.push({
|
||||
path: "/admin/keys",
|
||||
query: memberId == null ? { member_id: "unassigned" } : { member_id: String(memberId) },
|
||||
});
|
||||
}
|
||||
|
||||
function onKeyClick(keyId: number) {
|
||||
void router.push({ path: "/admin/keys", query: { key_id: String(keyId) } });
|
||||
}
|
||||
|
||||
function goFailedKeys() {
|
||||
void router.push({ path: "/admin/keys", query: { recent_failed: "1" } });
|
||||
}
|
||||
|
||||
/** 表头:标题 + 问号说明 */
|
||||
function tipTitle(label: string, tip: string) {
|
||||
return h("span", { class: "overview-col-title" }, [
|
||||
label,
|
||||
h(
|
||||
Tooltip,
|
||||
{ title: tip },
|
||||
() =>
|
||||
h(QuestionCircleOutlined, {
|
||||
class: "overview-col-title__icon",
|
||||
"aria-hidden": "true",
|
||||
}),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
const memberColumns = [
|
||||
{ title: "成员", key: "member_name" },
|
||||
{ title: "名下 Key 数", key: "key_count" },
|
||||
{
|
||||
title: tipTitle(
|
||||
"调用次数",
|
||||
"本时段内该成员名下的消费日志条数(type=2,一次调用一行)",
|
||||
),
|
||||
key: "count",
|
||||
},
|
||||
{
|
||||
title: tipTitle(
|
||||
"额度消耗",
|
||||
"本时段消耗的 quota 原值,括号内为按当时换算率折算的美元",
|
||||
),
|
||||
key: "usage",
|
||||
},
|
||||
{
|
||||
title: tipTitle(
|
||||
"用量占比",
|
||||
"该成员本时段 quota ÷ 全员同期 quota。只是用量事实占比,不是费用分摊",
|
||||
),
|
||||
key: "share",
|
||||
},
|
||||
{
|
||||
title: tipTitle(
|
||||
"同步截止",
|
||||
"该成员名下各 key 的 log_synced_at 里最慢的一刻(北京时间)",
|
||||
),
|
||||
key: "data_cutoff",
|
||||
},
|
||||
];
|
||||
|
||||
const keyColumns = [
|
||||
{ title: "Key 名称", dataIndex: "token_name", key: "token_name" },
|
||||
{ title: "归属成员", key: "member_name" },
|
||||
{
|
||||
title: tipTitle("当前状态", "启用中才会进入同步轮询;已停用仍可查历史"),
|
||||
key: "status",
|
||||
},
|
||||
{
|
||||
title: tipTitle("同步截止", "该枚 key 最近一次成功写入日志的同步时刻"),
|
||||
key: "data_cutoff",
|
||||
},
|
||||
{
|
||||
title: tipTitle("调用次数", "本时段消费日志条数(type=2)"),
|
||||
key: "count",
|
||||
},
|
||||
{
|
||||
title: tipTitle("额度消耗", "本时段 quota 原值与按当时换算率折算的美元"),
|
||||
key: "usage",
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<PageHeader
|
||||
title="全员总览"
|
||||
subtitle="健康带 · 按成员 / key 汇总(信息结构见交互设计-全员总览)"
|
||||
subtitle="健康与用量全景 · 仅统计消费行(type=2)"
|
||||
class="overview-header"
|
||||
>
|
||||
<template #actions>
|
||||
<span class="overview-range-label">时间范围</span>
|
||||
<TimeRangePicker v-model="range" :extra-presets="extraPresets" />
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<HealthBand
|
||||
:engine="engine"
|
||||
:overview="overview"
|
||||
:loading="loading"
|
||||
@go-failed-keys="goFailedKeys"
|
||||
/>
|
||||
<a-skeleton active :paragraph="{ rows: 4 }" />
|
||||
|
||||
<a-alert
|
||||
v-if="overviewQuery.isError.value"
|
||||
type="error"
|
||||
show-icon
|
||||
message="总览数据加载失败"
|
||||
:description="overviewQuery.error.value?.message"
|
||||
style="margin-bottom: 16px"
|
||||
>
|
||||
<template #action>
|
||||
<a-button size="small" @click="() => overviewQuery.refetch()">重试</a-button>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<!-- 来源分解 -->
|
||||
<section class="overview-block">
|
||||
<h2 class="overview-block__title">按来源分解</h2>
|
||||
<SourceBar :rows="bySource" />
|
||||
</section>
|
||||
|
||||
<!-- 按成员 -->
|
||||
<section class="overview-block">
|
||||
<h2 class="overview-block__title">按成员汇总</h2>
|
||||
<a-empty
|
||||
v-if="!loading && byMember.length === 0"
|
||||
description="本时段内没有任何消费调用,因此没有成员用量可汇总"
|
||||
/>
|
||||
<a-table
|
||||
v-else
|
||||
:columns="memberColumns"
|
||||
:data-source="byMember"
|
||||
:row-key="(r: { member_id: number | null }) => r.member_id ?? 'unassigned'"
|
||||
size="middle"
|
||||
:pagination="false"
|
||||
:scroll="{ x: 900 }"
|
||||
:loading="overviewQuery.isFetching.value"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'member_name'">
|
||||
<a @click="onMemberClick(record.member_id)">
|
||||
{{ record.member_name ?? "未归属" }}
|
||||
</a>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'key_count'">
|
||||
{{ record.key_count }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'count'">
|
||||
{{ formatInt(record.usage.count) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'usage'">
|
||||
<QuotaText :quota="record.usage.quota" :quota-usd="record.usage.quota_usd" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'share'">
|
||||
<span class="overview-share-cell__num">
|
||||
{{ memberPercent(record.usage.quota) }}%
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'data_cutoff'">
|
||||
{{ formatMinute(record.data_cutoff) }}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</section>
|
||||
|
||||
<!-- 按 key -->
|
||||
<section class="overview-block">
|
||||
<h2 class="overview-block__title">按 key 明细</h2>
|
||||
<a-table
|
||||
:columns="keyColumns"
|
||||
:data-source="byKey"
|
||||
:row-key="(r: { key_id: number }) => r.key_id"
|
||||
size="middle"
|
||||
:pagination="false"
|
||||
:scroll="{ x: 900 }"
|
||||
:loading="overviewQuery.isFetching.value"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'member_name'">
|
||||
{{
|
||||
record.member_name ??
|
||||
(record.member_id == null
|
||||
? "未归属"
|
||||
: `成员 #${record.member_id}`)
|
||||
}}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-badge
|
||||
:status="record.status === 'active' ? 'success' : 'default'"
|
||||
:text="record.status === 'active' ? '启用中' : '已停用'"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'data_cutoff'">
|
||||
{{ formatMinute(record.data_cutoff) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'count'">
|
||||
{{ formatInt(record.usage.count) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'usage'">
|
||||
<QuotaText :quota="record.usage.quota" :quota-usd="record.usage.quota_usd" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'token_name'">
|
||||
<a @click="onKeyClick(record.key_id)">{{ record.token_name }}</a>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overview-range-label {
|
||||
font-size: 13px;
|
||||
line-height: 32px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.overview-block {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.overview-block__title {
|
||||
margin: 0 0 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.overview-col-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overview-col-title__icon {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.overview-share-cell__num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user