feat(web): 管理端日志来源徽章支付弹窗与支付记录筛选——订阅 ID / 交易号可筛,弹窗可跳转核对补录

This commit is contained in:
2026-10-18 12:22:02 +08:00
parent 199972a2dc
commit fdc5942a72
5 changed files with 169 additions and 24 deletions
+113 -7
View File
@@ -3,7 +3,7 @@
* 管理面调用日志(交互设计-调用日志):筛选 + 明细表 + 与 key/成员跳转
*/
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import type { LogItem } from "@new-api-key-usage/core";
import type { LogItem, SubscriptionItem } from "@new-api-key-usage/core";
import { useRoute, useRouter } from "vue-router";
import PageHeader from "@/components/common/page-header.vue";
import TableRefresh from "@/components/common/table-refresh.vue";
@@ -14,9 +14,9 @@ import {
useLiveTimeRange,
type RangeKind,
} from "@/composables/use-live-time-range.ts";
import { useAdminLogs, useKeys, useMembers } from "@/queries/admin.ts";
import { useAdminLogs, useKeys, useMembers, useSubscriptions } from "@/queries/admin.ts";
import { formatDateTime } from "@/utils/time.ts";
import { formatInt } from "@/utils/format.ts";
import { formatInt, formatPaidAmount } from "@/utils/format.ts";
const route = useRoute();
const router = useRouter();
@@ -167,12 +167,54 @@ watch(page, () => pushQuery());
function sourceBadge(record: LogItem) {
if (record.billing_source === "subscription" && record.subscription_id != null) {
return { text: `订阅 #${record.subscription_id}`, color: "blue" as const };
return {
text: `订阅 #${record.subscription_id}`,
color: "blue" as const,
kind: "sub" as const,
id: record.subscription_id,
};
}
if (record.billing_source === "wallet") {
return { text: "充值", color: "gold" as const };
return { text: "充值", color: "gold" as const, kind: "wallet" as const, id: null };
}
return { text: "未关联", color: undefined };
return { text: "未关联", color: undefined, kind: "none" as const, id: null };
}
const payModalOpen = ref(false);
const payModalSubId = ref<number | null>(null);
const subsQuery = useSubscriptions();
const paymentBySub = computed(() => {
const m = new Map<number, SubscriptionItem>();
for (const p of subsQuery.data.value ?? []) {
m.set(p.subscription_id, p);
}
return m;
});
const payModalPayment = computed(() =>
payModalSubId.value == null
? undefined
: paymentBySub.value.get(payModalSubId.value),
);
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: SubscriptionItem): string {
const end = p.period_end == null ? "进行中" : formatDateTime(p.period_end);
return `${formatDateTime(p.period_start)} ~ ${end}`;
}
function goPayments() {
if (payModalSubId.value == null) return;
payModalOpen.value = false;
void router.push({
path: "/admin/payments",
query: { tab: "subs", subscription_id: String(payModalSubId.value) },
});
}
function goKey(keyId: number) {
@@ -354,7 +396,19 @@ onUnmounted(() => {
</a-tag>
</template>
<template v-else-if="column.key === 'source'">
<a-tag :color="sourceBadge(record).color">
<a-tooltip
v-if="sourceBadge(record).kind === 'sub'"
title="点击查看该订阅支付参数"
>
<a-tag
color="blue"
class="call-logs__badge"
@click="(e: MouseEvent) => onBadge(record, e)"
>
{{ sourceBadge(record).text }}
</a-tag>
</a-tooltip>
<a-tag v-else :color="sourceBadge(record).color">
{{ sourceBadge(record).text }}
</a-tag>
</template>
@@ -432,6 +486,41 @@ onUnmounted(() => {
</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="call-logs__pay">
<div class="call-logs__kv">
<span class="call-logs__note">期间</span>
<span>{{ periodText(payModalPayment) }}</span>
</div>
<div class="call-logs__kv">
<span class="call-logs__note">额度</span>
<QuotaText
:quota="payModalPayment.quota_granted"
:quota-usd="payModalPayment.quota_usd"
/>
</div>
<div class="call-logs__kv">
<span class="call-logs__note">金额</span>
<span class="call-logs__num">{{ formatPaidAmount(payModalPayment.paid_amount) }}</span>
</div>
<div v-if="payModalPayment.remark" class="call-logs__kv">
<span class="call-logs__note">备注</span>
<span>{{ payModalPayment.remark }}</span>
</div>
</div>
<div v-else class="call-logs__note">该订阅无登记信息</div>
<div class="call-logs__foot">
<a @click="goPayments">在支付记录中查看</a>
</div>
</a-modal>
</section>
</template>
@@ -508,4 +597,21 @@ onUnmounted(() => {
line-height: 20px;
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
}
.call-logs__badge {
cursor: pointer;
}
.call-logs__pay {
display: flex;
flex-direction: column;
gap: 12px;
}
.call-logs__foot {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
text-align: right;
}
</style>
+44 -8
View File
@@ -27,6 +27,8 @@ const subsQuery = useSubscriptions();
const topupsQuery = useTopups();
const filterDomain = ref<string | "none" | null>(null);
const filterSubId = ref<number | null>(null);
const filterTradeNo = ref<string | null>(null);
const domainOptions = computed(() => {
const set = new Set<string>();
for (const s of subsQuery.data.value ?? []) {
@@ -39,7 +41,10 @@ const domainOptions = computed(() => {
});
const filteredSubs = computed(() => {
const list = subsQuery.data.value ?? [];
let list = subsQuery.data.value ?? [];
if (filterSubId.value != null) {
list = list.filter((r) => r.subscription_id === filterSubId.value);
}
if (!filterDomain.value) return list;
if (filterDomain.value === "none") {
return list.filter((r) => r.billing_domain == null);
@@ -48,7 +53,15 @@ const filteredSubs = computed(() => {
});
const filteredTops = computed(() => {
const list = topupsQuery.data.value ?? [];
let list = topupsQuery.data.value ?? [];
if (filterTradeNo.value) {
const q = filterTradeNo.value.trim().toLowerCase();
list = list.filter(
(r) =>
(r.trade_no ?? "").toLowerCase().includes(q) ||
(r.upstream_id != null && String(r.upstream_id).includes(q)),
);
}
if (!filterDomain.value) return list;
if (filterDomain.value === "none") {
return list.filter((r) => r.billing_domain == null);
@@ -67,18 +80,23 @@ const checking = ref(false);
function syncFromRoute() {
tab.value = route.query.tab === "topups" ? "topups" : "subs";
const sid = route.query.subscription_id;
filterSubId.value = typeof sid === "string" && sid ? Number(sid) || null : null;
const tn = route.query.trade_no;
filterTradeNo.value = typeof tn === "string" && tn ? tn : null;
}
function pushTab() {
void router.replace({
path: "/admin/payments",
query: tab.value === "topups" ? { tab: "topups" } : {},
});
function pushQuery() {
const query: Record<string, string> = {};
if (tab.value === "topups") query.tab = "topups";
if (filterSubId.value != null) query.subscription_id = String(filterSubId.value);
if (filterTradeNo.value) query.trade_no = filterTradeNo.value;
void router.replace({ path: "/admin/payments", query });
}
syncFromRoute();
watch(() => route.query, () => syncFromRoute());
watch(tab, () => pushTab());
watch([tab, filterSubId, filterTradeNo], () => pushQuery());
const subRows = computed(() => [...filteredSubs.value]);
const topRows = computed(() => {
@@ -231,6 +249,24 @@ watch(
</a-tabs>
<div class="payments-toolbar">
<template v-if="tab === 'subs'">
<a-input-number
v-model:value="filterSubId"
:min="1"
:precision="0"
style="width: 140px"
placeholder="订阅 ID"
allow-clear
/>
</template>
<template v-else>
<a-input
v-model:value="filterTradeNo"
style="width: 180px"
placeholder="交易号 / 上游 ID"
allow-clear
/>
</template>
<a-select
v-model:value="filterDomain"
:options="[