Files
new-api-key-usage/apps/web/src/views/admin/payments-view.vue
T

194 lines
6.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* 支付记录(交互设计-支付记录):同构登记 · 编辑删除 · 核对提示
*/
import { computed, ref, watch } from "vue";
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 PageHeader from "@/components/common/page-header.vue";
import PaymentFormModal from "@/components/admin/payment-form-modal.vue";
import QuotaText from "@/components/common/quota-text.vue";
import { useDeletePayment, usePayments } from "@/queries/admin.ts";
import { formatDateTime, toRequestIso } from "@/utils/time.ts";
import { formatPaidAmount } from "@/utils/format.ts";
const paymentsQuery = usePayments();
const deletePay = useDeletePayment();
const formOpen = ref(false);
const editing = ref<PaymentItem | null>(null);
const missingSubs = ref<number[]>([]);
const checking = ref(false);
const rows = computed(() => {
const list = [...(paymentsQuery.data.value ?? [])];
list.sort((a, b) => (b.paid_at ?? 0) - (a.paid_at ?? 0));
return list;
});
const columns = [
{ title: "类型", key: "kind", width: 90 },
{ title: "金额(币种自定)", key: "paid_amount", width: 140 },
{ title: "时间", key: "time", width: 180 },
{ title: "订阅 ID", key: "subscription_id", width: 100 },
{ title: "额度", key: "quota", width: 160 },
{ title: "备注", key: "remark" },
{ title: "操作", key: "actions", width: 120 },
];
function timeText(p: PaymentItem): string {
if (p.kind === "topup") return formatDateTime(p.paid_at);
const start = formatDateTime(p.period_start);
const end = p.period_end == null ? "进行中" : formatDateTime(p.period_end);
return `${start} ~ ${end}`;
}
function onCreate() {
editing.value = null;
formOpen.value = true;
}
function onEdit(p: PaymentItem) {
editing.value = p;
formOpen.value = true;
}
async function onDelete(p: PaymentItem) {
try {
await deletePay.mutateAsync(p.id);
message.success("已删除");
} catch (e) {
message.error((e as ApiError).message || "删除失败");
}
}
/** 观测订阅号 ↔ 已登记:只提示不回填;payments 全空则静默 */
async function reconcile(pays: PaymentItem[]) {
if (pays.length === 0) {
missingSubs.value = [];
return;
}
checking.value = true;
try {
const known = new Set(
pays.map((p) => p.subscription_id).filter((v): v is number => v != null),
);
const earliest = Math.min(...pays.map((p) => p.paid_at ?? 0), Date.now() / 1000);
const from = toRequestIso(new Date((earliest - 30 * 86400) * 1000));
const to = toRequestIso(new Date());
const observed = new Set<number>();
let page = 1;
for (;;) {
const res = await fetchLogs({
from,
to,
page,
page_size: 200,
});
for (const it of res.items) {
if (it.type === 2 && it.subscription_id != null) {
observed.add(it.subscription_id);
}
}
if (page * 200 >= res.total || page >= 20) break;
page += 1;
}
missingSubs.value = [...observed]
.filter((id) => !known.has(id))
.sort((a, b) => a - b);
} catch {
missingSubs.value = [];
} finally {
checking.value = false;
}
}
// 数据到达/失效刷新后再核对(挂载时尚未 resolve)
watch(
() => paymentsQuery.data.value,
(pays) => {
if (!pays) return;
void reconcile(pays);
},
{ immediate: true },
);
</script>
<template>
<section>
<PageHeader title="支付记录" subtitle="订阅与充值的人工登记">
<template #actions>
<a-button type="primary" @click="onCreate">登记支付</a-button>
</template>
</PageHeader>
<a-alert
v-for="id in missingSubs"
:key="id"
type="warning"
show-icon
:message="`观测到订阅 #${id} 有消耗,但尚无对应登记——请核对补录`"
style="margin-bottom: 8px"
/>
<a-empty
v-if="!paymentsQuery.isPending.value && rows.length === 0"
description="还没有支付记录——订阅/充值同构登记"
>
<a-button @click="onCreate">登记支付</a-button>
</a-empty>
<a-table
v-else
:columns="columns"
:data-source="rows"
:row-key="(r: PaymentItem) => r.id"
:loading="paymentsQuery.isPending.value || paymentsQuery.isFetching.value || checking"
:pagination="false"
size="middle"
:scroll="{ x: 1000 }"
>
<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 === 'paid_amount'">
{{ formatPaidAmount(record.paid_amount) }}
</template>
<template v-else-if="column.key === 'time'">
{{ timeText(record) }}
</template>
<template v-else-if="column.key === 'subscription_id'">
{{ record.subscription_id ?? "—" }}
</template>
<template v-else-if="column.key === 'quota'">
<QuotaText :quota="record.quota_granted" :quota-usd="record.quota_usd" />
</template>
<template v-else-if="column.key === 'remark'">
{{ record.remark || "—" }}
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button size="small" @click="() => onEdit(record)">编辑</a-button>
<a-popconfirm
title="删除后该条支付记录将消失"
description="改错即修,删除即消失"
ok-text="删除"
cancel-text="取消"
@confirm="() => onDelete(record)"
>
<a-button size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
<PaymentFormModal v-model:open="formOpen" :editing="editing" />
</section>
</template>