feat(web): 成员端同构三页与日志详情,管理端轻量筛选
成员视图改管理端壳(总览/调用日志/支付参数),行内详情弹窗含原始数据; 成员日志含错误行;共享时间范围。管理端成员/支付筛选;支付仅留类型筛选。
This commit is contained in:
@@ -78,8 +78,22 @@ const columns = [
|
||||
{ title: "来源", key: "source", width: 110 },
|
||||
{ title: "用量", key: "usage", width: 140 },
|
||||
{ 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() {
|
||||
const q = route.query;
|
||||
const m = q.member_id;
|
||||
@@ -159,6 +173,21 @@ function goMember(memberId: number | 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 bodyY = ref(480);
|
||||
const CHROME = 96;
|
||||
@@ -190,13 +219,7 @@ onUnmounted(() => {
|
||||
<span class="call-logs__label">时间范围</span>
|
||||
<TimeRangePicker
|
||||
:model-value="range"
|
||||
@change="
|
||||
(v, k) => {
|
||||
setRange(v, k);
|
||||
page = 1;
|
||||
pushQuery();
|
||||
}
|
||||
"
|
||||
@change="onRangeChange"
|
||||
/>
|
||||
</template>
|
||||
</PageHeader>
|
||||
@@ -266,10 +289,7 @@ onUnmounted(() => {
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['20', '50', '100', '200'],
|
||||
showTotal: (t: number) => `共 ${t} 条`,
|
||||
onChange: (p: number, ps: number) => {
|
||||
page = p;
|
||||
pageSize = ps;
|
||||
},
|
||||
onChange: onPageChange,
|
||||
}"
|
||||
size="middle"
|
||||
:scroll="{ y: bodyY }"
|
||||
@@ -306,9 +326,74 @@ onUnmounted(() => {
|
||||
<template v-else-if="column.key === 'tokens'">
|
||||
{{ formatInt(record.prompt_tokens) }} / {{ formatInt(record.completion_tokens) }}
|
||||
</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="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>
|
||||
</template>
|
||||
|
||||
@@ -337,4 +422,52 @@ onUnmounted(() => {
|
||||
flex: 1;
|
||||
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>
|
||||
|
||||
@@ -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 { QuestionCircleOutlined } from "@antdv-next/icons";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
@@ -34,6 +34,29 @@ const tokenMemberName = ref("");
|
||||
const createForm = reactive({ name: "", remark: "" });
|
||||
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) {
|
||||
return h("span", { class: "overview-col-title" }, [
|
||||
label,
|
||||
@@ -63,15 +86,34 @@ const columns = [
|
||||
];
|
||||
|
||||
const rows = computed(() => {
|
||||
const list = membersQuery.data.value ?? [];
|
||||
let list = membersQuery.data.value ?? [];
|
||||
const mid = route.query.member_id;
|
||||
if (typeof mid === "string" && 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;
|
||||
});
|
||||
|
||||
const hasFilter = computed(
|
||||
() =>
|
||||
!!filterName.value.trim() ||
|
||||
filterStatus.value != null ||
|
||||
(typeof route.query.member_id === "string" && !!route.query.member_id),
|
||||
);
|
||||
|
||||
async function onCreate() {
|
||||
createError.value = "";
|
||||
const name = createForm.name.trim();
|
||||
@@ -133,11 +175,31 @@ async function onToggle(record: MemberItem) {
|
||||
</template>
|
||||
</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
|
||||
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-table
|
||||
@@ -230,6 +292,13 @@ async function onToggle(record: MemberItem) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.members-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.overview-col-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { message } from "antdv-next";
|
||||
import type { PaymentItem } from "@new-api-key-usage/core";
|
||||
import type { ApiError } from "@/api/http.ts";
|
||||
import { fetchLogs } from "@/api/admin/logs.ts";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import PageHeader from "@/components/common/page-header.vue";
|
||||
import PaymentFormModal from "@/components/admin/payment-form-modal.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 { formatPaidAmount } from "@/utils/format.ts";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const paymentsQuery = usePayments();
|
||||
const deletePay = useDeletePayment();
|
||||
|
||||
@@ -22,12 +25,42 @@ const editing = ref<PaymentItem | null>(null);
|
||||
const missingSubs = ref<number[]>([]);
|
||||
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 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));
|
||||
return list;
|
||||
});
|
||||
|
||||
const hasFilter = computed(() => filterKind.value != null);
|
||||
|
||||
syncFiltersFromRoute();
|
||||
watch(
|
||||
() => route.query,
|
||||
() => syncFiltersFromRoute(),
|
||||
);
|
||||
|
||||
const columns = [
|
||||
{ title: "类型", key: "kind", width: 90 },
|
||||
{ title: "金额(币种自定)", key: "paid_amount", width: 140 },
|
||||
@@ -124,6 +157,21 @@ watch(
|
||||
</template>
|
||||
</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
|
||||
v-if="missingSubs.length"
|
||||
type="warning"
|
||||
@@ -144,9 +192,9 @@ watch(
|
||||
|
||||
<a-empty
|
||||
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-table
|
||||
@@ -202,6 +250,14 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.payments-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pay-missing__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
Reference in New Issue
Block a user