feat(web): 全员总览——时间范围、健康带、来源分解、成员表与 key 明细

This commit is contained in:
2026-10-07 20:56:38 +08:00
parent d6d7d9411f
commit 293141ea0a
8 changed files with 722 additions and 2 deletions
@@ -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>