feat(web): 支付记录——同构登记表单、额度双输入与核对提示
This commit is contained in:
@@ -0,0 +1,242 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 支付登记/编辑:订阅与充值同构;额度双输入(落库恒 quota 原值)
|
||||||
|
*/
|
||||||
|
import { computed, reactive, ref, watch } from "vue";
|
||||||
|
import type { Dayjs } from "dayjs";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import type { PaymentItem } from "@new-api-key-usage/core";
|
||||||
|
import type { ApiError } from "@/api/http.ts";
|
||||||
|
import { useCreatePayment, useQpuLatest, useUpdatePayment } from "@/queries/admin.ts";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean;
|
||||||
|
/** 有则为编辑 */
|
||||||
|
editing?: PaymentItem | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:open", v: boolean): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const createPay = useCreatePayment();
|
||||||
|
const updatePay = useUpdatePayment();
|
||||||
|
const qpuQuery = useQpuLatest();
|
||||||
|
const qpu = computed(() => qpuQuery.data.value?.qpu ?? null);
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
kind: "subscription" as "subscription" | "topup",
|
||||||
|
paid_amount: null as number | null,
|
||||||
|
paid_at: null as Dayjs | null,
|
||||||
|
period_start: null as Dayjs | null,
|
||||||
|
period_end: null as Dayjs | null,
|
||||||
|
subscription_id: null as number | null,
|
||||||
|
remark: "",
|
||||||
|
quotaMode: "quota" as "quota" | "usd",
|
||||||
|
quota_granted: null as number | null,
|
||||||
|
quota_usd_input: null as number | null,
|
||||||
|
});
|
||||||
|
const error = ref("");
|
||||||
|
|
||||||
|
const isEdit = computed(() => props.editing != null);
|
||||||
|
const saving = computed(() => createPay.isPending.value || updatePay.isPending.value);
|
||||||
|
|
||||||
|
const quotaPreview = computed(() => {
|
||||||
|
if (form.quotaMode === "quota") {
|
||||||
|
const q = form.quota_granted;
|
||||||
|
if (q == null || !qpu.value) return null;
|
||||||
|
return `≈ $${(q / qpu.value).toFixed(2)}(按当前换算率)`;
|
||||||
|
}
|
||||||
|
const u = form.quota_usd_input;
|
||||||
|
if (u == null || !qpu.value) return null;
|
||||||
|
return `≈ ${Math.round(u * qpu.value)} quota`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const quotaGrantedFinal = computed(() => {
|
||||||
|
if (form.quotaMode === "quota") return form.quota_granted;
|
||||||
|
if (form.quota_usd_input == null || !qpu.value) return null;
|
||||||
|
return Math.round(form.quota_usd_input * qpu.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(v) => {
|
||||||
|
if (!v) return;
|
||||||
|
error.value = "";
|
||||||
|
const ed = props.editing;
|
||||||
|
if (ed) {
|
||||||
|
form.kind = ed.kind;
|
||||||
|
form.paid_amount = ed.paid_amount;
|
||||||
|
form.paid_at = ed.paid_at ? dayjs.unix(ed.paid_at) : null;
|
||||||
|
form.period_start = ed.period_start ? dayjs.unix(ed.period_start) : null;
|
||||||
|
form.period_end = ed.period_end ? dayjs.unix(ed.period_end) : null;
|
||||||
|
form.subscription_id = ed.subscription_id;
|
||||||
|
form.remark = ed.remark;
|
||||||
|
form.quotaMode = "quota";
|
||||||
|
form.quota_granted = ed.quota_granted;
|
||||||
|
form.quota_usd_input = null;
|
||||||
|
} else {
|
||||||
|
form.kind = "subscription";
|
||||||
|
form.paid_amount = null;
|
||||||
|
form.paid_at = dayjs();
|
||||||
|
form.period_start = null;
|
||||||
|
form.period_end = null;
|
||||||
|
form.subscription_id = null;
|
||||||
|
form.remark = "";
|
||||||
|
form.quotaMode = "quota";
|
||||||
|
form.quota_granted = null;
|
||||||
|
form.quota_usd_input = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function toIso(d: Dayjs | null): string | null {
|
||||||
|
return d ? d.toISOString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
error.value = "";
|
||||||
|
if (form.paid_amount == null) {
|
||||||
|
error.value = "请填写金额";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.paid_at) {
|
||||||
|
error.value = "请填写支付时间";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const quota = quotaGrantedFinal.value;
|
||||||
|
if (quota == null) {
|
||||||
|
error.value = "请填写额度";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.kind === "subscription") {
|
||||||
|
if (!form.period_start) {
|
||||||
|
error.value = "订阅必须填写期间起点";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.subscription_id == null) {
|
||||||
|
error.value = "订阅必须填写订阅编号";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const body = {
|
||||||
|
kind: form.kind,
|
||||||
|
paid_at: toIso(form.paid_at)!,
|
||||||
|
paid_amount: form.paid_amount,
|
||||||
|
remark: form.remark.trim(),
|
||||||
|
quota_granted: quota,
|
||||||
|
...(form.kind === "subscription"
|
||||||
|
? {
|
||||||
|
period_start: toIso(form.period_start),
|
||||||
|
period_end: toIso(form.period_end),
|
||||||
|
subscription_id: form.subscription_id,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
period_start: null,
|
||||||
|
period_end: null,
|
||||||
|
subscription_id: null,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (isEdit.value && props.editing) {
|
||||||
|
await updatePay.mutateAsync({ id: props.editing.id, body });
|
||||||
|
} else {
|
||||||
|
await createPay.mutateAsync(body);
|
||||||
|
}
|
||||||
|
emit("update:open", false);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = (e as ApiError).message || "保存失败";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
:open="open"
|
||||||
|
:title="isEdit ? '编辑支付记录' : '登记支付'"
|
||||||
|
:confirm-loading="saving"
|
||||||
|
ok-text="保存"
|
||||||
|
cancel-text="取消"
|
||||||
|
@update:open="emit('update:open', $event)"
|
||||||
|
@ok="onSubmit"
|
||||||
|
>
|
||||||
|
<a-form layout="vertical">
|
||||||
|
<a-form-item label="类型" required>
|
||||||
|
<a-radio-group v-model:value="form.kind" :disabled="isEdit">
|
||||||
|
<a-radio value="subscription">订阅</a-radio>
|
||||||
|
<a-radio value="topup">充值</a-radio>
|
||||||
|
</a-radio-group>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="金额(币种自定)" required>
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="form.paid_amount"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
<div class="pay-form__note">金额数值——币种由你们约定,系统不预设</div>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="支付时间" required>
|
||||||
|
<a-date-picker
|
||||||
|
v-model:value="form.paid_at"
|
||||||
|
show-time
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<template v-if="form.kind === 'subscription'">
|
||||||
|
<a-form-item label="期间起" required>
|
||||||
|
<a-date-picker v-model:value="form.period_start" style="width: 100%" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="期间止(空 = 进行中)">
|
||||||
|
<a-date-picker v-model:value="form.period_end" style="width: 100%" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="订阅 ID" required>
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="form.subscription_id"
|
||||||
|
:min="1"
|
||||||
|
:precision="0"
|
||||||
|
style="width: 100%"
|
||||||
|
placeholder="钱包页面「订阅 #N」"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
<a-form-item label="本笔额度" required>
|
||||||
|
<a-radio-group v-model:value="form.quotaMode">
|
||||||
|
<a-radio value="quota">原始 quota</a-radio>
|
||||||
|
<a-radio value="usd">按美元换算</a-radio>
|
||||||
|
</a-radio-group>
|
||||||
|
<a-input-number
|
||||||
|
v-if="form.quotaMode === 'quota'"
|
||||||
|
v-model:value="form.quota_granted"
|
||||||
|
:min="0"
|
||||||
|
:precision="0"
|
||||||
|
style="width: 100%; margin-top: 8px"
|
||||||
|
/>
|
||||||
|
<a-input-number
|
||||||
|
v-else
|
||||||
|
v-model:value="form.quota_usd_input"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
style="width: 100%; margin-top: 8px"
|
||||||
|
placeholder="美元金额"
|
||||||
|
/>
|
||||||
|
<div v-if="quotaPreview" class="pay-form__note">
|
||||||
|
{{ quotaPreview }} · 落库为原始 quota
|
||||||
|
</div>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="备注">
|
||||||
|
<a-input v-model:value="form.remark" placeholder="备注(可选)" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pay-form__note {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
type KeyListFilters,
|
type KeyListFilters,
|
||||||
} from "@/api/admin/keys.ts";
|
} from "@/api/admin/keys.ts";
|
||||||
import { fetchOverview } from "@/api/admin/overview.ts";
|
import { fetchOverview } from "@/api/admin/overview.ts";
|
||||||
import { fetchEngine } from "@/api/admin/engine.ts";
|
import { fetchEngine, fetchQpuLatest } from "@/api/admin/engine.ts";
|
||||||
import {
|
import {
|
||||||
createPayment,
|
createPayment,
|
||||||
deletePayment,
|
deletePayment,
|
||||||
@@ -40,6 +40,13 @@ export function useEngine() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useQpuLatest() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: qk.admin.qpuLatest(),
|
||||||
|
queryFn: fetchQpuLatest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useKeys(filters: () => KeyListFilters) {
|
export function useKeys(filters: () => KeyListFilters) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: () => qk.admin.keys(filters()),
|
queryKey: () => qk.admin.keys(filters()),
|
||||||
|
|||||||
@@ -1,13 +1,193 @@
|
|||||||
<script setup lang="ts">
|
<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 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 { formatDate, formatDateTime } 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 = formatDate(p.period_start);
|
||||||
|
const end = p.period_end == null ? "进行中" : formatDate(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 = new Date((earliest - 30 * 86400) * 1000).toISOString();
|
||||||
|
const to = new Date().toISOString();
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section>
|
<section>
|
||||||
<PageHeader
|
<PageHeader title="支付记录" subtitle="订阅与充值的人工登记">
|
||||||
title="支付记录"
|
<template #actions>
|
||||||
subtitle="订阅 / 充值登记与核对(信息结构见交互设计-支付记录)"
|
<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-skeleton active :paragraph="{ rows: 4 }" />
|
|
||||||
|
<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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user