feat(web): 成员端同构三页与日志详情,管理端轻量筛选

成员视图改管理端壳(总览/调用日志/支付参数),行内详情弹窗含原始数据;
成员日志含错误行;共享时间范围。管理端成员/支付筛选;支付仅留类型筛选。
This commit is contained in:
2026-10-09 17:04:59 +08:00
parent 665fa088f5
commit 0269cf3386
20 changed files with 1695 additions and 602 deletions
-524
View File
@@ -1,524 +0,0 @@
<script setup lang="ts">
/**
* 成员视图单页(交互设计-成员视图):聚合 · 支付参数 · 我的 key · 日志徽章展开
*/
import { computed, h, onMounted, onUnmounted, ref, watch } from "vue";
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 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 { useLiveTimeRange } from "@/composables/use-live-time-range.ts";
import { useMemberLogs, useMemberOverview, useMemberMe } from "@/queries/member.ts";
import { formatDateTime, formatMinute, hoursSince } from "@/utils/time.ts";
import { formatInt } from "@/utils/format.ts";
const route = useRoute();
const router = useRouter();
const { range, setRange } = useLiveTimeRange("recent-30");
const meQuery = useMemberMe();
const overviewQuery = useMemberOverview(() => range.value);
const page = ref(1);
const pageSize = ref(50);
const logsQuery = useMemberLogs(() => ({
...range.value,
page: page.value,
page_size: pageSize.value,
}));
const overview = computed(() => overviewQuery.data.value);
const me = computed(() => overview.value?.member ?? meQuery.data.value ?? null);
const usage = computed(() => overview.value?.usage);
const bySource = computed(() => overview.value?.by_source ?? []);
const keys = computed(() => overview.value?.keys ?? []);
const payments = computed(() => overview.value?.payments ?? []);
const extraPresets = computed(() =>
payments.value
.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 paymentBySub = computed(() => {
const m = new Map<number, PaymentItem>();
for (const p of payments.value) {
if (p.subscription_id != null) m.set(p.subscription_id, p);
}
return m;
});
const logs = computed(() => logsQuery.data.value?.items ?? []);
const logTotal = computed(() => logsQuery.data.value?.total ?? 0);
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 = [
{ title: "时间", key: "created_at", width: 150 },
{ title: "Key", key: "token_name", dataIndex: "token_name", width: 120 },
{
title: "模型",
key: "model_name",
dataIndex: "model_name",
width: 120,
},
{ title: "来源", key: "source", width: 120 },
{ title: "用量", key: "usage", width: 150 },
{
title: tipTitle("Tokens", "提示 tokens / 补全 tokens"),
key: "tokens",
width: 110,
},
];
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;
expandedRowKeys.value = [];
pushRangeQuery();
}
function isStalled(at: number | null | undefined): boolean {
const h = hoursSince(at);
return h != null && h > 2;
}
function sourceBadge(record: LogItem) {
if (record.billing_source === "subscription" && record.subscription_id != null) {
return {
text: `订阅 #${record.subscription_id}`,
kind: "sub" as const,
id: record.subscription_id,
};
}
if (record.billing_source === "wallet") {
return { text: "充值", kind: "wallet" as const, id: null };
}
return { text: "未关联", kind: "none" as const, id: null };
}
function onBadge(record: LogItem) {
const b = sourceBadge(record);
if (b.kind !== "sub") return;
const i = expandedRowKeys.value.indexOf(record.id);
if (i >= 0) expandedRowKeys.value.splice(i, 1);
else expandedRowKeys.value.push(record.id);
}
function onExpand(expanded: boolean, record: LogItem) {
const i = expandedRowKeys.value.indexOf(record.id);
if (expanded && i < 0) expandedRowKeys.value.push(record.id);
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>
<template>
<section class="mv">
<PageHeader
:title="me?.name ? me.name : '我的用量'"
subtitle="你在本池的名下用量"
>
<template #actions>
<span class="mv-range-label">时间范围</span>
<TimeRangePicker
:model-value="range"
:extra-presets="extraPresets"
@change="onRangeChange"
/>
</template>
</PageHeader>
<a-skeleton
v-if="overviewQuery.isPending.value"
active
:paragraph="{ rows: 4 }"
/>
<a-empty
v-else-if="keys.length === 0"
description="作者尚未为你分配 key"
/>
<div v-else class="mv-body">
<section class="mv-block">
<h2 class="mv-block__title">总用量</h2>
<template v-if="usage && usage.count > 0">
<div class="mv-agg">
<span>调用 {{ formatInt(usage.count) }} 次</span>
<QuotaText :quota="usage.quota" :quota-usd="usage.quota_usd" />
<span class="mv-note">按当时换算率</span>
</div>
<SourceBar :rows="bySource" />
</template>
<div v-else class="mv-empty">该时段内无调用</div>
</section>
<section class="mv-block">
<h2 class="mv-block__title">支付参数</h2>
<div v-if="payments.length === 0" class="mv-empty">暂无支付记录信息</div>
<template v-else>
<div v-for="p in payments" :key="p.id" class="mv-pay">
<div class="mv-pay__head">
<a-tag :color="p.kind === 'subscription' ? 'blue' : 'gold'">
{{ p.kind === "subscription" ? "订阅" : "充值" }}
</a-tag>
<strong v-if="p.kind === 'subscription'">
订阅 #{{ p.subscription_id }}
</strong>
</div>
<div class="mv-pay__grid">
<div class="mv-pay__row">
<span class="mv-pay__k">{{ p.kind === "subscription" ? "期间" : "支付时间" }}</span>
<span>{{ periodText(p) }}</span>
</div>
<div class="mv-pay__row">
<span class="mv-pay__k">额度</span>
<QuotaText :quota="p.quota_granted" :quota-usd="p.quota_usd" />
</div>
<div class="mv-pay__row">
<span class="mv-pay__k">金额</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 class="mv-note mv-foot">
支付参数对成员完全公开;币种由成员与作者约定
</div>
</template>
</section>
<section class="mv-block">
<h2 class="mv-block__title">我的 key</h2>
<div class="mv-keys">
<div v-for="k in keys" :key="k.key_id" class="mv-key">
<strong>{{ k.token_name }}</strong>
<span v-if="isStalled(k.log_synced_at)" class="mv-stall">
同步截止 {{ formatMinute(k.log_synced_at) }} · 停滞
</span>
<span v-else class="mv-note">
同步截止 {{ formatMinute(k.log_synced_at) }}
</span>
<span>调用 {{ formatInt(k.usage.count) }} 次</span>
<QuotaText :quota="k.usage.quota" :quota-usd="k.usage.quota_usd" />
</div>
</div>
</section>
<section class="mv-block mv-block--logs">
<h2 class="mv-block__title">日志明细</h2>
<div ref="boxRef" class="mv-logs">
<a-table
:columns="logColumns"
:data-source="logs"
:row-key="(r: LogItem) => r.id"
:loading="logsQuery.isPending.value || logsQuery.isFetching.value"
:pagination="{
current: page,
pageSize,
total: logTotal,
showSizeChanger: true,
pageSizeOptions: ['20', '50', '100'],
onChange: (p: number, ps: number) => {
page = p;
pageSize = ps;
},
}"
size="middle"
:scroll="{ x: 800, y: bodyY }"
:expanded-row-keys="expandedRowKeys"
:expandable="{
showExpandColumn: false,
rowExpandable: (r: LogItem) => sourceBadge(r).kind === 'sub',
}"
@expand="onExpand"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'usage'">
<QuotaText :quota="record.quota" :quota-usd="record.quota_usd" />
</template>
<template v-else-if="column.key === 'tokens'">
{{ formatInt(record.prompt_tokens) }} / {{ formatInt(record.completion_tokens) }}
</template>
<template v-else-if="column.key === 'source'">
<a-tooltip
v-if="sourceBadge(record).kind === 'sub'"
title="点击查看该订阅支付参数"
>
<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 v-else>未关联</a-tag>
</template>
</template>
<template #expandedRowRender="{ record }">
<div class="mv-expand">
<template v-if="expandPayment(record)">
<div class="mv-expand__title">订阅支付参数</div>
<div class="mv-pay__grid">
<div class="mv-pay__row">
<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>
</div>
</section>
</template>
<style scoped>
.mv {
min-height: 0;
}
.mv-range-label {
font-size: 13px;
line-height: 32px;
color: rgba(0, 0, 0, 0.65);
}
.mv-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.mv-block {
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 {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
line-height: 22px;
}
.mv-agg {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 16px;
margin-bottom: 12px;
font-variant-numeric: tabular-nums;
}
.mv-note {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.mv-foot {
margin-top: 8px;
}
.mv-empty {
font-size: 13px;
color: rgba(0, 0, 0, 0.45);
}
.mv-num {
font-variant-numeric: tabular-nums;
}
.mv-pay {
padding: 12px 0;
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;
}
.mv-pay__row {
display: flex;
gap: 12px;
}
.mv-pay__k {
width: 56px;
flex-shrink: 0;
color: rgba(0, 0, 0, 0.45);
}
.mv-keys {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
}
.mv-key {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 16px;
border: 1px solid #f0f0f0;
border-radius: 8px;
font-size: 13px;
}
.mv-stall {
color: #d48806;
font-weight: 600;
}
.mv-badge {
cursor: pointer;
}
.mv-expand {
padding: 8px 12px;
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>
+1 -1
View File
@@ -20,7 +20,7 @@ async function onFinish() {
tokenStore.setToken(model.token.trim());
const me = await fetchMe();
tokenStore.setMemberName(me.name);
void router.replace({ name: "member-home" });
void router.replace({ name: "member-overview" });
} catch {
tokenStore.clearToken();
error.value = "令牌无效或已停用";
+547
View File
@@ -0,0 +1,547 @@
<script setup lang="ts">
/**
* 成员调用日志(交互设计-成员视图 §4):
* 行展开 = 模型调用详情;来源徽章 = 订阅支付参数弹窗(R12)
*/
import { computed, h, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
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 TimeRangePicker from "@/components/common/time-range-picker.vue";
import QuotaText from "@/components/common/quota-text.vue";
import { useMemberRange } from "@/composables/use-member-range.ts";
import { useMemberLogs, useMemberOverview } from "@/queries/member.ts";
import { formatDateTime } from "@/utils/time.ts";
import { formatInt, formatPaidAmount } from "@/utils/format.ts";
const route = useRoute();
const router = useRouter();
const { range, setRange } = useMemberRange();
const apiKeyId = ref<number | null>(null);
const billingSource = ref<string | null>(null);
const subscriptionId = ref<number | null>(null);
const typeFilter = ref<number | "error" | null>(null);
const page = ref(1);
const pageSize = ref(50);
const overviewQuery = useMemberOverview(() => range.value);
const payments = computed(() => overviewQuery.data.value?.payments ?? []);
const keys = computed(() => overviewQuery.data.value?.keys ?? []);
const params = computed(() => ({
...range.value,
page: page.value,
page_size: pageSize.value,
api_key_id: apiKeyId.value ?? undefined,
billing_source: billingSource.value ?? undefined,
subscription_id: subscriptionId.value ?? undefined,
type: typeFilter.value ?? undefined,
}));
const logsQuery = useMemberLogs(() => params.value);
const rows = computed(() => logsQuery.data.value?.items ?? []);
const total = computed(() => logsQuery.data.value?.total ?? 0);
const keyOptions = computed(() => [
{ value: null, label: "全部 Key" },
...keys.value.map((k) => ({ value: k.key_id, label: k.token_name })),
]);
const paymentBySub = computed(() => {
const m = new Map<number, PaymentItem>();
for (const p of payments.value) {
if (p.subscription_id != null) m.set(p.subscription_id, p);
}
return m;
});
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" })),
]);
}
const columns = [
{ title: "时间", key: "created_at", width: 150 },
{ title: "Key", key: "token_name", dataIndex: "token_name", width: 120 },
{ title: "模型", key: "model_name", dataIndex: "model_name", width: 120 },
{ title: "类型", key: "type", width: 80 },
{ title: "来源", key: "source", width: 120 },
{
title: tipTitle("用量", "quota 原值与按当时换算率折算的美元"),
key: "usage",
width: 150,
},
{ title: "操作", key: "actions", width: 72 },
];
const detailOpen = ref(false);
const detailRecord = ref<LogItem | null>(null);
/** 正在从 URL 灌入状态——避免筛选 watcher 误重置 page */
const applyingRoute = ref(false);
function openDetail(record: LogItem) {
detailRecord.value = record;
detailOpen.value = true;
}
function formatRaw(record: LogItem | null): string {
if (!record) return "";
return JSON.stringify(record.raw ?? record, null, 2);
}
function sourceBadge(record: LogItem) {
if (record.billing_source === "subscription" && record.subscription_id != null) {
return {
text: `订阅 #${record.subscription_id}`,
kind: "sub" as const,
id: record.subscription_id,
};
}
if (record.billing_source === "wallet") {
return { text: "充值", kind: "wallet" as const, id: null };
}
return { text: "未关联", kind: "none" as const, id: null };
}
function onBadge(record: LogItem, e: MouseEvent) {
e.stopPropagation();
if (sourceBadge(record).kind !== "sub") return;
payModalSubId.value = sourceBadge(record).id;
payModalOpen.value = true;
}
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}`;
}
const payModalOpen = ref(false);
const payModalSubId = ref<number | null>(null);
const payModalPayment = computed(() =>
payModalSubId.value == null
? undefined
: paymentBySub.value.get(payModalSubId.value),
);
function syncFromRoute() {
applyingRoute.value = true;
const q = route.query;
const k = q.api_key_id ?? q.key_id;
apiKeyId.value = typeof k === "string" && k ? Number(k) || null : null;
billingSource.value =
typeof q.billing_source === "string" && q.billing_source
? q.billing_source
: null;
const s = q.subscription_id;
subscriptionId.value = typeof s === "string" && s ? Number(s) || null : null;
const t = q.type;
if (t === "2") typeFilter.value = 2;
else if (t === "error") typeFilter.value = "error";
else typeFilter.value = null;
const p = q.page;
page.value = typeof p === "string" && p ? Number(p) || 1 : 1;
void nextTick(() => {
applyingRoute.value = false;
});
}
function pushQuery() {
const query: Record<string, string> = {
from: range.value.from,
to: range.value.to,
};
if (apiKeyId.value != null) query.api_key_id = String(apiKeyId.value);
if (billingSource.value) query.billing_source = billingSource.value;
if (subscriptionId.value != null) {
query.subscription_id = String(subscriptionId.value);
}
if (typeFilter.value) query.type = String(typeFilter.value);
if (page.value > 1) query.page = String(page.value);
void router.replace({ path: "/member/logs", query });
}
syncFromRoute();
watch(() => route.query, () => syncFromRoute());
watch([apiKeyId, billingSource, subscriptionId, typeFilter], () => {
if (applyingRoute.value) return;
page.value = 1;
pushQuery();
});
watch(page, () => pushQuery());
function onRangeChange(v: { from: string; to: string }, k: "recent-7" | "recent-30" | "fixed") {
setRange(v, k);
page.value = 1;
pushQuery();
}
function onPageChange(p: number, ps: number) {
page.value = p;
pageSize.value = ps;
}
const boxRef = ref<HTMLElement | null>(null);
const bodyY = ref(480);
const CHROME = 96;
let ro: ResizeObserver | null = null;
function measure() {
const h = boxRef.value?.clientHeight ?? 0;
if (h > 0) bodyY.value = Math.max(200, h - CHROME);
}
function bindObserve() {
ro?.disconnect();
ro = null;
if (typeof ResizeObserver !== "undefined" && boxRef.value) {
ro = new ResizeObserver(() => measure());
ro.observe(boxRef.value);
}
measure();
}
onMounted(() => {
bindObserve();
window.addEventListener("resize", measure);
});
onUnmounted(() => {
ro?.disconnect();
window.removeEventListener("resize", measure);
});
// 空态/有数据切换会重建 box 节点,需重新挂 RO
watch(
() => rows.value.length === 0 && !logsQuery.isPending.value,
async () => {
await nextTick();
bindObserve();
},
);
</script>
<template>
<section class="member-logs">
<PageHeader title="调用日志" subtitle="名下 key 的模型请求流水">
<template #actions>
<span class="member-logs__label">时间范围</span>
<TimeRangePicker :model-value="range" @change="onRangeChange" />
</template>
</PageHeader>
<div class="member-logs__filters">
<a-select
v-model:value="apiKeyId"
:options="keyOptions"
style="width: 160px"
allow-clear
placeholder="Key"
show-search
option-filter-prop="label"
/>
<a-select
v-model:value="billingSource"
:options="[
{ value: null, label: '全部来源' },
{ value: 'subscription', label: '订阅' },
{ value: 'wallet', label: '充值' },
{ value: 'none', label: '未关联' },
]"
style="width: 120px"
allow-clear
placeholder="来源"
/>
<a-input-number
v-model:value="subscriptionId"
:min="1"
:precision="0"
style="width: 120px"
placeholder="订阅 ID"
/>
<a-select
v-model:value="typeFilter"
:options="[
{ value: null, label: '全部类型' },
{ value: 2, label: '消费' },
{ value: 'error', label: '错误' },
]"
style="width: 120px"
placeholder="类型"
/>
</div>
<a-alert
v-if="logsQuery.isError.value"
type="error"
show-icon
message="调用日志加载失败"
:description="logsQuery.error.value?.message"
style="margin-bottom: 16px"
>
<template #action>
<a-button size="small" @click="() => logsQuery.refetch()">重试</a-button>
</template>
</a-alert>
<a-empty
v-if="!logsQuery.isPending.value && !logsQuery.isError.value && rows.length === 0"
description="该时段内无调用日志"
/>
<div v-else ref="boxRef" class="member-logs__box">
<a-table
:columns="columns"
:data-source="rows"
:row-key="(r: LogItem) => r.id"
:loading="logsQuery.isPending.value || logsQuery.isFetching.value"
:pagination="{
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: ['20', '50', '100', '200'],
showTotal: (t: number) => `共 ${t} 条`,
onChange: onPageChange,
}"
size="middle"
:scroll="{ x: 780, y: bodyY }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'usage'">
<QuotaText :quota="record.quota" :quota-usd="record.quota_usd" />
</template>
<template v-else-if="column.key === 'type'">
<a-tag :color="record.type === 2 ? 'blue' : 'red'">
{{ record.type === 2 ? "消费" : "错误" }}
</a-tag>
</template>
<template v-else-if="column.key === 'source'">
<a-tooltip
v-if="sourceBadge(record).kind === 'sub'"
title="点击查看该订阅支付参数"
>
<a-tag
color="blue"
class="member-logs__badge"
@click="(e: MouseEvent) => onBadge(record, e)"
>
{{ sourceBadge(record).text }}
</a-tag>
</a-tooltip>
<a-tag v-else-if="sourceBadge(record).kind === 'wallet'" color="gold">
充值
</a-tag>
<a-tag v-else>未关联</a-tag>
</template>
<template v-else-if="column.key === 'actions'">
<a @click="openDetail(record)">详情</a>
</template>
</template>
</a-table>
</div>
<a-modal
v-model:open="detailOpen"
title="调用详情"
:footer="null"
width="720"
centered
:styles="{ body: { maxHeight: '70vh', overflowY: 'auto' } }"
>
<div v-if="detailRecord" class="member-logs__detail">
<div class="member-logs__kv">
<span class="member-logs__note">时间</span>
<span>{{ formatDateTime(detailRecord.created_at) }}</span>
</div>
<div class="member-logs__kv">
<span class="member-logs__note">Key / 模型</span>
<span>{{ detailRecord.token_name }} · {{ detailRecord.model_name }}</span>
</div>
<div class="member-logs__kv">
<span class="member-logs__note">类型</span>
<a-tag :color="detailRecord.type === 2 ? 'blue' : 'red'">
{{ detailRecord.type === 2 ? "消费" : "错误" }}
</a-tag>
</div>
<div class="member-logs__kv">
<span class="member-logs__note">用量</span>
<QuotaText :quota="detailRecord.quota" :quota-usd="detailRecord.quota_usd" />
</div>
<div class="member-logs__kv">
<span class="member-logs__note">Tokens</span>
<span class="member-logs__num">
提示 {{ formatInt(detailRecord.prompt_tokens) }} · 补全 {{ formatInt(detailRecord.completion_tokens) }}
</span>
</div>
<div class="member-logs__kv">
<span class="member-logs__note">耗时</span>
<span class="member-logs__num">
{{ detailRecord.use_time }}s · {{ detailRecord.is_stream ? "流式" : "非流式" }}
</span>
</div>
<div class="member-logs__kv">
<span class="member-logs__note">来源</span>
<a-tag :color="sourceBadge(detailRecord).kind === 'sub' ? 'blue' : sourceBadge(detailRecord).kind === 'wallet' ? 'gold' : undefined">
{{ sourceBadge(detailRecord).text }}
</a-tag>
</div>
<div v-if="detailRecord.content" class="member-logs__kv">
<span class="member-logs__note">内容</span>
<span class="member-logs__content">{{ detailRecord.content }}</span>
</div>
<div class="member-logs__raw">
<div class="member-logs__note">原始数据</div>
<pre class="member-logs__json">{{ formatRaw(detailRecord) }}</pre>
</div>
</div>
</a-modal>
<a-modal
v-model:open="payModalOpen"
:title="payModalSubId != null ? `订阅 #${payModalSubId} 支付参数` : '支付参数'"
:footer="null"
width="480"
centered
:styles="{ body: { maxHeight: '56vh', overflowY: 'auto' } }"
>
<div v-if="payModalPayment" class="member-logs__pay">
<div class="member-logs__kv">
<span class="member-logs__note">期间</span>
<span>{{ periodText(payModalPayment) }}</span>
</div>
<div class="member-logs__kv">
<span class="member-logs__note">额度</span>
<QuotaText
:quota="payModalPayment.quota_granted"
:quota-usd="payModalPayment.quota_usd"
/>
</div>
<div class="member-logs__kv">
<span class="member-logs__note">金额</span>
<span class="member-logs__num">{{ formatPaidAmount(payModalPayment.paid_amount) }}</span>
</div>
<div v-if="payModalPayment.remark" class="member-logs__kv">
<span class="member-logs__note">备注</span>
<span>{{ payModalPayment.remark }}</span>
</div>
<div class="member-logs__foot">
支付参数对成员完全公开;币种由成员与作者约定
</div>
</div>
<div v-else class="member-logs__note">该订阅无登记信息</div>
</a-modal>
</section>
</template>
<style scoped>
.member-logs {
display: flex;
flex-direction: column;
height: calc(100vh - 64px - 48px - 48px);
min-height: 0;
}
.member-logs__label {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
}
.member-logs__filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.member-logs__box {
flex: 1;
min-height: 0;
}
.member-logs__badge {
cursor: pointer;
}
.member-logs__detail {
display: flex;
flex-direction: column;
gap: 12px;
}
.member-logs__expand {
display: flex;
flex-direction: column;
gap: 8px;
padding: 4px 0;
}
.member-logs__pay {
display: flex;
flex-direction: column;
gap: 12px;
}
.member-logs__foot {
margin-top: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.member-logs__kv {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.member-logs__note {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
min-width: 72px;
}
.member-logs__num {
font-variant-numeric: tabular-nums;
}
.member-logs__content {
white-space: pre-wrap;
word-break: break-all;
flex: 1;
min-width: 0;
}
.member-logs__raw {
margin-top: 4px;
}
.member-logs__json {
margin: 4px 0 0;
padding: 12px;
max-height: 360px;
overflow: auto;
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 6px;
font-size: 12px;
line-height: 20px;
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
}
.member-logs :deep(.mv-col-title) {
display: inline-flex;
align-items: center;
gap: 4px;
}
.member-logs :deep(.mv-col-title__icon) {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
</style>
+234
View File
@@ -0,0 +1,234 @@
<script setup lang="ts">
/**
* 成员总览(交互设计-成员视图 §3):紧凑摘要 + 我的 Key 表 + 支付入口
*/
import { computed } from "vue";
import { useRouter } from "vue-router";
import PageHeader from "@/components/common/page-header.vue";
import TimeRangePicker from "@/components/common/time-range-picker.vue";
import SourceBar from "@/components/common/source-bar.vue";
import QuotaText from "@/components/common/quota-text.vue";
import { useMemberRange } from "@/composables/use-member-range.ts";
import { useMemberOverview } from "@/queries/member.ts";
import { formatMinute, formatRangeCaption, hoursSince } from "@/utils/time.ts";
import { formatInt } from "@/utils/format.ts";
const router = useRouter();
const { range, onRangeChange } = useMemberRange();
const overviewQuery = useMemberOverview(() => range.value);
const usage = computed(() => overviewQuery.data.value?.usage);
const bySource = computed(() => overviewQuery.data.value?.by_source ?? []);
const keys = computed(() => overviewQuery.data.value?.keys ?? []);
const payments = computed(() => overviewQuery.data.value?.payments ?? []);
const extraPresets = computed(() =>
payments.value
.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 rangeCaption = computed(() =>
formatRangeCaption(range.value.from, range.value.to),
);
function isStalled(at: number | null | undefined): boolean {
const h = hoursSince(at);
return h != null && h > 2;
}
function goLogs(keyId?: number) {
const query: Record<string, string> = {
from: range.value.from,
to: range.value.to,
};
if (keyId != null) query.api_key_id = String(keyId);
void router.push({ path: "/member/logs", query });
}
const keyColumns = [
{ title: "Key 名称", dataIndex: "token_name", key: "token_name" },
{
title: "数据截止",
key: "data_cutoff",
},
{ title: "调用次数", key: "count" },
{ title: "额度消耗", key: "usage" },
{ title: "", key: "actions", width: 80 },
];
</script>
<template>
<section>
<PageHeader title="总览" subtitle="你在本池的名下用量 · 仅统计消费行">
<template #actions>
<span class="mv-range-label">时间范围</span>
<TimeRangePicker
:model-value="range"
:extra-presets="extraPresets"
@change="onRangeChange"
/>
</template>
</PageHeader>
<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>
<a-skeleton
v-if="overviewQuery.isPending.value"
active
:paragraph="{ rows: 4 }"
style="margin-bottom: 24px"
/>
<a-empty
v-else-if="!overviewQuery.isError.value && keys.length === 0"
description="作者尚未为你分配 key"
style="margin-bottom: 24px"
/>
<template v-else-if="keys.length > 0">
<section class="mv-block">
<h2 class="mv-block__title">
用量摘要
<span class="mv-caption">{{ rangeCaption }}</span>
</h2>
<div class="mv-summary">
<div class="mv-summary__main">
<QuotaText
v-if="usage"
:quota="usage.quota"
:quota-usd="usage.quota_usd"
/>
<div class="mv-summary__sub">
调用 {{ formatInt(usage?.count ?? 0) }} 次 · 按当时换算率
<span v-if="usage && usage.count === 0" class="mv-note">· 该时段内无调用</span>
</div>
</div>
<SourceBar :rows="bySource" />
</div>
</section>
<section class="mv-block">
<h2 class="mv-block__title">我的 Key</h2>
<a-table
:columns="keyColumns"
:data-source="keys"
:row-key="(r: { key_id: number }) => r.key_id"
size="middle"
:pagination="false"
:scroll="{ x: 720 }"
:loading="overviewQuery.isFetching.value"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'data_cutoff'">
<span
v-if="record.log_synced_at == null"
class="mv-syncing"
>同步中…</span>
<span
v-else
:class="{ 'is-stall': isStalled(record.log_synced_at) }"
>
{{ isStalled(record.log_synced_at) ? "同步停滞 " : "同步 " }}{{ formatMinute(record.log_synced_at) }}
</span>
</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 === 'actions'">
<a-button size="small" @click="() => goLogs(record.key_id)">查调用</a-button>
</template>
</template>
</a-table>
</section>
<section class="mv-block">
<h2 class="mv-block__title">支付参数</h2>
<div class="mv-pay-entry">
<span v-if="payments.length === 0" class="mv-note">暂无支付记录信息</span>
<span v-else class="mv-note">
该时段涉及 {{ payments.length }} 笔登记参数(订阅 / 充值)
</span>
<a @click="() => router.push('/member/payments')">查看完整参数</a>
</div>
</section>
</template>
</section>
</template>
<style scoped>
.mv-range-label {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
}
.mv-block {
margin-bottom: 24px;
}
.mv-block__title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
line-height: 22px;
}
.mv-caption {
margin-left: 8px;
font-size: 12px;
font-weight: 400;
color: rgba(0, 0, 0, 0.45);
}
.mv-syncing {
color: rgba(0, 0, 0, 0.45);
}
.mv-summary {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
border: 1px solid #f0f0f0;
border-radius: 8px;
}
.mv-summary__sub {
margin-top: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.mv-note {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.mv-pay-entry {
display: flex;
align-items: center;
gap: 16px;
}
.is-stall {
color: #faad14;
}
</style>
+144
View File
@@ -0,0 +1,144 @@
<script setup lang="ts">
/**
* 成员支付参数(交互设计-成员视图 §5):完全公开的登记参数表
*/
import { computed } from "vue";
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 { useMemberRange } from "@/composables/use-member-range.ts";
import { useMemberOverview } from "@/queries/member.ts";
import { formatDateTime } from "@/utils/time.ts";
import { formatPaidAmount } from "@/utils/format.ts";
const { range, onRangeChange } = useMemberRange();
const overviewQuery = useMemberOverview(() => range.value);
const payments = computed(() => overviewQuery.data.value?.payments ?? []);
const extraPresets = computed(() =>
payments.value
.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),
);
function periodText(p: { kind: string; paid_at: number; period_start: number | null; period_end: number | null }): string {
if (p.kind !== "subscription") return "—";
const end = p.period_end == null ? "进行中" : formatDateTime(p.period_end);
return `${formatDateTime(p.period_start)} ~ ${end}`;
}
const columns = [
{ title: "类型", key: "kind", width: 80 },
{ title: "编号 / 时间", key: "id_or_time", width: 160 },
{ title: "期间", key: "period", width: 220 },
{ title: "额度", key: "quota", width: 160 },
{ title: "金额", key: "amount", width: 100 },
{ title: "备注", key: "remark" },
];
</script>
<template>
<section>
<PageHeader
title="支付参数"
subtitle="订阅与充值的登记参数 · 对成员完全公开"
>
<template #actions>
<span class="mv-range-label">时间范围</span>
<TimeRangePicker
:model-value="range"
:extra-presets="extraPresets"
@change="onRangeChange"
/>
</template>
</PageHeader>
<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>
<a-skeleton
v-if="overviewQuery.isPending.value"
active
:paragraph="{ rows: 4 }"
/>
<a-empty
v-else-if="!overviewQuery.isError.value && payments.length === 0"
description="暂无支付记录信息"
/>
<template v-else-if="payments.length > 0">
<a-table
:columns="columns"
:data-source="payments"
:row-key="(r: { id: number }) => r.id"
size="middle"
:pagination="false"
:scroll="{ x: 860 }"
:loading="overviewQuery.isFetching.value"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'kind'">
<a-tag :color="record.kind === 'subscription' ? 'blue' : 'gold'">
{{ record.kind === "subscription" ? "订阅" : "充值" }}
</a-tag>
</template>
<template v-else-if="column.key === 'id_or_time'">
<template v-if="record.kind === 'subscription'">
订阅 #{{ record.subscription_id ?? record.id }}
</template>
<template v-else>
{{ formatDateTime(record.paid_at) }}
</template>
</template>
<template v-else-if="column.key === 'period'">
{{ periodText(record) }}
</template>
<template v-else-if="column.key === 'quota'">
<QuotaText
v-if="record.quota_granted != null"
:quota="record.quota_granted"
:quota-usd="record.quota_usd"
/>
<span v-else>—</span>
</template>
<template v-else-if="column.key === 'amount'">
{{ formatPaidAmount(record.paid_amount) }}
</template>
<template v-else-if="column.key === 'remark'">
{{ record.remark || "—" }}
</template>
</template>
</a-table>
<div class="mv-foot">
支付参数对成员完全公开;币种由成员与作者约定
</div>
</template>
</section>
</template>
<style scoped>
.mv-range-label {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
}
.mv-foot {
margin-top: 16px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
</style>