feat(web): 成员端同构三页与日志详情,管理端轻量筛选
成员视图改管理端壳(总览/调用日志/支付参数),行内详情弹窗含原始数据; 成员日志含错误行;共享时间范围。管理端成员/支付筛选;支付仅留类型筛选。
This commit is contained in:
@@ -359,6 +359,33 @@ export function adminUsageRoutes() {
|
|||||||
is_stream: row.is_stream,
|
is_stream: row.is_stream,
|
||||||
billing_source: row.billing_source as LogItem["billing_source"],
|
billing_source: row.billing_source as LogItem["billing_source"],
|
||||||
subscription_id: row.subscription_id,
|
subscription_id: row.subscription_id,
|
||||||
|
raw: {
|
||||||
|
id: row.id,
|
||||||
|
api_key_id: row.api_key_id,
|
||||||
|
dedupe_key: row.dedupe_key,
|
||||||
|
billing_source: row.billing_source,
|
||||||
|
subscription_id: row.subscription_id,
|
||||||
|
user_id: row.user_id,
|
||||||
|
created_at: toUnixSec(row.created_at),
|
||||||
|
type: row.type,
|
||||||
|
content: row.content,
|
||||||
|
username: row.username,
|
||||||
|
token_name: row.token_name,
|
||||||
|
model_name: row.model_name,
|
||||||
|
quota: row.quota,
|
||||||
|
prompt_tokens: row.prompt_tokens,
|
||||||
|
completion_tokens: row.completion_tokens,
|
||||||
|
use_time: row.use_time,
|
||||||
|
is_stream: row.is_stream,
|
||||||
|
channel: row.channel,
|
||||||
|
channel_name: row.channel_name,
|
||||||
|
token_id: row.token_id,
|
||||||
|
group_name: row.group_name,
|
||||||
|
ip: row.ip,
|
||||||
|
request_id: row.request_id,
|
||||||
|
upstream_request_id: row.upstream_request_id,
|
||||||
|
other: row.other,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ export function memberViewRoutes() {
|
|||||||
method: "get",
|
method: "get",
|
||||||
path: "/logs",
|
path: "/logs",
|
||||||
summary: "日志明细(名下 key)",
|
summary: "日志明细(名下 key)",
|
||||||
description: "分页;恒 type=2;DTO 与管理面同构",
|
description: "分页;默认全类型(可 type 筛);DTO 与管理面同构",
|
||||||
tags: ["成员视图"],
|
tags: ["成员视图"],
|
||||||
security: [{ memberToken: [] }],
|
security: [{ memberToken: [] }],
|
||||||
request: {
|
request: {
|
||||||
@@ -234,6 +234,10 @@ export function memberViewRoutes() {
|
|||||||
to: z.string(),
|
to: z.string(),
|
||||||
page: z.string().optional(),
|
page: z.string().optional(),
|
||||||
page_size: z.string().optional(),
|
page_size: z.string().optional(),
|
||||||
|
api_key_id: z.string().optional(),
|
||||||
|
billing_source: z.string().optional(),
|
||||||
|
subscription_id: z.string().optional(),
|
||||||
|
type: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
responses: {
|
responses: {
|
||||||
@@ -264,6 +268,18 @@ export function memberViewRoutes() {
|
|||||||
to: range.to,
|
to: range.to,
|
||||||
page,
|
page,
|
||||||
page_size,
|
page_size,
|
||||||
|
api_key_id: q.api_key_id ? Number(q.api_key_id) : undefined,
|
||||||
|
billing_source:
|
||||||
|
q.billing_source === "none" ? "none" : q.billing_source || undefined,
|
||||||
|
subscription_id: q.subscription_id
|
||||||
|
? Number(q.subscription_id)
|
||||||
|
: undefined,
|
||||||
|
type:
|
||||||
|
q.type === "error"
|
||||||
|
? "error"
|
||||||
|
: q.type != null && q.type !== ""
|
||||||
|
? Number(q.type)
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
const items: LogItem[] = rows.map((row) => {
|
const items: LogItem[] = rows.map((row) => {
|
||||||
const { quota_usd } = usageRowPrecise(
|
const { quota_usd } = usageRowPrecise(
|
||||||
@@ -287,6 +303,33 @@ export function memberViewRoutes() {
|
|||||||
is_stream: row.is_stream,
|
is_stream: row.is_stream,
|
||||||
billing_source: row.billing_source as LogItem["billing_source"],
|
billing_source: row.billing_source as LogItem["billing_source"],
|
||||||
subscription_id: row.subscription_id,
|
subscription_id: row.subscription_id,
|
||||||
|
raw: {
|
||||||
|
id: row.id,
|
||||||
|
api_key_id: row.api_key_id,
|
||||||
|
dedupe_key: row.dedupe_key,
|
||||||
|
billing_source: row.billing_source,
|
||||||
|
subscription_id: row.subscription_id,
|
||||||
|
user_id: row.user_id,
|
||||||
|
created_at: toUnixSec(row.created_at),
|
||||||
|
type: row.type,
|
||||||
|
content: row.content,
|
||||||
|
username: row.username,
|
||||||
|
token_name: row.token_name,
|
||||||
|
model_name: row.model_name,
|
||||||
|
quota: row.quota,
|
||||||
|
prompt_tokens: row.prompt_tokens,
|
||||||
|
completion_tokens: row.completion_tokens,
|
||||||
|
use_time: row.use_time,
|
||||||
|
is_stream: row.is_stream,
|
||||||
|
channel: row.channel,
|
||||||
|
channel_name: row.channel_name,
|
||||||
|
token_id: row.token_id,
|
||||||
|
group_name: row.group_name,
|
||||||
|
ip: row.ip,
|
||||||
|
request_id: row.request_id,
|
||||||
|
upstream_request_id: row.upstream_request_id,
|
||||||
|
other: row.other,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return c.json({ items, total }, 200);
|
return c.json({ items, total }, 200);
|
||||||
|
|||||||
@@ -45,11 +45,20 @@ export async function fetchOverview(range: {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchLogs(
|
export type MemberLogQuery = {
|
||||||
range: { from: string; to: string; page: number; page_size: number },
|
from: string;
|
||||||
): Promise<Paginated<LogItem>> {
|
to: string;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
api_key_id?: number;
|
||||||
|
billing_source?: string;
|
||||||
|
subscription_id?: number;
|
||||||
|
type?: number | "error";
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchLogs(query: MemberLogQuery): Promise<Paginated<LogItem>> {
|
||||||
const { data } = await memberHttp.get<Paginated<LogItem>>("/logs", {
|
const { data } = await memberHttp.get<Paginated<LogItem>>("/logs", {
|
||||||
params: range,
|
params: query,
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,18 @@ export const qk = {
|
|||||||
me: () => ["member", "me"] as const,
|
me: () => ["member", "me"] as const,
|
||||||
overview: (range: TimeRange) =>
|
overview: (range: TimeRange) =>
|
||||||
["member", "overview", range.from, range.to] as const,
|
["member", "overview", range.from, range.to] as const,
|
||||||
logs: (range: TimeRange & PageQuery) =>
|
logs: (range: TimeRange & PageQuery & Record<string, unknown>) =>
|
||||||
["member", "logs", range.from, range.to, range.page, range.page_size] as const,
|
[
|
||||||
|
"member",
|
||||||
|
"logs",
|
||||||
|
range.from,
|
||||||
|
range.to,
|
||||||
|
range.page,
|
||||||
|
range.page_size,
|
||||||
|
range.api_key_id ?? "",
|
||||||
|
range.billing_source ?? "",
|
||||||
|
range.subscription_id ?? "",
|
||||||
|
range.type ?? "",
|
||||||
|
] as const,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { computed, watch } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
import {
|
||||||
|
useMemberRangeStore,
|
||||||
|
type RangeKind,
|
||||||
|
type RangeValue,
|
||||||
|
} from "@/stores/member-range.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 成员端共享时间范围 + 当前页 URL(from/to)双向同步。
|
||||||
|
* 切页时布局菜单带上 from/to,状态从 store 读——对账窗口不打断。
|
||||||
|
*/
|
||||||
|
export function useMemberRange() {
|
||||||
|
const store = useMemberRangeStore();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const range = computed(() => store.range);
|
||||||
|
|
||||||
|
function syncFromRoute() {
|
||||||
|
const from = route.query.from;
|
||||||
|
const to = route.query.to;
|
||||||
|
if (typeof from === "string" && from && typeof to === "string" && to) {
|
||||||
|
store.setRange({ from, to }, "fixed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在当前路径上写入 from/to(可并入页内其它 query) */
|
||||||
|
function pushRangeQuery(extra: Record<string, string> = {}) {
|
||||||
|
void router.replace({
|
||||||
|
path: route.path,
|
||||||
|
query: {
|
||||||
|
...pickNonRangeQuery(route.query),
|
||||||
|
...extra,
|
||||||
|
from: store.range.from,
|
||||||
|
to: store.range.to,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRangeChange(v: RangeValue, k: RangeKind) {
|
||||||
|
store.setRange(v, k);
|
||||||
|
pushRangeQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
syncFromRoute();
|
||||||
|
watch(() => route.query, () => syncFromRoute());
|
||||||
|
|
||||||
|
return {
|
||||||
|
range,
|
||||||
|
setRange: store.setRange,
|
||||||
|
onRangeChange,
|
||||||
|
pushRangeQuery,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickNonRangeQuery(q: Record<string, unknown>): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const [k, v] of Object.entries(q)) {
|
||||||
|
if (k === "from" || k === "to") continue;
|
||||||
|
if (typeof v === "string" && v) out[k] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -1,71 +1,273 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import type { MenuInfo, MenuItemType } from "antdv-next";
|
||||||
|
import { theme } from "antdv-next";
|
||||||
|
import {
|
||||||
|
AccountBookOutlined,
|
||||||
|
DashboardOutlined,
|
||||||
|
DownOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
|
MenuFoldOutlined,
|
||||||
|
MenuUnfoldOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
} from "@antdv-next/icons";
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { useMemberTokenStore } from "@/stores/member-token.ts";
|
import { useMemberTokenStore } from "@/stores/member-token.ts";
|
||||||
|
import { useMemberRangeStore } from "@/stores/member-range.ts";
|
||||||
import { useMemberMe } from "@/queries/member.ts";
|
import { useMemberMe } from "@/queries/member.ts";
|
||||||
|
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
const tokenStore = useMemberTokenStore();
|
const tokenStore = useMemberTokenStore();
|
||||||
|
const rangeStore = useMemberRangeStore();
|
||||||
const meQuery = useMemberMe();
|
const meQuery = useMemberMe();
|
||||||
|
|
||||||
|
const collapsed = ref(false);
|
||||||
|
const brandTitle = "NAKU";
|
||||||
|
const brandDescription = "成员用量";
|
||||||
const memberName = computed(
|
const memberName = computed(
|
||||||
() => tokenStore.memberName || meQuery.data.value?.name || "",
|
() => tokenStore.memberName || meQuery.data.value?.name || "",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const items: MenuItemType[] = [
|
||||||
|
{ key: "/member", icon: DashboardOutlined, label: "总览" },
|
||||||
|
{ key: "/member/logs", icon: FileTextOutlined, label: "调用日志" },
|
||||||
|
{ key: "/member/payments", icon: AccountBookOutlined, label: "支付参数" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const selectedKeys = ref<string[]>([route.path === "/member" ? "/member" : route.path]);
|
||||||
|
watch(
|
||||||
|
() => route.path,
|
||||||
|
(path) => {
|
||||||
|
selectedKeys.value = [path === "/member" ? "/member" : path];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function onMenuClick({ key }: MenuInfo) {
|
||||||
|
// 切页保留对账窗口(from/to),不带页内筛选
|
||||||
|
void router.push({
|
||||||
|
path: String(key),
|
||||||
|
query: {
|
||||||
|
from: rangeStore.range.from,
|
||||||
|
to: rangeStore.range.to,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMenuItems: MenuItemType[] = [
|
||||||
|
{ key: "logout", icon: LogoutOutlined, label: "退出", danger: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
function onUserMenuClick({ key }: MenuInfo) {
|
||||||
|
if (key === "logout") {
|
||||||
|
tokenStore.clearToken();
|
||||||
|
void router.replace({ name: "member-login" });
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="member-layout">
|
<a-layout class="member-layout">
|
||||||
<header class="member-layout__header">
|
<a-layout-sider
|
||||||
<div class="member-layout__brand">成员入口</div>
|
v-model:collapsed="collapsed"
|
||||||
<div class="member-layout__name">{{ memberName }}</div>
|
collapsible
|
||||||
</header>
|
:width="220"
|
||||||
<main class="member-layout__main">
|
:collapsed-width="80"
|
||||||
<div class="member-layout__surface">
|
>
|
||||||
<router-view />
|
<template #trigger>
|
||||||
|
<MenuUnfoldOutlined v-if="collapsed" />
|
||||||
|
<MenuFoldOutlined v-else />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="member-layout__brand"
|
||||||
|
:class="{ 'is-collapsed': collapsed }"
|
||||||
|
:title="collapsed ? `${brandTitle} · ${brandDescription}` : undefined"
|
||||||
|
>
|
||||||
|
<span class="member-layout__mark" aria-hidden="true">N</span>
|
||||||
|
<div v-if="!collapsed" class="member-layout__brand-text">
|
||||||
|
<div class="member-layout__brand-title">{{ brandTitle }}</div>
|
||||||
|
<div class="member-layout__brand-desc">{{ brandDescription }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
|
||||||
</div>
|
<a-menu
|
||||||
|
theme="dark"
|
||||||
|
mode="inline"
|
||||||
|
:inline-collapsed="collapsed"
|
||||||
|
:selected-keys="selectedKeys"
|
||||||
|
:items="items"
|
||||||
|
@click="onMenuClick"
|
||||||
|
/>
|
||||||
|
</a-layout-sider>
|
||||||
|
<a-layout class="member-layout__main">
|
||||||
|
<a-layout-header class="member-layout__header">
|
||||||
|
<a-dropdown
|
||||||
|
:menu="{ items: userMenuItems }"
|
||||||
|
placement="bottomRight"
|
||||||
|
:trigger="['click']"
|
||||||
|
@menu-click="onUserMenuClick"
|
||||||
|
>
|
||||||
|
<a class="member-layout__account">
|
||||||
|
<UserOutlined class="member-layout__account-icon" />
|
||||||
|
<span v-if="memberName" class="member-layout__username">{{ memberName }}</span>
|
||||||
|
<span v-else class="member-layout__username is-muted">…</span>
|
||||||
|
<DownOutlined class="member-layout__account-caret" />
|
||||||
|
</a>
|
||||||
|
</a-dropdown>
|
||||||
|
</a-layout-header>
|
||||||
|
<a-layout-content class="member-layout__content">
|
||||||
|
<div
|
||||||
|
class="member-layout__surface"
|
||||||
|
:style="{
|
||||||
|
background: token.colorBgContainer,
|
||||||
|
borderRadius: `${token.borderRadiusLG}px`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
</a-layout-content>
|
||||||
|
</a-layout>
|
||||||
|
</a-layout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* design.md:bg-layout 底 + bg-container 内容面;间距 24/16 对齐 spacing lg/md */
|
|
||||||
.member-layout {
|
.member-layout {
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
display: flex;
|
overflow: hidden;
|
||||||
flex-direction: column;
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-layout__header {
|
|
||||||
height: 64px;
|
|
||||||
padding: 0 24px;
|
|
||||||
background: #fff;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-layout__brand {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-layout__name {
|
|
||||||
color: rgba(0, 0, 0, 0.65);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-layout__main {
|
.member-layout__main {
|
||||||
flex: 1;
|
height: 100vh;
|
||||||
width: 100%;
|
min-width: 0;
|
||||||
max-width: 960px;
|
display: flex;
|
||||||
margin: 0 auto;
|
flex-direction: column;
|
||||||
padding: 24px 16px 48px;
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-layout__brand {
|
||||||
|
height: 64px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-layout__brand.is-collapsed {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-layout__mark {
|
||||||
|
flex: 0 0 28px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #1677ff;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 28px;
|
||||||
|
text-align: center;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-layout__brand-text {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-layout__brand-title {
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 22px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-layout__brand-desc {
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 20px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-layout__header {
|
||||||
|
flex: 0 0 64px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
background: #fff;
|
||||||
|
padding: 0 24px;
|
||||||
|
height: 64px;
|
||||||
|
line-height: 64px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-layout__account {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 8px;
|
||||||
|
height: 40px;
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-layout__account:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-layout__account-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-layout__account-caret {
|
||||||
|
font-size: 10px;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-layout__username {
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
max-width: 160px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-layout__username.is-muted {
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-layout__content {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-layout__surface {
|
.member-layout__surface {
|
||||||
background: #fff;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import {
|
|||||||
fetchLogs,
|
fetchLogs,
|
||||||
fetchMe,
|
fetchMe,
|
||||||
fetchOverview,
|
fetchOverview,
|
||||||
|
type MemberLogQuery,
|
||||||
} from "@/api/member/me.ts";
|
} from "@/api/member/me.ts";
|
||||||
import { qk, type PageQuery, type TimeRange } from "@/api/query-keys.ts";
|
import { qk, type TimeRange } from "@/api/query-keys.ts";
|
||||||
|
|
||||||
export function useMemberMe() {
|
export function useMemberMe() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -21,9 +22,10 @@ export function useMemberOverview(range: () => TimeRange) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMemberLogs(params: () => TimeRange & PageQuery) {
|
export function useMemberLogs(params: () => MemberLogQuery) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: () => qk.member.logs(params()),
|
queryKey: () => qk.member.logs(params()),
|
||||||
queryFn: () => fetchLogs(params()),
|
queryFn: () => fetchLogs(params()),
|
||||||
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { RouteRecordRaw } from "vue-router";
|
import type { RouteRecordRaw } from "vue-router";
|
||||||
import MemberLayout from "@/layouts/member-layout.vue";
|
import MemberLayout from "@/layouts/member-layout.vue";
|
||||||
|
|
||||||
/** 成员视图 `/member/*`:登录壳 → 单页(交互共性 §1) */
|
/** 成员视图 `/member/*`:登录壳 → 侧边栏三页(与管理端同构壳) */
|
||||||
export const memberRoutes: RouteRecordRaw[] = [
|
export const memberRoutes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
path: "/member/login",
|
path: "/member/login",
|
||||||
@@ -16,8 +16,18 @@ export const memberRoutes: RouteRecordRaw[] = [
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: "",
|
path: "",
|
||||||
name: "member-home",
|
name: "member-overview",
|
||||||
component: () => import("@/views/member/home-view.vue"),
|
component: () => import("@/views/member/overview-view.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "logs",
|
||||||
|
name: "member-logs",
|
||||||
|
component: () => import("@/views/member/logs-view.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "payments",
|
||||||
|
name: "member-payments",
|
||||||
|
component: () => import("@/views/member/payments-view.vue"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { recentDaysRange } from "@/utils/time.ts";
|
||||||
|
|
||||||
|
export type RangeValue = { from: string; to: string };
|
||||||
|
export type RangeKind = "recent-7" | "recent-30" | "fixed";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 成员端全局时间范围——总览 / 调用日志 / 支付参数共用(对账窗口不因切页打断)。
|
||||||
|
*/
|
||||||
|
export const useMemberRangeStore = defineStore("member-range", () => {
|
||||||
|
const kind = ref<RangeKind>("recent-30");
|
||||||
|
const fixed = ref<RangeValue>(recentDaysRange(30));
|
||||||
|
const nowTick = ref(Date.now());
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
nowTick.value = Date.now();
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
const range = computed<RangeValue>(() => {
|
||||||
|
void nowTick.value;
|
||||||
|
if (kind.value === "recent-7") return recentDaysRange(7);
|
||||||
|
if (kind.value === "recent-30") return recentDaysRange(30);
|
||||||
|
return fixed.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
function setRange(value: RangeValue, nextKind: RangeKind) {
|
||||||
|
kind.value = nextKind;
|
||||||
|
if (nextKind === "fixed") fixed.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind, fixed, range, setRange };
|
||||||
|
});
|
||||||
@@ -78,8 +78,22 @@ const columns = [
|
|||||||
{ title: "来源", key: "source", width: 110 },
|
{ title: "来源", key: "source", width: 110 },
|
||||||
{ title: "用量", key: "usage", width: 140 },
|
{ title: "用量", key: "usage", width: 140 },
|
||||||
{ title: "Tokens", key: "tokens", width: 110 },
|
{ title: "Tokens", key: "tokens", width: 110 },
|
||||||
|
{ title: "操作", key: "actions", width: 72 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const detailOpen = ref(false);
|
||||||
|
const detailRecord = ref<LogItem | null>(null);
|
||||||
|
|
||||||
|
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 syncFromRoute() {
|
function syncFromRoute() {
|
||||||
const q = route.query;
|
const q = route.query;
|
||||||
const m = q.member_id;
|
const m = q.member_id;
|
||||||
@@ -159,6 +173,21 @@ function goMember(memberId: number | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let ro: ResizeObserver | null = null;
|
let ro: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
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 boxRef = ref<HTMLElement | null>(null);
|
||||||
const bodyY = ref(480);
|
const bodyY = ref(480);
|
||||||
const CHROME = 96;
|
const CHROME = 96;
|
||||||
@@ -190,13 +219,7 @@ onUnmounted(() => {
|
|||||||
<span class="call-logs__label">时间范围</span>
|
<span class="call-logs__label">时间范围</span>
|
||||||
<TimeRangePicker
|
<TimeRangePicker
|
||||||
:model-value="range"
|
:model-value="range"
|
||||||
@change="
|
@change="onRangeChange"
|
||||||
(v, k) => {
|
|
||||||
setRange(v, k);
|
|
||||||
page = 1;
|
|
||||||
pushQuery();
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
@@ -266,10 +289,7 @@ onUnmounted(() => {
|
|||||||
showSizeChanger: true,
|
showSizeChanger: true,
|
||||||
pageSizeOptions: ['20', '50', '100', '200'],
|
pageSizeOptions: ['20', '50', '100', '200'],
|
||||||
showTotal: (t: number) => `共 ${t} 条`,
|
showTotal: (t: number) => `共 ${t} 条`,
|
||||||
onChange: (p: number, ps: number) => {
|
onChange: onPageChange,
|
||||||
page = p;
|
|
||||||
pageSize = ps;
|
|
||||||
},
|
|
||||||
}"
|
}"
|
||||||
size="middle"
|
size="middle"
|
||||||
:scroll="{ y: bodyY }"
|
:scroll="{ y: bodyY }"
|
||||||
@@ -306,9 +326,74 @@ onUnmounted(() => {
|
|||||||
<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 === 'actions'">
|
||||||
|
<a @click="openDetail(record)">详情</a>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<a-modal
|
||||||
|
v-model:open="detailOpen"
|
||||||
|
title="调用详情"
|
||||||
|
:footer="null"
|
||||||
|
width="720"
|
||||||
|
centered
|
||||||
|
:styles="{ body: { maxHeight: '70vh', overflowY: 'auto' } }"
|
||||||
|
>
|
||||||
|
<div v-if="detailRecord" class="call-logs__detail">
|
||||||
|
<div class="call-logs__kv">
|
||||||
|
<span class="call-logs__note">时间</span>
|
||||||
|
<span>{{ formatDateTime(detailRecord.created_at) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="call-logs__kv">
|
||||||
|
<span class="call-logs__note">Key / 归属</span>
|
||||||
|
<span>
|
||||||
|
{{ detailRecord.token_name }}
|
||||||
|
·
|
||||||
|
{{ keyById.get(detailRecord.api_key_id)?.member_name ?? "未归属" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="call-logs__kv">
|
||||||
|
<span class="call-logs__note">模型 / 类型</span>
|
||||||
|
<span>
|
||||||
|
{{ detailRecord.model_name }}
|
||||||
|
·
|
||||||
|
{{ detailRecord.type === 2 ? "消费" : "错误" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="call-logs__kv">
|
||||||
|
<span class="call-logs__note">用量</span>
|
||||||
|
<QuotaText :quota="detailRecord.quota" :quota-usd="detailRecord.quota_usd" />
|
||||||
|
</div>
|
||||||
|
<div class="call-logs__kv">
|
||||||
|
<span class="call-logs__note">Tokens</span>
|
||||||
|
<span class="call-logs__num">
|
||||||
|
提示 {{ formatInt(detailRecord.prompt_tokens) }} · 补全 {{ formatInt(detailRecord.completion_tokens) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="call-logs__kv">
|
||||||
|
<span class="call-logs__note">耗时</span>
|
||||||
|
<span class="call-logs__num">
|
||||||
|
{{ detailRecord.use_time }}s · {{ detailRecord.is_stream ? "流式" : "非流式" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="call-logs__kv">
|
||||||
|
<span class="call-logs__note">来源</span>
|
||||||
|
<a-tag :color="sourceBadge(detailRecord).color">
|
||||||
|
{{ sourceBadge(detailRecord).text }}
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
<div v-if="detailRecord.content" class="call-logs__kv">
|
||||||
|
<span class="call-logs__note">内容</span>
|
||||||
|
<span class="call-logs__content">{{ detailRecord.content }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="call-logs__raw">
|
||||||
|
<div class="call-logs__note">原始数据</div>
|
||||||
|
<pre class="call-logs__json">{{ formatRaw(detailRecord) }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -337,4 +422,52 @@ onUnmounted(() => {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.call-logs__detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-logs__kv {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-logs__note {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
min-width: 72px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-logs__num {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-logs__content {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-logs__raw {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
/**
|
/**
|
||||||
* 成员管理(交互设计-成员管理):清单表 · 开户 · 令牌明文/重发 · 启停
|
* 成员管理(交互设计-成员管理):清单表 · 开户 · 令牌明文/重发 · 启停
|
||||||
*/
|
*/
|
||||||
import { computed, h, reactive, ref } from "vue";
|
import { computed, h, reactive, ref, watch } from "vue";
|
||||||
import { Tooltip, message } from "antdv-next";
|
import { Tooltip, message } from "antdv-next";
|
||||||
import { QuestionCircleOutlined } from "@antdv-next/icons";
|
import { QuestionCircleOutlined } from "@antdv-next/icons";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
@@ -34,6 +34,29 @@ const tokenMemberName = ref("");
|
|||||||
const createForm = reactive({ name: "", remark: "" });
|
const createForm = reactive({ name: "", remark: "" });
|
||||||
const createError = ref("");
|
const createError = ref("");
|
||||||
|
|
||||||
|
const filterName = ref("");
|
||||||
|
const filterStatus = ref<"active" | "disabled" | null>(null);
|
||||||
|
|
||||||
|
function syncFiltersFromRoute() {
|
||||||
|
const q = route.query;
|
||||||
|
filterName.value = typeof q.name === "string" ? q.name : "";
|
||||||
|
filterStatus.value =
|
||||||
|
q.status === "active" || q.status === "disabled" ? q.status : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushFilterQuery() {
|
||||||
|
const query: Record<string, string> = {};
|
||||||
|
const mid = route.query.member_id;
|
||||||
|
if (typeof mid === "string" && mid) query.member_id = mid;
|
||||||
|
if (filterName.value.trim()) query.name = filterName.value.trim();
|
||||||
|
if (filterStatus.value) query.status = filterStatus.value;
|
||||||
|
void router.replace({ path: "/admin/members", query });
|
||||||
|
}
|
||||||
|
|
||||||
|
syncFiltersFromRoute();
|
||||||
|
watch(() => route.query, () => syncFiltersFromRoute());
|
||||||
|
watch([filterName, filterStatus], () => pushFilterQuery());
|
||||||
|
|
||||||
function tipTitle(label: string, tip: string) {
|
function tipTitle(label: string, tip: string) {
|
||||||
return h("span", { class: "overview-col-title" }, [
|
return h("span", { class: "overview-col-title" }, [
|
||||||
label,
|
label,
|
||||||
@@ -63,15 +86,34 @@ const columns = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const rows = computed(() => {
|
const rows = computed(() => {
|
||||||
const list = membersQuery.data.value ?? [];
|
let list = membersQuery.data.value ?? [];
|
||||||
const mid = route.query.member_id;
|
const mid = route.query.member_id;
|
||||||
if (typeof mid === "string" && mid) {
|
if (typeof mid === "string" && mid) {
|
||||||
const n = Number(mid);
|
const n = Number(mid);
|
||||||
return list.filter((m) => m.id === n);
|
list = list.filter((m) => m.id === n);
|
||||||
|
}
|
||||||
|
const name = filterName.value.trim();
|
||||||
|
if (name) {
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
list = list.filter(
|
||||||
|
(m) =>
|
||||||
|
m.name.toLowerCase().includes(lower) ||
|
||||||
|
(m.remark ?? "").toLowerCase().includes(lower),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filterStatus.value) {
|
||||||
|
list = list.filter((m) => m.status === filterStatus.value);
|
||||||
}
|
}
|
||||||
return list;
|
return list;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasFilter = computed(
|
||||||
|
() =>
|
||||||
|
!!filterName.value.trim() ||
|
||||||
|
filterStatus.value != null ||
|
||||||
|
(typeof route.query.member_id === "string" && !!route.query.member_id),
|
||||||
|
);
|
||||||
|
|
||||||
async function onCreate() {
|
async function onCreate() {
|
||||||
createError.value = "";
|
createError.value = "";
|
||||||
const name = createForm.name.trim();
|
const name = createForm.name.trim();
|
||||||
@@ -133,11 +175,31 @@ async function onToggle(record: MemberItem) {
|
|||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="members-filters">
|
||||||
|
<a-input
|
||||||
|
v-model:value="filterName"
|
||||||
|
allow-clear
|
||||||
|
placeholder="名称 / 备注"
|
||||||
|
style="width: 180px"
|
||||||
|
/>
|
||||||
|
<a-select
|
||||||
|
v-model:value="filterStatus"
|
||||||
|
:options="[
|
||||||
|
{ value: null, label: '全部状态' },
|
||||||
|
{ value: 'active', label: '启用中' },
|
||||||
|
{ value: 'disabled', label: '已停用' },
|
||||||
|
]"
|
||||||
|
style="width: 120px"
|
||||||
|
allow-clear
|
||||||
|
placeholder="状态"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<a-empty
|
<a-empty
|
||||||
v-if="!membersQuery.isPending.value && rows.length === 0"
|
v-if="!membersQuery.isPending.value && rows.length === 0"
|
||||||
description="还没有成员——为每位分摊朋友开户并发放访问令牌"
|
:description="hasFilter ? '没有符合筛选的成员' : '还没有成员——为每位分摊朋友开户并发放访问令牌'"
|
||||||
>
|
>
|
||||||
<a-button @click="createOpen = true">新建成员</a-button>
|
<a-button v-if="!hasFilter" @click="createOpen = true">新建成员</a-button>
|
||||||
</a-empty>
|
</a-empty>
|
||||||
|
|
||||||
<a-table
|
<a-table
|
||||||
@@ -230,6 +292,13 @@ async function onToggle(record: MemberItem) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.members-filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.overview-col-title {
|
.overview-col-title {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { message } from "antdv-next";
|
|||||||
import type { PaymentItem } from "@new-api-key-usage/core";
|
import type { PaymentItem } from "@new-api-key-usage/core";
|
||||||
import type { ApiError } from "@/api/http.ts";
|
import type { ApiError } from "@/api/http.ts";
|
||||||
import { fetchLogs } from "@/api/admin/logs.ts";
|
import { fetchLogs } from "@/api/admin/logs.ts";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import PageHeader from "@/components/common/page-header.vue";
|
import PageHeader from "@/components/common/page-header.vue";
|
||||||
import PaymentFormModal from "@/components/admin/payment-form-modal.vue";
|
import PaymentFormModal from "@/components/admin/payment-form-modal.vue";
|
||||||
import QuotaText from "@/components/common/quota-text.vue";
|
import QuotaText from "@/components/common/quota-text.vue";
|
||||||
@@ -14,6 +15,8 @@ import { useDeletePayment, usePayments } from "@/queries/admin.ts";
|
|||||||
import { formatDateTime, toRequestIso } from "@/utils/time.ts";
|
import { formatDateTime, toRequestIso } from "@/utils/time.ts";
|
||||||
import { formatPaidAmount } from "@/utils/format.ts";
|
import { formatPaidAmount } from "@/utils/format.ts";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
const paymentsQuery = usePayments();
|
const paymentsQuery = usePayments();
|
||||||
const deletePay = useDeletePayment();
|
const deletePay = useDeletePayment();
|
||||||
|
|
||||||
@@ -22,12 +25,42 @@ const editing = ref<PaymentItem | null>(null);
|
|||||||
const missingSubs = ref<number[]>([]);
|
const missingSubs = ref<number[]>([]);
|
||||||
const checking = ref(false);
|
const checking = ref(false);
|
||||||
|
|
||||||
|
const filterKind = ref<"subscription" | "topup" | null>(null);
|
||||||
|
|
||||||
|
function syncFiltersFromRoute() {
|
||||||
|
const q = route.query;
|
||||||
|
filterKind.value =
|
||||||
|
q.kind === "subscription" || q.kind === "topup" ? q.kind : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushFilterQuery() {
|
||||||
|
const query: Record<string, string> = {};
|
||||||
|
if (filterKind.value) query.kind = filterKind.value;
|
||||||
|
void router.replace({ path: "/admin/payments", query });
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKindChange(v: "subscription" | "topup" | null) {
|
||||||
|
filterKind.value = v;
|
||||||
|
pushFilterQuery();
|
||||||
|
}
|
||||||
|
|
||||||
const rows = computed(() => {
|
const rows = computed(() => {
|
||||||
const list = [...(paymentsQuery.data.value ?? [])];
|
let list = [...(paymentsQuery.data.value ?? [])];
|
||||||
|
if (filterKind.value) {
|
||||||
|
list = list.filter((p) => p.kind === filterKind.value);
|
||||||
|
}
|
||||||
list.sort((a, b) => (b.paid_at ?? 0) - (a.paid_at ?? 0));
|
list.sort((a, b) => (b.paid_at ?? 0) - (a.paid_at ?? 0));
|
||||||
return list;
|
return list;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasFilter = computed(() => filterKind.value != null);
|
||||||
|
|
||||||
|
syncFiltersFromRoute();
|
||||||
|
watch(
|
||||||
|
() => route.query,
|
||||||
|
() => syncFiltersFromRoute(),
|
||||||
|
);
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: "类型", key: "kind", width: 90 },
|
{ title: "类型", key: "kind", width: 90 },
|
||||||
{ title: "金额(币种自定)", key: "paid_amount", width: 140 },
|
{ title: "金额(币种自定)", key: "paid_amount", width: 140 },
|
||||||
@@ -124,6 +157,21 @@ watch(
|
|||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="payments-filters">
|
||||||
|
<a-select
|
||||||
|
:value="filterKind"
|
||||||
|
:options="[
|
||||||
|
{ value: null, label: '全部类型' },
|
||||||
|
{ value: 'subscription', label: '订阅' },
|
||||||
|
{ value: 'topup', label: '充值' },
|
||||||
|
]"
|
||||||
|
style="width: 120px"
|
||||||
|
allow-clear
|
||||||
|
placeholder="类型"
|
||||||
|
@update:value="onKindChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<a-alert
|
<a-alert
|
||||||
v-if="missingSubs.length"
|
v-if="missingSubs.length"
|
||||||
type="warning"
|
type="warning"
|
||||||
@@ -144,9 +192,9 @@ watch(
|
|||||||
|
|
||||||
<a-empty
|
<a-empty
|
||||||
v-if="!paymentsQuery.isPending.value && rows.length === 0"
|
v-if="!paymentsQuery.isPending.value && rows.length === 0"
|
||||||
description="还没有支付记录——订阅/充值同构登记"
|
:description="hasFilter ? '没有符合筛选的支付记录' : '还没有支付记录——订阅/充值同构登记'"
|
||||||
>
|
>
|
||||||
<a-button @click="onCreate">登记支付</a-button>
|
<a-button v-if="!hasFilter" @click="onCreate">登记支付</a-button>
|
||||||
</a-empty>
|
</a-empty>
|
||||||
|
|
||||||
<a-table
|
<a-table
|
||||||
@@ -202,6 +250,14 @@ watch(
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.payments-filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.pay-missing__tags {
|
.pay-missing__tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -20,7 +20,7 @@ async function onFinish() {
|
|||||||
tokenStore.setToken(model.token.trim());
|
tokenStore.setToken(model.token.trim());
|
||||||
const me = await fetchMe();
|
const me = await fetchMe();
|
||||||
tokenStore.setMemberName(me.name);
|
tokenStore.setMemberName(me.name);
|
||||||
void router.replace({ name: "member-home" });
|
void router.replace({ name: "member-overview" });
|
||||||
} catch {
|
} catch {
|
||||||
tokenStore.clearToken();
|
tokenStore.clearToken();
|
||||||
error.value = "令牌无效或已停用";
|
error.value = "令牌无效或已停用";
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -65,6 +65,7 @@ export const logItem = z.object({
|
|||||||
is_stream: z.boolean(),
|
is_stream: z.boolean(),
|
||||||
billing_source: z.string().nullable(),
|
billing_source: z.string().nullable(),
|
||||||
subscription_id: z.number().int().nullable(),
|
subscription_id: z.number().int().nullable(),
|
||||||
|
raw: z.record(z.string(), z.unknown()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const logPage = z.object({
|
export const logPage = z.object({
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export interface LogItem {
|
|||||||
is_stream: boolean;
|
is_stream: boolean;
|
||||||
billing_source: BillingSource | null;
|
billing_source: BillingSource | null;
|
||||||
subscription_id: number | null;
|
subscription_id: number | null;
|
||||||
|
/** 完整落库行(上游字段原貌)——「原始数据」视图 */
|
||||||
|
raw?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KeyItem {
|
export interface KeyItem {
|
||||||
|
|||||||
@@ -597,20 +597,39 @@ export function createRepos(db: Db) {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 成员视图明细分页(恒 type=2) */
|
/** 成员视图明细分页(可 type 筛;默认全类型) */
|
||||||
async listMemberPage(params: {
|
async listMemberPage(params: {
|
||||||
member_id: number;
|
member_id: number;
|
||||||
from: Date;
|
from: Date;
|
||||||
to: Date;
|
to: Date;
|
||||||
page: number;
|
page: number;
|
||||||
page_size: number;
|
page_size: number;
|
||||||
|
api_key_id?: number;
|
||||||
|
billing_source?: string | "none";
|
||||||
|
subscription_id?: number;
|
||||||
|
type?: number | "error";
|
||||||
}) {
|
}) {
|
||||||
const conds = [
|
const conds = [
|
||||||
eq(apiKeys.member_id, params.member_id),
|
eq(apiKeys.member_id, params.member_id),
|
||||||
gte(usageLogs.created_at, params.from),
|
gte(usageLogs.created_at, params.from),
|
||||||
lt(usageLogs.created_at, params.to),
|
lt(usageLogs.created_at, params.to),
|
||||||
eq(usageLogs.type, 2),
|
|
||||||
];
|
];
|
||||||
|
if (params.type === "error") {
|
||||||
|
conds.push(ne(usageLogs.type, 2));
|
||||||
|
} else if (typeof params.type === "number") {
|
||||||
|
conds.push(eq(usageLogs.type, params.type));
|
||||||
|
}
|
||||||
|
if (params.api_key_id != null) {
|
||||||
|
conds.push(eq(usageLogs.api_key_id, params.api_key_id));
|
||||||
|
}
|
||||||
|
if (params.billing_source === "none") {
|
||||||
|
conds.push(isNull(usageLogs.billing_source));
|
||||||
|
} else if (params.billing_source) {
|
||||||
|
conds.push(eq(usageLogs.billing_source, params.billing_source));
|
||||||
|
}
|
||||||
|
if (params.subscription_id != null) {
|
||||||
|
conds.push(eq(usageLogs.subscription_id, params.subscription_id));
|
||||||
|
}
|
||||||
const totalRows = await db
|
const totalRows = await db
|
||||||
.select({ n: count() })
|
.select({ n: count() })
|
||||||
.from(usageLogs)
|
.from(usageLogs)
|
||||||
@@ -621,6 +640,7 @@ export function createRepos(db: Db) {
|
|||||||
.select({
|
.select({
|
||||||
id: usageLogs.id,
|
id: usageLogs.id,
|
||||||
api_key_id: usageLogs.api_key_id,
|
api_key_id: usageLogs.api_key_id,
|
||||||
|
dedupe_key: usageLogs.dedupe_key,
|
||||||
created_at: usageLogs.created_at,
|
created_at: usageLogs.created_at,
|
||||||
token_name: apiKeys.token_name,
|
token_name: apiKeys.token_name,
|
||||||
model_name: usageLogs.model_name,
|
model_name: usageLogs.model_name,
|
||||||
@@ -633,6 +653,16 @@ export function createRepos(db: Db) {
|
|||||||
is_stream: usageLogs.is_stream,
|
is_stream: usageLogs.is_stream,
|
||||||
billing_source: usageLogs.billing_source,
|
billing_source: usageLogs.billing_source,
|
||||||
subscription_id: usageLogs.subscription_id,
|
subscription_id: usageLogs.subscription_id,
|
||||||
|
user_id: usageLogs.user_id,
|
||||||
|
username: usageLogs.username,
|
||||||
|
channel: usageLogs.channel,
|
||||||
|
channel_name: usageLogs.channel_name,
|
||||||
|
token_id: usageLogs.token_id,
|
||||||
|
group_name: usageLogs.group_name,
|
||||||
|
ip: usageLogs.ip,
|
||||||
|
request_id: usageLogs.request_id,
|
||||||
|
upstream_request_id: usageLogs.upstream_request_id,
|
||||||
|
other: usageLogs.other,
|
||||||
log_synced_at: apiKeys.log_synced_at,
|
log_synced_at: apiKeys.log_synced_at,
|
||||||
})
|
})
|
||||||
.from(usageLogs)
|
.from(usageLogs)
|
||||||
|
|||||||
Reference in New Issue
Block a user