fix(web): 成员视图模型列与支付结构,范围 watch 勿误重置

This commit is contained in:
2026-10-09 02:28:21 +08:00
parent 431b27ea58
commit 29f5f4f84c
2 changed files with 303 additions and 124 deletions
+18 -6
View File
@@ -73,7 +73,7 @@ const columns = [
{ title: "时间", key: "created_at", width: 150 }, { title: "时间", key: "created_at", width: 150 },
{ title: "Key", key: "token_name", width: 120 }, { title: "Key", key: "token_name", width: 120 },
{ title: "归属", key: "member_name", width: 110 }, { title: "归属", key: "member_name", width: 110 },
{ title: "模型", key: "model_name", width: 110 }, { title: "模型", key: "model_name", dataIndex: "model_name", width: 110 },
{ title: "类型", key: "type", width: 80 }, { title: "类型", key: "type", width: 80 },
{ title: "来源", key: "source", width: 110 }, { title: "来源", key: "source", width: 110 },
{ title: "用量", key: "usage", width: 140 }, { title: "用量", key: "usage", width: 140 },
@@ -127,10 +127,13 @@ watch(
() => route.query, () => route.query,
() => syncFromRoute(), () => syncFromRoute(),
); );
watch([memberId, apiKeyId, billingSource, subscriptionId, typeFilter, range], () => { watch(
page.value = 1; [memberId, apiKeyId, billingSource, subscriptionId, typeFilter],
pushQuery(); () => {
}); page.value = 1;
pushQuery();
},
);
watch(page, () => pushQuery()); watch(page, () => pushQuery());
function sourceBadge(record: LogItem) { function sourceBadge(record: LogItem) {
@@ -185,7 +188,16 @@ onUnmounted(() => {
<PageHeader title="调用日志" subtitle="全池模型请求查询与筛选"> <PageHeader title="调用日志" subtitle="全池模型请求查询与筛选">
<template #actions> <template #actions>
<span class="call-logs__label">时间范围</span> <span class="call-logs__label">时间范围</span>
<TimeRangePicker :model-value="range" @change="(v, k) => setRange(v, k)" /> <TimeRangePicker
:model-value="range"
@change="
(v, k) => {
setRange(v, k);
page = 1;
pushQuery();
}
"
/>
</template> </template>
</PageHeader> </PageHeader>
+285 -118
View File
@@ -2,8 +2,11 @@
/** /**
* 成员视图单页(交互设计-成员视图):聚合 · 支付参数 · 我的 key · 日志徽章展开 * 成员视图单页(交互设计-成员视图):聚合 · 支付参数 · 我的 key · 日志徽章展开
*/ */
import { computed, ref, watch } from "vue"; import { computed, h, onMounted, onUnmounted, ref, watch } from "vue";
import type { LogItem, PaymentItem } from "@new-api-key-usage/core"; import type { LogItem, PaymentItem } from "@new-api-key-usage/core";
import { Tooltip } from "antdv-next";
import { QuestionCircleOutlined } from "@antdv-next/icons";
import { useRoute, useRouter } from "vue-router";
import PageHeader from "@/components/common/page-header.vue"; import PageHeader from "@/components/common/page-header.vue";
import TimeRangePicker from "@/components/common/time-range-picker.vue"; import TimeRangePicker from "@/components/common/time-range-picker.vue";
import QuotaText from "@/components/common/quota-text.vue"; import QuotaText from "@/components/common/quota-text.vue";
@@ -13,6 +16,8 @@ import { useMemberLogs, useMemberOverview, useMemberMe } from "@/queries/member.
import { formatDateTime, formatMinute, hoursSince } from "@/utils/time.ts"; import { formatDateTime, formatMinute, hoursSince } from "@/utils/time.ts";
import { formatInt } from "@/utils/format.ts"; import { formatInt } from "@/utils/format.ts";
const route = useRoute();
const router = useRouter();
const { range, setRange } = useLiveTimeRange("recent-30"); const { range, setRange } = useLiveTimeRange("recent-30");
const meQuery = useMemberMe(); const meQuery = useMemberMe();
const overviewQuery = useMemberOverview(() => range.value); const overviewQuery = useMemberOverview(() => range.value);
@@ -55,22 +60,71 @@ const paymentBySub = computed(() => {
const logs = computed(() => logsQuery.data.value?.items ?? []); const logs = computed(() => logsQuery.data.value?.items ?? []);
const logTotal = computed(() => logsQuery.data.value?.total ?? 0); const logTotal = computed(() => logsQuery.data.value?.total ?? 0);
const expandedRowKeys = ref<number[]>([]); const expandedRowKeys = ref<number[]>([]);
function tipTitle(label: string, tip: string) {
return h("span", { class: "mv-col-title" }, [
label,
h(
Tooltip,
{ title: tip },
() =>
h(QuestionCircleOutlined, {
class: "mv-col-title__icon",
"aria-hidden": "true",
}),
),
]);
}
const logColumns = [ const logColumns = [
{ title: "时间", key: "created_at", width: 150 }, { title: "时间", key: "created_at", width: 150 },
{ title: "Key", key: "token_name", width: 120 }, { title: "Key", key: "token_name", dataIndex: "token_name", width: 120 },
{ title: "模型", key: "model_name", width: 120 }, {
title: "模型",
key: "model_name",
dataIndex: "model_name",
width: 120,
},
{ title: "来源", key: "source", width: 120 }, { title: "来源", key: "source", width: 120 },
{ title: "用量", key: "usage", width: 150 }, { title: "用量", key: "usage", width: 150 },
{ title: "Tokens", key: "tokens", width: 110 }, {
title: tipTitle("Tokens", "提示 tokens / 补全 tokens"),
key: "tokens",
width: 110,
},
]; ];
watch(range, () => { function syncFromRoute() {
const from = route.query.from;
const to = route.query.to;
if (typeof from === "string" && from && typeof to === "string" && to) {
setRange({ from, to }, "fixed");
}
}
function pushRangeQuery() {
void router.replace({
path: "/member",
query: { from: range.value.from, to: range.value.to },
});
}
syncFromRoute();
watch(
() => route.query,
() => syncFromRoute(),
);
function onRangeChange(
v: { from: string; to: string },
kind: "recent-7" | "recent-30" | "fixed",
) {
setRange(v, kind);
page.value = 1; page.value = 1;
expandedRowKeys.value = []; expandedRowKeys.value = [];
}); pushRangeQuery();
}
function isStalled(at: number | null | undefined): boolean { function isStalled(at: number | null | undefined): boolean {
const h = hoursSince(at); const h = hoursSince(at);
@@ -79,7 +133,11 @@ function isStalled(at: number | null | undefined): boolean {
function sourceBadge(record: LogItem) { function sourceBadge(record: LogItem) {
if (record.billing_source === "subscription" && record.subscription_id != null) { if (record.billing_source === "subscription" && record.subscription_id != null) {
return { text: `订阅 #${record.subscription_id}`, kind: "sub" as const, id: record.subscription_id }; return {
text: `订阅 #${record.subscription_id}`,
kind: "sub" as const,
id: record.subscription_id,
};
} }
if (record.billing_source === "wallet") { if (record.billing_source === "wallet") {
return { text: "充值", kind: "wallet" as const, id: null }; return { text: "充值", kind: "wallet" as const, id: null };
@@ -95,30 +153,49 @@ function onBadge(record: LogItem) {
else expandedRowKeys.value.push(record.id); else expandedRowKeys.value.push(record.id);
} }
function paymentText(p: PaymentItem | undefined): string {
if (!p) return "该订阅无登记信息";
const period =
p.kind === "subscription"
? `${formatDateTime(p.period_start)} ~ ${p.period_end == null ? "进行中" : formatDateTime(p.period_end)}`
: formatDateTime(p.paid_at);
return `${period} · 订阅 #${p.subscription_id ?? p.id} · 额度 ${formatInt(p.quota_granted)} ($${p.quota_usd}) · 金额 ${p.paid_amount}${p.remark ? ` · ${p.remark}` : ""}`;
}
function onRangeChange(v: { from: string; to: string }, kind: "recent-7" | "recent-30" | "fixed") {
setRange(v, kind);
}
function onExpand(expanded: boolean, record: LogItem) { function onExpand(expanded: boolean, record: LogItem) {
const i = expandedRowKeys.value.indexOf(record.id); const i = expandedRowKeys.value.indexOf(record.id);
if (expanded && i < 0) expandedRowKeys.value.push(record.id); if (expanded && i < 0) expandedRowKeys.value.push(record.id);
if (!expanded && i >= 0) expandedRowKeys.value.splice(i, 1); if (!expanded && i >= 0) expandedRowKeys.value.splice(i, 1);
} }
/** 表体滚动,避免整页被明细撑高 */
const boxRef = ref<HTMLElement | null>(null);
const bodyY = ref(420);
const CHROME = 120;
function measure() {
const h = boxRef.value?.clientHeight ?? 0;
if (h > 0) bodyY.value = Math.max(220, h - CHROME);
}
let ro: ResizeObserver | null = null;
onMounted(() => {
measure();
if (typeof ResizeObserver !== "undefined" && boxRef.value) {
ro = new ResizeObserver(() => measure());
ro.observe(boxRef.value);
}
window.addEventListener("resize", measure);
});
onUnmounted(() => {
ro?.disconnect();
window.removeEventListener("resize", measure);
});
function periodText(p: PaymentItem): string {
if (p.kind !== "subscription") return formatDateTime(p.paid_at);
const end = p.period_end == null ? "进行中" : formatDateTime(p.period_end);
return `${formatDateTime(p.period_start)} ~ ${end}`;
}
function expandPayment(record: LogItem): PaymentItem | undefined {
return paymentBySub.value.get(sourceBadge(record).id ?? -1);
}
</script> </script>
<template> <template>
<section> <section class="mv">
<PageHeader <PageHeader
:title="me?.name ? `${me.name}` : '我的用量'" :title="me?.name ? me.name : '我的用量'"
subtitle="你在本池的名下用量" subtitle="你在本池的名下用量"
> >
<template #actions> <template #actions>
@@ -136,13 +213,12 @@ function onExpand(expanded: boolean, record: LogItem) {
active active
:paragraph="{ rows: 4 }" :paragraph="{ rows: 4 }"
/> />
<a-empty <a-empty
v-else-if="keys.length === 0" v-else-if="keys.length === 0"
description="作者尚未为你分配 key" description="作者尚未为你分配 key"
/> />
<template v-else> <div v-else class="mv-body">
<section class="mv-block"> <section class="mv-block">
<h2 class="mv-block__title">总用量</h2> <h2 class="mv-block__title">总用量</h2>
<template v-if="usage && usage.count > 0"> <template v-if="usage && usage.count > 0">
@@ -160,27 +236,33 @@ function onExpand(expanded: boolean, record: LogItem) {
<h2 class="mv-block__title">支付参数</h2> <h2 class="mv-block__title">支付参数</h2>
<div v-if="payments.length === 0" class="mv-empty">暂无支付记录信息</div> <div v-if="payments.length === 0" class="mv-empty">暂无支付记录信息</div>
<template v-else> <template v-else>
<div <div v-for="p in payments" :key="p.id" class="mv-pay">
v-for="p in payments" <div class="mv-pay__head">
:key="p.id" <a-tag :color="p.kind === 'subscription' ? 'blue' : 'gold'">
class="mv-pay" {{ p.kind === "subscription" ? "订阅" : "充值" }}
> </a-tag>
<a-tag :color="p.kind === 'subscription' ? 'blue' : 'gold'"> <strong v-if="p.kind === 'subscription'">
{{ p.kind === "subscription" ? "订阅" : "充值" }} 订阅 #{{ p.subscription_id }}
</a-tag> </strong>
<template v-if="p.kind === 'subscription'"> </div>
<span> <div class="mv-pay__grid">
{{ formatDateTime(p.period_start) }} ~ <div class="mv-pay__row">
{{ p.period_end == null ? "进行中" : formatDateTime(p.period_end) }} <span class="mv-pay__k">{{ p.kind === "subscription" ? "期间" : "支付时间" }}</span>
· 订阅 #{{ p.subscription_id }} <span>{{ periodText(p) }}</span>
</span> </div>
</template> <div class="mv-pay__row">
<template v-else> <span class="mv-pay__k">额度</span>
<span>支付于 {{ formatDateTime(p.paid_at) }}</span> <QuotaText :quota="p.quota_granted" :quota-usd="p.quota_usd" />
</template> </div>
<QuotaText :quota="p.quota_granted" :quota-usd="p.quota_usd" /> <div class="mv-pay__row">
<span>金额 {{ p.paid_amount }}</span> <span class="mv-pay__k">金额</span>
<span v-if="p.remark" class="mv-note">{{ p.remark }}</span> <span class="mv-num">{{ p.paid_amount }}</span>
</div>
<div v-if="p.remark" class="mv-pay__row">
<span class="mv-pay__k">备注</span>
<span>{{ p.remark }}</span>
</div>
</div>
</div> </div>
<div class="mv-note mv-foot"> <div class="mv-note mv-foot">
支付参数对成员完全公开;币种由成员与作者约定 支付参数对成员完全公开;币种由成员与作者约定
@@ -194,95 +276,139 @@ function onExpand(expanded: boolean, record: LogItem) {
<div v-for="k in keys" :key="k.key_id" class="mv-key"> <div v-for="k in keys" :key="k.key_id" class="mv-key">
<strong>{{ k.token_name }}</strong> <strong>{{ k.token_name }}</strong>
<span v-if="isStalled(k.log_synced_at)" class="mv-stall"> <span v-if="isStalled(k.log_synced_at)" class="mv-stall">
截止 {{ formatMinute(k.log_synced_at) }} 停滞 同步截止 {{ formatMinute(k.log_synced_at) }} · 停滞
</span>
<span v-else class="mv-note">
同步截止 {{ formatMinute(k.log_synced_at) }}
</span> </span>
<span v-else class="mv-note">截止 {{ formatMinute(k.log_synced_at) }}</span>
<span>调用 {{ formatInt(k.usage.count) }} 次</span> <span>调用 {{ formatInt(k.usage.count) }} 次</span>
<QuotaText :quota="k.usage.quota" :quota-usd="k.usage.quota_usd" /> <QuotaText :quota="k.usage.quota" :quota-usd="k.usage.quota_usd" />
</div> </div>
</div> </div>
</section> </section>
<section class="mv-block"> <section class="mv-block mv-block--logs">
<h2 class="mv-block__title">日志明细</h2> <h2 class="mv-block__title">日志明细</h2>
<a-table <div ref="boxRef" class="mv-logs">
:columns="logColumns" <a-table
:data-source="logs" :columns="logColumns"
:row-key="(r: LogItem) => r.id" :data-source="logs"
:loading="logsQuery.isPending.value || logsQuery.isFetching.value" :row-key="(r: LogItem) => r.id"
:pagination="{ :loading="logsQuery.isPending.value || logsQuery.isFetching.value"
current: page, :pagination="{
pageSize, current: page,
total: logTotal, pageSize,
showSizeChanger: true, total: logTotal,
pageSizeOptions: ['20', '50', '100'], showSizeChanger: true,
onChange: (p: number, ps: number) => { pageSizeOptions: ['20', '50', '100'],
page = p; onChange: (p: number, ps: number) => {
pageSize = ps; page = p;
}, pageSize = ps;
}" },
size="middle" }"
:scroll="{ x: 800 }" size="middle"
:expanded-row-keys="expandedRowKeys" :scroll="{ x: 800, y: bodyY }"
:expand-column-width="24" :expanded-row-keys="expandedRowKeys"
:expandable="{ :expandable="{
showExpandColumn: false, showExpandColumn: false,
rowExpandable: (r: LogItem) => sourceBadge(r).kind === 'sub', rowExpandable: (r: LogItem) => sourceBadge(r).kind === 'sub',
}" }"
@expand="onExpand" @expand="onExpand"
> >
<template #bodyCell="{ column, record }"> <template #bodyCell="{ column, record }">
<template v-if="column.key === 'created_at'"> <template v-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }} {{ formatDateTime(record.created_at) }}
</template> </template>
<template v-else-if="column.key === 'usage'"> <template v-else-if="column.key === 'usage'">
<QuotaText :quota="record.quota" :quota-usd="record.quota_usd" /> <QuotaText :quota="record.quota" :quota-usd="record.quota_usd" />
</template> </template>
<template v-else-if="column.key === 'tokens'"> <template v-else-if="column.key === 'tokens'">
{{ formatInt(record.prompt_tokens) }} / {{ formatInt(record.completion_tokens) }} {{ formatInt(record.prompt_tokens) }} / {{ formatInt(record.completion_tokens) }}
</template> </template>
<template v-else-if="column.key === 'source'"> <template v-else-if="column.key === 'source'">
<a-tooltip <a-tooltip
v-if="sourceBadge(record).kind === 'sub'" v-if="sourceBadge(record).kind === 'sub'"
title="点击查看该订阅支付参数" title="点击查看该订阅支付参数"
>
<a-tag
color="blue"
class="mv-badge"
@click="onBadge(record)"
> >
{{ sourceBadge(record).text }} <a-tag color="blue" class="mv-badge" @click="onBadge(record)">
{{ sourceBadge(record).text }}
</a-tag>
</a-tooltip>
<a-tag v-else-if="sourceBadge(record).kind === 'wallet'" color="gold">
充值
</a-tag> </a-tag>
</a-tooltip> <a-tag v-else>未关联</a-tag>
<a-tag </template>
v-else-if="sourceBadge(record).kind === 'wallet'"
color="gold"
>
充值
</a-tag>
<a-tag v-else>未关联</a-tag>
</template> </template>
</template> <template #expandedRowRender="{ record }">
<template #expandedRowRender="{ record }"> <div class="mv-expand">
<div class="mv-expand"> <template v-if="expandPayment(record)">
{{ paymentText(paymentBySub.get(sourceBadge(record).id ?? -1)) }} <div class="mv-expand__title">订阅支付参数</div>
</div> <div class="mv-pay__grid">
</template> <div class="mv-pay__row">
</a-table> <span class="mv-pay__k">期间</span>
<span>{{ periodText(expandPayment(record)!) }}</span>
</div>
<div class="mv-pay__row">
<span class="mv-pay__k">额度</span>
<QuotaText
:quota="expandPayment(record)!.quota_granted"
:quota-usd="expandPayment(record)!.quota_usd"
/>
</div>
<div class="mv-pay__row">
<span class="mv-pay__k">金额</span>
<span class="mv-num">
{{ expandPayment(record)!.paid_amount }}
</span>
</div>
<div v-if="expandPayment(record)!.remark" class="mv-pay__row">
<span class="mv-pay__k">备注</span>
<span>{{ expandPayment(record)!.remark }}</span>
</div>
</div>
</template>
<div v-else class="mv-empty">该订阅无登记信息</div>
</div>
</template>
</a-table>
</div>
</section> </section>
</template> </div>
</section> </section>
</template> </template>
<style scoped> <style scoped>
.mv {
min-height: 0;
}
.mv-range-label { .mv-range-label {
font-size: 13px; font-size: 13px;
line-height: 32px; line-height: 32px;
color: rgba(0, 0, 0, 0.65); color: rgba(0, 0, 0, 0.65);
} }
.mv-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.mv-block { .mv-block {
margin-bottom: 24px; margin-bottom: 16px;
}
.mv-block--logs {
flex: 1;
min-height: 280px;
display: flex;
flex-direction: column;
}
.mv-logs {
flex: 1;
min-height: 0;
} }
.mv-block__title { .mv-block__title {
@@ -315,16 +441,39 @@ function onExpand(expanded: boolean, record: LogItem) {
color: rgba(0, 0, 0, 0.45); color: rgba(0, 0, 0, 0.45);
} }
.mv-num {
font-variant-numeric: tabular-nums;
}
.mv-pay { .mv-pay {
display: flex; padding: 12px 0;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
padding: 10px 0;
border-bottom: 1px solid #f0f0f0; border-bottom: 1px solid #f0f0f0;
}
.mv-pay__head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.mv-pay__grid {
display: grid;
gap: 4px;
font-size: 13px; font-size: 13px;
} }
.mv-pay__row {
display: flex;
gap: 12px;
}
.mv-pay__k {
width: 56px;
flex-shrink: 0;
color: rgba(0, 0, 0, 0.45);
}
.mv-keys { .mv-keys {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
@@ -351,7 +500,25 @@ function onExpand(expanded: boolean, record: LogItem) {
} }
.mv-expand { .mv-expand {
padding: 4px 8px; padding: 8px 12px;
font-size: 13px; font-size: 13px;
} }
.mv-expand__title {
font-weight: 600;
margin-bottom: 8px;
}
.mv-col-title {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.mv-col-title__icon {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
cursor: help;
}
</style> </style>