Files
new-api-key-usage/apps/web/src/components/admin/payment-form-modal.vue
T
skycurtain c32548e598 fix(web): 支付表列与表单语义校准
充值金额/实付金额文案与 dataIndex;期间两行;备注列收窄;
选用弹层 booting 不闪登录;订阅 paid_at 不默认当前时间。
2026-10-09 22:41:04 +08:00

462 lines
14 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">
/**
* 支付登记弹窗:订阅 / 充值分形态(拆表后)
* 时间控件统一精确到秒(show-time)
*/
import type { Dayjs } from "dayjs";
import dayjs from "dayjs";
import { computed, reactive, ref, watch } from "vue";
import { message } from "antdv-next";
import type { SubscriptionItem, TopupItem } from "@new-api-key-usage/core";
import type { ApiError } from "@/api/http.ts";
import PlatformSubscriptionPicker from "@/components/admin/platform-subscription-picker.vue";
import PlatformTopupPicker from "@/components/admin/platform-topup-picker.vue";
import type {
PlatformSubscription,
PlatformTopup,
} from "@/api/admin/platform.ts";
import {
useCreateSubscription,
useCreateTopup,
useQpuLatest,
useSubscriptions,
useTopups,
useUpdateSubscription,
useUpdateTopup,
} from "@/queries/admin.ts";
import { unixToBeijingWall, wallAsBeijingIso } from "@/utils/time.ts";
const TIME_FMT = "YYYY-MM-DD HH:mm:ss";
const props = defineProps<{
open: boolean;
tab: "subs" | "topups";
editingSub?: SubscriptionItem | null;
editingTop?: TopupItem | null;
}>();
const emit = defineEmits<{ (e: "update:open", v: boolean): void }>();
const createSub = useCreateSubscription();
const updateSub = useUpdateSubscription();
const createTop = useCreateTopup();
const updateTop = useUpdateTopup();
const subsQuery = useSubscriptions();
const topupsQuery = useTopups();
const qpuQuery = useQpuLatest();
const qpu = computed(() => qpuQuery.data.value?.qpu ?? null);
/** 额度双输入旁的换算预览(不入库,仅展示) */
const quotaPreview = computed(() => {
if (isSub.value) {
if (subForm.quotaMode === "quota") {
const q = subForm.quota_granted;
if (q == null || !qpu.value) return null;
return `≈ $${(q / qpu.value).toFixed(2)}(按当前换算率)`;
}
const u = subForm.quota_usd;
if (u == null || !qpu.value) return null;
return `≈ ${Math.round(u * qpu.value)} quota`;
}
const a = topForm.amount;
if (a == null || !qpu.value) return null;
return `≈ ${Math.round(a * qpu.value)} quota(按当前换算率)`;
});
const pickerOpen = ref(false);
const topPickerOpen = ref(false);
const error = ref("");
const isSub = ref(props.tab === "subs");
const registeredSubIds = computed(
() => (subsQuery.data.value ?? []).map((s) => s.subscription_id),
);
const registeredTopIds = computed(
() =>
(topupsQuery.data.value ?? [])
.map((t) => t.upstream_id)
.filter((v): v is number => v != null),
);
const subForm = reactive({
subscription_id: null as number | null,
period_start: null as Dayjs | null,
period_end: null as Dayjs | null,
quota_granted: null as number | null,
quotaMode: "quota" as "quota" | "usd",
quota_usd: null as number | null,
paid_amount: null as number | null,
paid_at: null as Dayjs | null,
remark: "",
});
const topForm = reactive({
upstream_id: null as number | null,
amount: null as number | null,
money: null as number | null,
trade_no: "",
payment_method: "",
payment_provider: "",
paid_at: null as Dayjs | null,
remark: "",
});
function resetSub() {
Object.assign(subForm, {
subscription_id: null,
period_start: null,
period_end: null,
quota_granted: null,
quotaMode: "quota",
quota_usd: null,
paid_amount: null,
paid_at: null,
remark: "",
});
}
function resetTop() {
Object.assign(topForm, {
upstream_id: null,
amount: null,
money: null,
trade_no: "",
payment_method: "",
payment_provider: "",
paid_at: null,
remark: "",
});
}
watch(
() => props.open,
(v) => {
if (!v) return;
error.value = "";
isSub.value = props.tab === "subs";
if (props.editingSub) {
isSub.value = true;
const s = props.editingSub;
subForm.subscription_id = s.subscription_id;
subForm.period_start = unixToBeijingWall(s.period_start);
subForm.period_end =
s.period_end != null ? unixToBeijingWall(s.period_end) : null;
subForm.quota_granted = s.quota_granted;
subForm.quotaMode = "quota";
subForm.quota_usd = null;
subForm.paid_amount = s.paid_amount;
subForm.paid_at =
s.paid_at != null ? unixToBeijingWall(s.paid_at) : null;
subForm.remark = s.remark;
} else {
// 订阅的 paid_at 是「套餐付款时刻」,可空——不默认填今天
resetSub();
}
if (props.editingTop) {
isSub.value = false;
const t = props.editingTop;
topForm.upstream_id = t.upstream_id;
topForm.amount = t.amount;
topForm.money = t.money;
topForm.trade_no = t.trade_no ?? "";
topForm.payment_method = t.payment_method ?? "";
topForm.payment_provider = t.payment_provider ?? "";
topForm.paid_at = unixToBeijingWall(t.paid_at);
topForm.remark = t.remark;
} else {
resetTop();
topForm.paid_at = dayjs();
}
},
);
function onPickSub(s: PlatformSubscription) {
subForm.subscription_id = s.id;
subForm.period_start = unixToBeijingWall(s.start_time);
subForm.period_end =
s.end_time != null ? unixToBeijingWall(s.end_time) : null;
subForm.quota_granted = s.amount_total;
subForm.quotaMode = "quota";
}
function onPickTop(t: PlatformTopup & { paid_at_iso: string }) {
topForm.upstream_id = t.id;
topForm.amount = t.amount;
topForm.money = t.money;
topForm.trade_no = t.trade_no ?? "";
topForm.payment_method = t.payment_method ?? "";
topForm.payment_provider = t.payment_provider ?? "";
const sec = t.complete_time ?? t.create_time;
topForm.paid_at =
sec != null ? unixToBeijingWall(sec) : unixToBeijingWall(Date.now() / 1000);
}
function iso(v: Dayjs | null): string | null {
// 北京墙钟进控件 → wallAsBeijingIso 无损回写(与旧登记表单一致)
return v ? wallAsBeijingIso(v) : null;
}
async function onSubmit() {
error.value = "";
try {
if (isSub.value) {
if (!subForm.period_start) {
error.value = "请填写期间起点";
return;
}
if (subForm.subscription_id == null) {
error.value = "请填写订阅编号";
return;
}
const qpu = qpuQuery.data.value?.qpu ?? 500000;
const quota =
subForm.quotaMode === "quota"
? subForm.quota_granted
: subForm.quota_usd != null
? Math.round(subForm.quota_usd * qpu)
: null;
if (quota == null) {
error.value = "请填写套餐总额度";
return;
}
if (subForm.paid_amount != null && subForm.paid_amount < 0) {
error.value = "付款金额不能为负数";
return;
}
const dup = (subsQuery.data.value ?? []).find(
(p) =>
p.subscription_id === subForm.subscription_id &&
p.id !== props.editingSub?.id,
);
if (dup) {
error.value = `订阅 #${subForm.subscription_id} 已有登记——请编辑原记录`;
return;
}
const body = {
subscription_id: subForm.subscription_id,
period_start: iso(subForm.period_start)!,
period_end: iso(subForm.period_end),
quota_granted: quota,
paid_amount: subForm.paid_amount,
paid_at: iso(subForm.paid_at),
remark: subForm.remark,
};
if (props.editingSub) {
await updateSub.mutateAsync({ id: props.editingSub.id, body });
} else {
await createSub.mutateAsync(body);
}
} else {
if (topForm.amount == null || topForm.amount <= 0) {
error.value = "请填写充值金额 amount(须为正)";
return;
}
if (topForm.money == null || topForm.money < 0) {
error.value = "实付金额不能为负数";
return;
}
if (!topForm.paid_at) {
error.value = "请填写入账时间";
return;
}
const body = {
upstream_id: topForm.upstream_id,
amount: topForm.amount,
money: topForm.money,
trade_no: topForm.trade_no || null,
payment_method: topForm.payment_method || null,
payment_provider: topForm.payment_provider || null,
status: props.editingTop?.status ?? "success",
paid_at: iso(topForm.paid_at)!,
remark: topForm.remark,
};
if (props.editingTop) {
await updateTop.mutateAsync({ id: props.editingTop.id, body });
} else {
await createTop.mutateAsync(body);
}
}
message.success(props.editingSub || props.editingTop ? "已保存" : "已登记");
emit("update:open", false);
} catch (e) {
const err = e as ApiError;
if (err.fields) {
error.value = Object.values(err.fields)[0] || "请检查表单";
} else {
error.value = err.message || "保存失败";
}
}
}
</script>
<template>
<a-modal
:open="open"
:title="isSub ? (editingSub ? '编辑订阅' : '登记订阅') : editingTop ? '编辑充值' : '登记充值'"
:confirm-loading="createSub.isPending.value || createTop.isPending.value || updateSub.isPending.value || updateTop.isPending.value"
ok-text="保存"
cancel-text="取消"
width="560"
centered
:styles="{ body: { maxHeight: '70vh', overflowY: 'auto' } }"
@ok="onSubmit"
@update:open="emit('update:open', $event)"
>
<a-radio-group
v-if="!editingSub && !editingTop"
v-model:value="isSub"
style="margin-bottom: 16px"
>
<a-radio :value="true">订阅</a-radio>
<a-radio :value="false">充值</a-radio>
</a-radio-group>
<a-form v-if="isSub" layout="vertical">
<a-form-item label="订阅 ID" required>
<a-space>
<a-input-number
v-model:value="subForm.subscription_id"
:min="1"
:precision="0"
style="width: 160px"
/>
<a-button @click="pickerOpen = true">从平台选用</a-button>
</a-space>
</a-form-item>
<a-form-item label="期间起止" required>
<a-date-picker
v-model:value="subForm.period_start"
:format="TIME_FMT"
:show-time="{ defaultValue: undefined }"
style="width: 200px"
/>
<span style="margin: 0 8px">~</span>
<a-date-picker
v-model:value="subForm.period_end"
:format="TIME_FMT"
:show-time="{ defaultValue: undefined }"
placeholder="止(空=进行中)"
style="width: 200px"
/>
</a-form-item>
<a-form-item label="套餐总额度" required>
<a-radio-group v-model:value="subForm.quotaMode" style="margin-right: 8px">
<a-radio-button value="quota">quota</a-radio-button>
<a-radio-button value="usd">美元</a-radio-button>
</a-radio-group>
<a-input-number
v-if="subForm.quotaMode === 'quota'"
v-model:value="subForm.quota_granted"
:min="0"
:precision="0"
style="width: 180px"
/>
<a-input-number
v-else
v-model:value="subForm.quota_usd"
:min="0"
:precision="2"
style="width: 180px"
/>
<div v-if="quotaPreview" class="form-note">
{{ quotaPreview }} · 落库为原始 quota
</div>
</a-form-item>
<a-form-item label="付款金额 / 付款时间(可空)">
<a-input-number
v-model:value="subForm.paid_amount"
:min="0"
:precision="2"
style="width: 140px"
placeholder="金额"
/>
<a-date-picker
v-model:value="subForm.paid_at"
:format="TIME_FMT"
:show-time="{ defaultValue: undefined }"
placeholder="钱出手时刻(可空)"
style="width: 200px; margin-left: 8px"
/>
<div class="form-note">
社交层付款事实:金额数值、币种由你们约定;未知可留空。登记时刻由系统另记
</div>
</a-form-item>
<a-form-item label="备注">
<a-input v-model:value="subForm.remark" />
</a-form-item>
</a-form>
<a-form v-else layout="vertical">
<a-form-item label="充值金额 amount" required>
<a-space>
<a-input-number
v-model:value="topForm.amount"
:min="0"
:precision="6"
style="width: 180px"
/>
<a-button @click="topPickerOpen = true">从平台选用</a-button>
</a-space>
<div class="form-note">
平台计费侧充值额(不标币种);与「实付」之间汇率由管理员约定。换得 quota ≈ amount × QPU
<template v-if="quotaPreview"> · {{ quotaPreview }}</template>
</div>
</a-form-item>
<a-form-item label="实付金额 money" required>
<a-input-number
v-model:value="topForm.money"
:min="0"
:precision="2"
style="width: 180px"
/>
<div class="form-note">实际支付金额(不预设币种)</div>
</a-form-item>
<a-form-item label="入账时间 paid_at" required>
<a-date-picker
v-model:value="topForm.paid_at"
:format="TIME_FMT"
:show-time="{ defaultValue: undefined }"
style="width: 220px"
/>
</a-form-item>
<a-form-item label="交易号 / 渠道">
<a-input v-model:value="topForm.trade_no" placeholder="trade_no" style="width: 220px" />
<a-input
v-model:value="topForm.payment_method"
placeholder="method"
style="width: 120px; margin-left: 8px"
/>
<a-input
v-model:value="topForm.payment_provider"
placeholder="provider"
style="width: 120px; margin-left: 8px"
/>
</a-form-item>
<a-form-item label="备注">
<a-input v-model:value="topForm.remark" />
</a-form-item>
</a-form>
<a-alert v-if="error" type="error" show-icon :message="error" />
<PlatformSubscriptionPicker
v-model:open="pickerOpen"
:registered-ids="registeredSubIds"
@select="onPickSub"
/>
<PlatformTopupPicker
v-model:open="topPickerOpen"
:registered-ids="registeredTopIds"
@select="onPickTop"
/>
</a-modal>
</template>
<style scoped>
.form-note {
margin-top: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
</style>