diff --git a/apps/web/src/api/admin/overview.ts b/apps/web/src/api/admin/overview.ts index 3059516..435c319 100644 --- a/apps/web/src/api/admin/overview.ts +++ b/apps/web/src/api/admin/overview.ts @@ -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; diff --git a/apps/web/src/components/admin/health-band.vue b/apps/web/src/components/admin/health-band.vue new file mode 100644 index 0000000..7c35915 --- /dev/null +++ b/apps/web/src/components/admin/health-band.vue @@ -0,0 +1,172 @@ + + + + + + + + 同步引擎 + + + 最近一次同步轮次共处理 + {{ lastRound.keys_total }} + 枚 key,其中成功 + {{ lastRound.succeeded }} + 枚。 + + 有 + + {{ failedCount }} 枚拉取失败 + + ,可到 Key 管理筛选「最近轮次失败」查看是哪几枚。 + + 没有拉取失败。 + + + 引擎尚未完成过同步轮次,或引擎进程未启动——此时用量数据可能还不完整。 + + + + + + 数据截止 + + + 全池同步截止于 {{ formatMinute(cutoff) }}(北京时间)。 + + + 截止时间落后当前超过 2 小时,同步可能已停滞——请检查引擎日志。 + + + 目前在正常滞后范围内。 + + + + + + 未关联订阅 + + + + + 本时段发现 + {{ unassigned?.count }} 条 / ${{ unassigned?.quota_usd }} + 订阅来源用量未关联到任何订阅编号 + + (说明支付记录缺登记,或上游计费行为发生了变化),需要人工核对。 + + + 本时段没有出现「订阅来源却关联不到订阅编号」的用量,属正常状态。 + + + + + + + + + diff --git a/apps/web/src/components/common/page-header.vue b/apps/web/src/components/common/page-header.vue index 745f50b..6e3ef79 100644 --- a/apps/web/src/components/common/page-header.vue +++ b/apps/web/src/components/common/page-header.vue @@ -47,7 +47,10 @@ defineProps<{ .page-header__actions { display: flex; + align-items: center; gap: 8px; flex-shrink: 0; + /* 与标题块上缘对齐时,控件 32px 高与 label 竖向居中 */ + min-height: 32px; } diff --git a/apps/web/src/components/common/quota-text.vue b/apps/web/src/components/common/quota-text.vue new file mode 100644 index 0000000..d72b4a6 --- /dev/null +++ b/apps/web/src/components/common/quota-text.vue @@ -0,0 +1,40 @@ + + + + + {{ quotaText }} + (${{ quotaUsd ?? "—" }}) + + + + diff --git a/apps/web/src/components/common/source-bar.vue b/apps/web/src/components/common/source-bar.vue new file mode 100644 index 0000000..15734fc --- /dev/null +++ b/apps/web/src/components/common/source-bar.vue @@ -0,0 +1,120 @@ + + + + + + 本时段内没有任何消费调用,因此没有可分解的来源。 + + + + + + + + + {{ seg.label }} + + 调用 {{ seg.count }} 次,消耗 ${{ seg.quota_usd }} + (占 {{ seg.pct.toFixed(1) }}%) + + + + + + + + diff --git a/apps/web/src/components/common/time-range-picker.vue b/apps/web/src/components/common/time-range-picker.vue new file mode 100644 index 0000000..8207d76 --- /dev/null +++ b/apps/web/src/components/common/time-range-picker.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/apps/web/src/utils/time.ts b/apps/web/src/utils/time.ts index 4b696fa..4c16515 100644 --- a/apps/web/src/utils/time.ts +++ b/apps/web/src/utils/time.ts @@ -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; +} diff --git a/apps/web/src/views/admin/overview-view.vue b/apps/web/src/views/admin/overview-view.vue index 8c3e0a7..5b2b261 100644 --- a/apps/web/src/views/admin/overview-view.vue +++ b/apps/web/src/views/admin/overview-view.vue @@ -1,13 +1,314 @@ + + 时间范围 + + + + + - + + + + overviewQuery.refetch()">重试 + + + + + + 按来源分解 + + + + + + 按成员汇总 + + + + + + {{ record.member_name ?? "未归属" }} + + + + {{ record.key_count }} + + + {{ formatInt(record.usage.count) }} + + + + + + + {{ memberPercent(record.usage.quota) }}% + + + + {{ formatMinute(record.data_cutoff) }} + + + + + + + + 按 key 明细 + + + + {{ + record.member_name ?? + (record.member_id == null + ? "未归属" + : `成员 #${record.member_id}`) + }} + + + + + + {{ formatMinute(record.data_cutoff) }} + + + {{ formatInt(record.usage.count) }} + + + + + + {{ record.token_name }} + + + + + +