feat(web): 支付记录双 Tab 与平台选用弹层
订阅/充值分形态表单(秒级时间、金额非负、额度换算预览); 选用弹层已登记灰显、每次打开重拉、关弹窗清密码。
This commit is contained in:
@@ -1,184 +1,290 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 支付登记/编辑:订阅与充值同构;额度双输入(落库恒 quota 原值)
|
||||
* 支付登记弹窗:订阅 / 充值分形态(拆表后)
|
||||
* 时间控件统一精确到秒(show-time)
|
||||
*/
|
||||
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 { 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 { unixToBeijingWall, wallAsBeijingIso } from "@/utils/time.ts";
|
||||
import {
|
||||
useCreatePayment,
|
||||
usePayments,
|
||||
useQpuLatest,
|
||||
useUpdatePayment,
|
||||
} from "@/queries/admin.ts";
|
||||
import PlatformSubscriptionPicker from "@/components/admin/platform-subscription-picker.vue";
|
||||
import type { PlatformSubscription } from "@/api/admin/platform.ts";
|
||||
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;
|
||||
/** 有则为编辑 */
|
||||
editing?: PaymentItem | null;
|
||||
tab: "subs" | "topups";
|
||||
editingSub?: SubscriptionItem | null;
|
||||
editingTop?: TopupItem | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:open", v: boolean): void;
|
||||
}>();
|
||||
const emit = defineEmits<{ (e: "update:open", v: boolean): void }>();
|
||||
|
||||
const createPay = useCreatePayment();
|
||||
const updatePay = useUpdatePayment();
|
||||
const createSub = useCreateSubscription();
|
||||
const updateSub = useUpdateSubscription();
|
||||
const createTop = useCreateTopup();
|
||||
const updateTop = useUpdateTopup();
|
||||
const subsQuery = useSubscriptions();
|
||||
const topupsQuery = useTopups();
|
||||
const qpuQuery = useQpuLatest();
|
||||
const paymentsQuery = usePayments();
|
||||
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,
|
||||
/** 额度双输入旁的换算预览(不入库,仅展示) */
|
||||
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,
|
||||
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,
|
||||
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 error = ref("");
|
||||
const pickerOpen = ref(false);
|
||||
|
||||
function onPickSubscription(s: PlatformSubscription) {
|
||||
form.kind = "subscription";
|
||||
form.subscription_id = s.id;
|
||||
form.period_start = unixToBeijingWall(s.start_time);
|
||||
form.period_end = s.end_time > 0 ? unixToBeijingWall(s.end_time) : null;
|
||||
form.quotaMode = "quota";
|
||||
form.quota_granted = s.amount_total;
|
||||
form.quota_usd_input = null;
|
||||
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: "",
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
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 = "";
|
||||
const ed = props.editing;
|
||||
if (ed) {
|
||||
form.kind = ed.kind;
|
||||
form.paid_amount = ed.paid_amount;
|
||||
// 北京墙钟进控件,保存时 wallAsBeijingIso 可无损回写
|
||||
form.paid_at = ed.paid_at != null ? unixToBeijingWall(ed.paid_at) : null;
|
||||
form.period_start =
|
||||
ed.period_start != null ? unixToBeijingWall(ed.period_start) : null;
|
||||
form.period_end =
|
||||
ed.period_end != null ? unixToBeijingWall(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;
|
||||
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 {
|
||||
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;
|
||||
resetSub();
|
||||
subForm.paid_at = dayjs();
|
||||
}
|
||||
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 toIso(d: Dayjs | null): string | null {
|
||||
return d ? wallAsBeijingIso(d) : null;
|
||||
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 = "";
|
||||
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 dup = (paymentsQuery.data.value ?? []).find(
|
||||
(p) =>
|
||||
p.subscription_id === form.subscription_id &&
|
||||
p.id !== (props.editing?.id ?? -1),
|
||||
);
|
||||
if (dup) {
|
||||
error.value = `订阅 #${form.subscription_id} 已有支付登记——请编辑原记录,不要重复登记`;
|
||||
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 });
|
||||
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 {
|
||||
await createPay.mutateAsync(body);
|
||||
if (topForm.amount == null || topForm.amount <= 0) {
|
||||
error.value = "请填写计费单位金额(须为正)";
|
||||
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) {
|
||||
error.value = (e as ApiError).message || "保存失败";
|
||||
const err = e as ApiError;
|
||||
if (err.fields) {
|
||||
error.value = Object.values(err.fields)[0] || "请检查表单";
|
||||
} else {
|
||||
error.value = err.message || "保存失败";
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -186,117 +292,167 @@ async function onSubmit() {
|
||||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
:title="isEdit ? '编辑支付记录' : '登记支付'"
|
||||
:confirm-loading="saving"
|
||||
:title="isSub ? (editingSub ? '编辑订阅' : '登记订阅') : editingTop ? '编辑充值' : '登记充值'"
|
||||
:confirm-loading="createSub.isPending.value || createTop.isPending.value || updateSub.isPending.value || updateTop.isPending.value"
|
||||
ok-text="保存"
|
||||
cancel-text="取消"
|
||||
:styles="{ body: { maxHeight: '62vh', overflowY: 'auto' } }"
|
||||
@update:open="emit('update:open', $event)"
|
||||
width="560"
|
||||
centered
|
||||
:styles="{ body: { maxHeight: '70vh', overflowY: 'auto' } }"
|
||||
@ok="onSubmit"
|
||||
@update:open="emit('update:open', $event)"
|
||||
>
|
||||
<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-button
|
||||
v-if="form.kind === 'subscription' && !isEdit"
|
||||
class="pay-form__pick"
|
||||
block
|
||||
@click="pickerOpen = true"
|
||||
>
|
||||
从平台选择订阅(自动填写编号、期间与额度)
|
||||
</a-button>
|
||||
<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"
|
||||
show-time
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="期间止(空 = 进行中)">
|
||||
<a-date-picker
|
||||
v-model:value="form.period_end"
|
||||
show-time
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="订阅 ID" required>
|
||||
<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="form.subscription_id"
|
||||
v-model:value="subForm.subscription_id"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
style="width: 100%"
|
||||
placeholder="钱包页面「订阅 #N」"
|
||||
style="width: 160px"
|
||||
/>
|
||||
</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-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="form.quotaMode === 'quota'"
|
||||
v-model:value="form.quota_granted"
|
||||
v-if="subForm.quotaMode === 'quota'"
|
||||
v-model:value="subForm.quota_granted"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
style="width: 100%; margin-top: 8px"
|
||||
style="width: 180px"
|
||||
/>
|
||||
<a-input-number
|
||||
v-else
|
||||
v-model:value="form.quota_usd_input"
|
||||
v-model:value="subForm.quota_usd"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 100%; margin-top: 8px"
|
||||
placeholder="美元金额"
|
||||
style="width: 180px"
|
||||
/>
|
||||
<div v-if="quotaPreview" class="pay-form__note">
|
||||
<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="form.remark" placeholder="备注(可选)" />
|
||||
<a-input v-model:value="subForm.remark" />
|
||||
</a-form-item>
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
</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">
|
||||
上游 amount(美元额度形态);换得 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"
|
||||
/>
|
||||
</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"
|
||||
@select="onPickSubscription"
|
||||
:registered-ids="registeredSubIds"
|
||||
@select="onPickSub"
|
||||
/>
|
||||
<PlatformTopupPicker
|
||||
v-model:open="topPickerOpen"
|
||||
:registered-ids="registeredTopIds"
|
||||
@select="onPickTop"
|
||||
/>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pay-form__pick {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pay-form__note {
|
||||
.form-note {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* 订阅选择弹层:登录(仅一次/标签页)+ 列表选用,与登记表单解耦
|
||||
*/
|
||||
import { ref } from "vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import {
|
||||
listPlatformSubscriptions,
|
||||
loginPlatform,
|
||||
@@ -13,6 +13,8 @@ import { formatDateTime } from "@/utils/time.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
/** 已登记的 subscription_id——灰显不可选用 */
|
||||
registeredIds?: number[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -45,8 +47,24 @@ function writeToken(token: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!username.value.trim() || !password.value || loading.value) return;
|
||||
watch(
|
||||
() => props.open,
|
||||
(v) => {
|
||||
if (v) {
|
||||
// 每次打开都重新拉取——上游续订/新订阅要能看见
|
||||
if (readToken()) void load(true);
|
||||
} else {
|
||||
list.value = [];
|
||||
error.value = "";
|
||||
password.value = "";
|
||||
// 用户名可留作便利;密码绝不残留
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function load(skipLoginCheck = false) {
|
||||
if (loading.value) return;
|
||||
if (!skipLoginCheck && (!username.value.trim() || !password.value)) return;
|
||||
error.value = "";
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -58,15 +76,15 @@ async function load() {
|
||||
});
|
||||
token = login.access_token;
|
||||
writeToken(token);
|
||||
password.value = "";
|
||||
}
|
||||
password.value = "";
|
||||
list.value = await listPlatformSubscriptions(token);
|
||||
if (list.value.length === 0) {
|
||||
error.value = "账号下没有订阅记录";
|
||||
}
|
||||
} catch (e) {
|
||||
const err = e as ApiError;
|
||||
if (err.status === 401) {
|
||||
if (err.status === 401 || err.code === "platform_token_invalid") {
|
||||
writeToken("");
|
||||
error.value = "平台登录已失效——请重新输入密码";
|
||||
} else {
|
||||
@@ -79,7 +97,12 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
function isRegistered(s: PlatformSubscription): boolean {
|
||||
return (props.registeredIds ?? []).includes(s.id);
|
||||
}
|
||||
|
||||
function onUse(s: PlatformSubscription) {
|
||||
if (isRegistered(s)) return;
|
||||
emit("select", s);
|
||||
emit("update:open", false);
|
||||
}
|
||||
@@ -139,11 +162,14 @@ function onUse(s: PlatformSubscription) {
|
||||
v-for="s in list"
|
||||
:key="s.id"
|
||||
class="plat-pick__item"
|
||||
:class="{ 'is-registered': isRegistered(s) }"
|
||||
@click="onUse(s)"
|
||||
>
|
||||
<div class="plat-pick__head">
|
||||
<strong>订阅 #{{ s.id }}</strong>
|
||||
<span class="plat-pick__status">{{ s.status }}</span>
|
||||
<span class="plat-pick__status">
|
||||
{{ isRegistered(s) ? "已登记" : s.status }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="plat-pick__meta">
|
||||
{{ formatDateTime(s.start_time) }} ~
|
||||
@@ -181,6 +207,15 @@ function onUse(s: PlatformSubscription) {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.plat-pick__item.is-registered {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.plat-pick__item.is-registered:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.plat-pick__item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 充值选用弹层:登录 + 传统分页;已登记按 upstream_id 灰显
|
||||
*/
|
||||
import { computed, ref, watch } from "vue";
|
||||
import {
|
||||
listPlatformTopups,
|
||||
loginPlatform,
|
||||
type PlatformTopup,
|
||||
} from "@/api/admin/platform.ts";
|
||||
import type { ApiError } from "@/api/http.ts";
|
||||
import { formatDateTime } from "@/utils/time.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
registeredIds?: number[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:open", v: boolean): void;
|
||||
(e: "select", topup: PlatformTopup & { paid_at_iso: string }): void;
|
||||
}>();
|
||||
|
||||
const TOKEN_KEY = "naku_platform_token";
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
const username = ref("");
|
||||
const password = ref("");
|
||||
const error = ref("");
|
||||
const loading = ref(false);
|
||||
const items = ref<PlatformTopup[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
|
||||
const totalPages = computed(() =>
|
||||
Math.max(1, Math.ceil(total.value / PAGE_SIZE)),
|
||||
);
|
||||
|
||||
function readToken(): string {
|
||||
try {
|
||||
return sessionStorage.getItem(TOKEN_KEY) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function writeToken(token: string) {
|
||||
try {
|
||||
if (token) sessionStorage.setItem(TOKEN_KEY, token);
|
||||
else sessionStorage.removeItem(TOKEN_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureToken(): Promise<string> {
|
||||
let token = readToken();
|
||||
if (token) return token;
|
||||
const login = await loginPlatform({
|
||||
username: username.value.trim(),
|
||||
password: password.value,
|
||||
});
|
||||
token = login.access_token;
|
||||
writeToken(token);
|
||||
password.value = "";
|
||||
return token;
|
||||
}
|
||||
|
||||
async function loadPage(p: number, opts?: { allowTokenOnly?: boolean }) {
|
||||
if (loading.value) return;
|
||||
if (!opts?.allowTokenOnly && !readToken() && (!username.value.trim() || !password.value)) {
|
||||
return;
|
||||
}
|
||||
error.value = "";
|
||||
loading.value = true;
|
||||
try {
|
||||
const token = await ensureToken();
|
||||
password.value = "";
|
||||
const data = await listPlatformTopups(token, p, PAGE_SIZE);
|
||||
items.value = data.items;
|
||||
total.value = data.total;
|
||||
page.value = data.page || p;
|
||||
if (items.value.length === 0 && p === 1) {
|
||||
error.value = "账号下没有充值记录";
|
||||
}
|
||||
} catch (e) {
|
||||
const err = e as ApiError;
|
||||
if (err.status === 401 || err.code === "platform_token_invalid") {
|
||||
writeToken("");
|
||||
error.value = "平台登录已失效——请重新输入密码";
|
||||
} else {
|
||||
error.value = err.message || "拉取失败";
|
||||
}
|
||||
items.value = [];
|
||||
password.value = "";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onLogin() {
|
||||
if (!username.value.trim() || !password.value || loading.value) return;
|
||||
await loadPage(1);
|
||||
}
|
||||
|
||||
function isRegistered(t: PlatformTopup): boolean {
|
||||
return (props.registeredIds ?? []).includes(t.id);
|
||||
}
|
||||
|
||||
function onUse(t: PlatformTopup) {
|
||||
if (isRegistered(t)) return;
|
||||
const sec = t.complete_time ?? t.create_time ?? Math.floor(Date.now() / 1000);
|
||||
emit("select", {
|
||||
...t,
|
||||
paid_at_iso: new Date(sec * 1000).toISOString(),
|
||||
});
|
||||
emit("update:open", false);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(v) => {
|
||||
if (v) {
|
||||
// 每次打开重拉第一页(有 token 则免再输密码)
|
||||
if (readToken()) void loadPage(1, { allowTokenOnly: true });
|
||||
} else {
|
||||
items.value = [];
|
||||
total.value = 0;
|
||||
page.value = 1;
|
||||
error.value = "";
|
||||
password.value = "";
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
title="从平台选择充值"
|
||||
:footer="null"
|
||||
:width="560"
|
||||
centered
|
||||
:styles="{ body: { maxHeight: '70vh', overflowY: 'auto' } }"
|
||||
destroy-on-hidden
|
||||
@update:open="emit('update:open', $event)"
|
||||
>
|
||||
<template v-if="items.length === 0 && !loading">
|
||||
<p class="top-pick__hint">
|
||||
登录 NewAPI 控制台拉取充值单(传统分页);密码不保存。
|
||||
</p>
|
||||
<a-form layout="vertical" @finish="onLogin">
|
||||
<a-form-item label="账号">
|
||||
<a-input v-model:value="username" autocomplete="username" />
|
||||
</a-form-item>
|
||||
<a-form-item label="密码">
|
||||
<a-input-password
|
||||
v-model:value="password"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
block
|
||||
:loading="loading"
|
||||
:disabled="!username.trim() || !password"
|
||||
>
|
||||
登录并拉取
|
||||
</a-button>
|
||||
</a-form>
|
||||
<a-alert
|
||||
v-if="error"
|
||||
type="warning"
|
||||
show-icon
|
||||
:message="error"
|
||||
style="margin-top: 12px"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="top-pick__list">
|
||||
<div
|
||||
v-for="t in items"
|
||||
:key="t.id"
|
||||
class="top-pick__item"
|
||||
:class="{ 'is-registered': isRegistered(t) }"
|
||||
@click="onUse(t)"
|
||||
>
|
||||
<div class="top-pick__head">
|
||||
<strong>
|
||||
{{ formatDateTime(t.complete_time ?? t.create_time) }}
|
||||
</strong>
|
||||
<span class="top-pick__status">
|
||||
{{ isRegistered(t) ? "已登记" : t.status || "" }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="top-pick__meta">
|
||||
amount {{ t.amount }} · money {{ t.money }}
|
||||
<template v-if="t.trade_no"> · {{ t.trade_no }}</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="top-pick__pager">
|
||||
<a-pagination
|
||||
:current="page"
|
||||
:page-size="PAGE_SIZE"
|
||||
:total="total"
|
||||
size="small"
|
||||
:show-total="(t: number) => `共 ${t} 条`"
|
||||
@change="(p: number) => loadPage(p)"
|
||||
/>
|
||||
</div>
|
||||
<a-alert
|
||||
v-if="error"
|
||||
type="warning"
|
||||
show-icon
|
||||
:message="error"
|
||||
style="margin-top: 8px"
|
||||
/>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.top-pick__hint {
|
||||
margin: 0 0 16px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.top-pick__list {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.top-pick__item {
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.top-pick__item:hover {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.top-pick__item.is-registered {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.top-pick__item.is-registered:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.top-pick__item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.top-pick__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.top-pick__status {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.top-pick__meta {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.top-pick__pager {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user