feat(web): 支付记录双 Tab 与平台选用弹层

订阅/充值分形态表单(秒级时间、金额非负、额度换算预览);
选用弹层已登记灰显、每次打开重拉、关弹窗清密码。
This commit is contained in:
2026-10-09 22:02:36 +08:00
parent 10d8f4c915
commit c03fdc3d67
16 changed files with 1212 additions and 483 deletions
-26
View File
@@ -1,26 +0,0 @@
import type { PaymentItem } from "@new-api-key-usage/core";
import { adminHttp } from "../http.ts";
export type PaymentBody = Record<string, unknown>;
export async function fetchPayments(): Promise<PaymentItem[]> {
const { data } = await adminHttp.get<PaymentItem[]>("/payments");
return data;
}
export async function createPayment(body: PaymentBody): Promise<PaymentItem> {
const { data } = await adminHttp.post<PaymentItem>("/payments", body);
return data;
}
export async function updatePayment(
id: number,
body: PaymentBody,
): Promise<PaymentItem> {
const { data } = await adminHttp.put<PaymentItem>(`/payments/${id}`, body);
return data;
}
export async function deletePayment(id: number): Promise<void> {
await adminHttp.delete(`/payments/${id}`);
}
+35
View File
@@ -10,6 +10,18 @@ export type PlatformSubscription = {
amount_used: number; amount_used: number;
}; };
export type PlatformTopup = {
id: number;
amount: number;
money: number;
trade_no?: string;
payment_method?: string;
payment_provider?: string;
create_time?: number;
complete_time?: number;
status?: string;
};
export type PlatformLoginResult = { access_token: string }; export type PlatformLoginResult = { access_token: string };
/** 登录控制台(密码不落库);token 由调用方短暂保存 */ /** 登录控制台(密码不落库);token 由调用方短暂保存 */
@@ -33,3 +45,26 @@ export async function listPlatformSubscriptions(
); );
return data.subscriptions; return data.subscriptions;
} }
export async function listPlatformTopups(
accessToken: string,
page = 1,
pageSize = 10,
): Promise<{
items: PlatformTopup[];
total: number;
page: number;
page_size: number;
}> {
const { data } = await adminHttp.post<{
items: PlatformTopup[];
total: number;
page: number;
page_size: number;
}>("/platform/topups", {
access_token: accessToken,
page,
page_size: pageSize,
});
return data;
}
+39
View File
@@ -0,0 +1,39 @@
import type { SubscriptionItem } from "@new-api-key-usage/core";
import { adminHttp } from "../http.ts";
export type SubscriptionBody = {
subscription_id: number;
period_start: string;
period_end?: string | null;
quota_granted: number;
paid_amount?: number | null;
paid_at?: string | null;
remark?: string;
};
export async function fetchSubscriptions(): Promise<SubscriptionItem[]> {
const { data } = await adminHttp.get<SubscriptionItem[]>("/subscriptions");
return data;
}
export async function createSubscription(
body: SubscriptionBody,
): Promise<SubscriptionItem> {
const { data } = await adminHttp.post<SubscriptionItem>("/subscriptions", body);
return data;
}
export async function updateSubscription(
id: number,
body: SubscriptionBody,
): Promise<SubscriptionItem> {
const { data } = await adminHttp.put<SubscriptionItem>(
`/subscriptions/${id}`,
body,
);
return data;
}
export async function deleteSubscription(id: number): Promise<void> {
await adminHttp.delete(`/subscriptions/${id}`);
}
+36
View File
@@ -0,0 +1,36 @@
import type { TopupItem } from "@new-api-key-usage/core";
import { adminHttp } from "../http.ts";
export type TopupBody = {
upstream_id?: number | null;
amount: number;
money: number;
trade_no?: string | null;
payment_method?: string | null;
payment_provider?: string | null;
status?: string | null;
paid_at: string;
remark?: string;
};
export async function fetchTopups(): Promise<TopupItem[]> {
const { data } = await adminHttp.get<TopupItem[]>("/topups");
return data;
}
export async function createTopup(body: TopupBody): Promise<TopupItem> {
const { data } = await adminHttp.post<TopupItem>("/topups", body);
return data;
}
export async function updateTopup(
id: number,
body: TopupBody,
): Promise<TopupItem> {
const { data } = await adminHttp.put<TopupItem>(`/topups/${id}`, body);
return data;
}
export async function deleteTopup(id: number): Promise<void> {
await adminHttp.delete(`/topups/${id}`);
}
+5 -1
View File
@@ -77,7 +77,11 @@ function attachInterceptors(client: AxiosInstance, world: AuthWorld): void {
(response) => response, (response) => response,
(error: AxiosError) => { (error: AxiosError) => {
const apiError = toApiError(error, world); const apiError = toApiError(error, world);
if (apiError.status === 401) { // 401 仅表示「本世界凭证失效」;上游平台 token 失效是 422 platform_token_invalid
if (
apiError.status === 401 &&
apiError.code !== "platform_token_invalid"
) {
redirectAuth(world, "expired"); redirectAuth(world, "expired");
} }
return Promise.reject(apiError); return Promise.reject(apiError);
+4 -2
View File
@@ -1,7 +1,8 @@
import type { import type {
LogItem, LogItem,
Paginated, Paginated,
PaymentItem, SubscriptionItem,
TopupItem,
UnixSec, UnixSec,
UsageTotals, UsageTotals,
} from "@new-api-key-usage/core"; } from "@new-api-key-usage/core";
@@ -27,7 +28,8 @@ export type MemberOverview = {
log_synced_at: UnixSec | null; log_synced_at: UnixSec | null;
usage: UsageTotals; usage: UsageTotals;
}>; }>;
payments: PaymentItem[]; subscriptions: SubscriptionItem[];
topups: TopupItem[];
}; };
export async function fetchMe(): Promise<MemberMe> { export async function fetchMe(): Promise<MemberMe> {
+2 -1
View File
@@ -40,7 +40,8 @@ export const qk = {
range.subscription_id ?? "", range.subscription_id ?? "",
range.type ?? "", range.type ?? "",
] as const, ] as const,
payments: () => ["admin", "payments"] as const, subscriptions: () => ["admin", "subscriptions"] as const,
topups: () => ["admin", "topups"] as const,
qpuLatest: () => ["admin", "qpu-latest"] as const, qpuLatest: () => ["admin", "qpu-latest"] as const,
members: () => ["admin", "members"] as const, members: () => ["admin", "members"] as const,
auditLogs: (q: PageQuery & { domain?: string; from?: string; to?: string }) => auditLogs: (q: PageQuery & { domain?: string; from?: string; to?: string }) =>
@@ -1,184 +1,290 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 支付登记/编辑:订阅与充值同构;额度双输入(落库恒 quota 原值) * 支付登记弹窗:订阅 / 充值分形态(拆表后)
* 时间控件统一精确到秒(show-time)
*/ */
import { computed, reactive, ref, watch } from "vue";
import type { Dayjs } from "dayjs"; import type { Dayjs } from "dayjs";
import 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 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 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<{ const props = defineProps<{
open: boolean; open: boolean;
/** 有则为编辑 */ tab: "subs" | "topups";
editing?: PaymentItem | null; editingSub?: SubscriptionItem | null;
editingTop?: TopupItem | null;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{ (e: "update:open", v: boolean): void }>();
(e: "update:open", v: boolean): void;
}>();
const createPay = useCreatePayment(); const createSub = useCreateSubscription();
const updatePay = useUpdatePayment(); const updateSub = useUpdateSubscription();
const createTop = useCreateTopup();
const updateTop = useUpdateTopup();
const subsQuery = useSubscriptions();
const topupsQuery = useTopups();
const qpuQuery = useQpuLatest(); const qpuQuery = useQpuLatest();
const paymentsQuery = usePayments();
const qpu = computed(() => qpuQuery.data.value?.qpu ?? null); 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 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 isEdit = computed(() => props.editing != null);
const saving = computed(() => createPay.isPending.value || updatePay.isPending.value);
const quotaPreview = computed(() => { const quotaPreview = computed(() => {
if (form.quotaMode === "quota") { if (isSub.value) {
const q = form.quota_granted; if (subForm.quotaMode === "quota") {
const q = subForm.quota_granted;
if (q == null || !qpu.value) return null; if (q == null || !qpu.value) return null;
return `≈ $${(q / qpu.value).toFixed(2)}(按当前换算率)`; return `≈ $${(q / qpu.value).toFixed(2)}(按当前换算率)`;
} }
const u = form.quota_usd_input; const u = subForm.quota_usd;
if (u == null || !qpu.value) return null; if (u == null || !qpu.value) return null;
return `≈ ${Math.round(u * qpu.value)} quota`; 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 quotaGrantedFinal = computed(() => { const pickerOpen = ref(false);
if (form.quotaMode === "quota") return form.quota_granted; const topPickerOpen = ref(false);
if (form.quota_usd_input == null || !qpu.value) return null; const error = ref("");
return Math.round(form.quota_usd_input * qpu.value); 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( watch(
() => props.open, () => props.open,
(v) => { (v) => {
if (!v) return; if (!v) return;
error.value = ""; error.value = "";
const ed = props.editing; isSub.value = props.tab === "subs";
if (ed) { if (props.editingSub) {
form.kind = ed.kind; isSub.value = true;
form.paid_amount = ed.paid_amount; const s = props.editingSub;
// 北京墙钟进控件,保存时 wallAsBeijingIso 可无损回写 subForm.subscription_id = s.subscription_id;
form.paid_at = ed.paid_at != null ? unixToBeijingWall(ed.paid_at) : null; subForm.period_start = unixToBeijingWall(s.period_start);
form.period_start = subForm.period_end =
ed.period_start != null ? unixToBeijingWall(ed.period_start) : null; s.period_end != null ? unixToBeijingWall(s.period_end) : null;
form.period_end = subForm.quota_granted = s.quota_granted;
ed.period_end != null ? unixToBeijingWall(ed.period_end) : null; subForm.quotaMode = "quota";
form.subscription_id = ed.subscription_id; subForm.quota_usd = null;
form.remark = ed.remark; subForm.paid_amount = s.paid_amount;
form.quotaMode = "quota"; subForm.paid_at =
form.quota_granted = ed.quota_granted; s.paid_at != null ? unixToBeijingWall(s.paid_at) : null;
form.quota_usd_input = null; subForm.remark = s.remark;
} else { } else {
form.kind = "subscription"; resetSub();
form.paid_amount = null; subForm.paid_at = dayjs();
form.paid_at = dayjs(); }
form.period_start = null; if (props.editingTop) {
form.period_end = null; isSub.value = false;
form.subscription_id = null; const t = props.editingTop;
form.remark = ""; topForm.upstream_id = t.upstream_id;
form.quotaMode = "quota"; topForm.amount = t.amount;
form.quota_granted = null; topForm.money = t.money;
form.quota_usd_input = null; 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 { function onPickSub(s: PlatformSubscription) {
return d ? wallAsBeijingIso(d) : null; 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() { async function onSubmit() {
error.value = ""; error.value = "";
if (form.paid_amount == null) { try {
error.value = "请填写金额"; if (isSub.value) {
if (!subForm.period_start) {
error.value = "请填写期间起点";
return; return;
} }
if (!form.paid_at) { if (subForm.subscription_id == null) {
error.value = "请填写支付时间"; error.value = "请填写订阅编号";
return; return;
} }
const quota = quotaGrantedFinal.value; 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) { if (quota == null) {
error.value = "请填写额度"; error.value = "请填写套餐总额度";
return; return;
} }
if (form.kind === "subscription") { if (subForm.paid_amount != null && subForm.paid_amount < 0) {
if (!form.period_start) { error.value = "付款金额不能为负数";
error.value = "订阅必须填写期间起点";
return; return;
} }
if (form.subscription_id == null) { const dup = (subsQuery.data.value ?? []).find(
error.value = "订阅必须填写订阅编号";
return;
}
// 一笔订阅只应登记一条支付记录;改错走「编辑」
const dup = (paymentsQuery.data.value ?? []).find(
(p) => (p) =>
p.subscription_id === form.subscription_id && p.subscription_id === subForm.subscription_id &&
p.id !== (props.editing?.id ?? -1), p.id !== props.editingSub?.id,
); );
if (dup) { if (dup) {
error.value = `订阅 #${form.subscription_id} 已有支付登记——请编辑原记录,不要重复登记`; error.value = `订阅 #${subForm.subscription_id} 已有登记——请编辑原记录`;
return; 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 = "请填写计费单位金额(须为正)";
return;
}
if (topForm.money == null || topForm.money < 0) {
error.value = "实付金额不能为负数";
return;
}
if (!topForm.paid_at) {
error.value = "请填写入账时间";
return;
} }
const body = { const body = {
kind: form.kind, upstream_id: topForm.upstream_id,
paid_at: toIso(form.paid_at)!, amount: topForm.amount,
paid_amount: form.paid_amount, money: topForm.money,
remark: form.remark.trim(), trade_no: topForm.trade_no || null,
quota_granted: quota, payment_method: topForm.payment_method || null,
...(form.kind === "subscription" payment_provider: topForm.payment_provider || null,
? { status: props.editingTop?.status ?? "success",
period_start: toIso(form.period_start), paid_at: iso(topForm.paid_at)!,
period_end: toIso(form.period_end), remark: topForm.remark,
subscription_id: form.subscription_id,
}
: {
period_start: null,
period_end: null,
subscription_id: null,
}),
}; };
try { if (props.editingTop) {
if (isEdit.value && props.editing) { await updateTop.mutateAsync({ id: props.editingTop.id, body });
await updatePay.mutateAsync({ id: props.editing.id, body });
} else { } else {
await createPay.mutateAsync(body); await createTop.mutateAsync(body);
} }
}
message.success(props.editingSub || props.editingTop ? "已保存" : "已登记");
emit("update:open", false); emit("update:open", false);
} catch (e) { } 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> </script>
@@ -186,117 +292,167 @@ async function onSubmit() {
<template> <template>
<a-modal <a-modal
:open="open" :open="open"
:title="isEdit ? '编辑支付记录' : '登记支付'" :title="isSub ? (editingSub ? '编辑订阅' : '登记订阅') : editingTop ? '编辑充值' : '登记充值'"
:confirm-loading="saving" :confirm-loading="createSub.isPending.value || createTop.isPending.value || updateSub.isPending.value || updateTop.isPending.value"
ok-text="保存" ok-text="保存"
cancel-text="取消" cancel-text="取消"
:styles="{ body: { maxHeight: '62vh', overflowY: 'auto' } }" width="560"
@update:open="emit('update:open', $event)" centered
:styles="{ body: { maxHeight: '70vh', overflowY: 'auto' } }"
@ok="onSubmit" @ok="onSubmit"
@update:open="emit('update:open', $event)"
> >
<a-form layout="vertical"> <a-radio-group
<a-form-item label="类型" required> v-if="!editingSub && !editingTop"
<a-radio-group v-model:value="form.kind" :disabled="isEdit"> v-model:value="isSub"
<a-radio value="subscription">订阅</a-radio> style="margin-bottom: 16px"
<a-radio value="topup">充值</a-radio> >
<a-radio :value="true">订阅</a-radio>
<a-radio :value="false">充值</a-radio>
</a-radio-group> </a-radio-group>
</a-form-item>
<a-button <a-form v-if="isSub" layout="vertical">
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-form-item label="订阅 ID" required>
<a-space>
<a-input-number <a-input-number
v-model:value="form.subscription_id" v-model:value="subForm.subscription_id"
:min="1" :min="1"
:precision="0" :precision="0"
style="width: 100%" style="width: 160px"
placeholder="钱包页面「订阅 #N」" />
<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>
</template> <a-form-item label="套餐总额度" required>
<a-form-item label="本笔额度" required> <a-radio-group v-model:value="subForm.quotaMode" style="margin-right: 8px">
<a-radio-group v-model:value="form.quotaMode"> <a-radio-button value="quota">quota</a-radio-button>
<a-radio value="quota">原始 quota</a-radio> <a-radio-button value="usd">美元</a-radio-button>
<a-radio value="usd">按美元换算</a-radio>
</a-radio-group> </a-radio-group>
<a-input-number <a-input-number
v-if="form.quotaMode === 'quota'" v-if="subForm.quotaMode === 'quota'"
v-model:value="form.quota_granted" v-model:value="subForm.quota_granted"
:min="0" :min="0"
:precision="0" :precision="0"
style="width: 100%; margin-top: 8px" style="width: 180px"
/> />
<a-input-number <a-input-number
v-else v-else
v-model:value="form.quota_usd_input" v-model:value="subForm.quota_usd"
:min="0" :min="0"
:precision="2" :precision="2"
style="width: 100%; margin-top: 8px" style="width: 180px"
placeholder="美元金额"
/> />
<div v-if="quotaPreview" class="pay-form__note"> <div v-if="quotaPreview" class="form-note">
{{ quotaPreview }} · 落库为原始 quota {{ quotaPreview }} · 落库为原始 quota
</div> </div>
</a-form-item> </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-form-item label="备注">
<a-input v-model:value="form.remark" placeholder="备注(可选)" /> <a-input v-model:value="subForm.remark" />
</a-form-item> </a-form-item>
<a-alert v-if="error" type="error" show-icon :message="error" />
</a-form> </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 <PlatformSubscriptionPicker
v-model:open="pickerOpen" v-model:open="pickerOpen"
@select="onPickSubscription" :registered-ids="registeredSubIds"
@select="onPickSub"
/>
<PlatformTopupPicker
v-model:open="topPickerOpen"
:registered-ids="registeredTopIds"
@select="onPickTop"
/> />
</a-modal> </a-modal>
</template> </template>
<style scoped> <style scoped>
.pay-form__pick { .form-note {
margin-bottom: 16px;
}
.pay-form__note {
margin-top: 4px; margin-top: 4px;
font-size: 12px; font-size: 12px;
line-height: 20px;
color: rgba(0, 0, 0, 0.45); color: rgba(0, 0, 0, 0.45);
} }
</style> </style>
@@ -2,7 +2,7 @@
/** /**
* 订阅选择弹层:登录(仅一次/标签页)+ 列表选用,与登记表单解耦 * 订阅选择弹层:登录(仅一次/标签页)+ 列表选用,与登记表单解耦
*/ */
import { ref } from "vue"; import { computed, ref, watch } from "vue";
import { import {
listPlatformSubscriptions, listPlatformSubscriptions,
loginPlatform, loginPlatform,
@@ -13,6 +13,8 @@ import { formatDateTime } from "@/utils/time.ts";
const props = defineProps<{ const props = defineProps<{
open: boolean; open: boolean;
/** 已登记的 subscription_id——灰显不可选用 */
registeredIds?: number[];
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -45,8 +47,24 @@ function writeToken(token: string) {
} }
} }
async function load() { watch(
if (!username.value.trim() || !password.value || loading.value) return; () => 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 = ""; error.value = "";
loading.value = true; loading.value = true;
try { try {
@@ -58,15 +76,15 @@ async function load() {
}); });
token = login.access_token; token = login.access_token;
writeToken(token); writeToken(token);
password.value = "";
} }
password.value = "";
list.value = await listPlatformSubscriptions(token); list.value = await listPlatformSubscriptions(token);
if (list.value.length === 0) { if (list.value.length === 0) {
error.value = "账号下没有订阅记录"; error.value = "账号下没有订阅记录";
} }
} catch (e) { } catch (e) {
const err = e as ApiError; const err = e as ApiError;
if (err.status === 401) { if (err.status === 401 || err.code === "platform_token_invalid") {
writeToken(""); writeToken("");
error.value = "平台登录已失效——请重新输入密码"; error.value = "平台登录已失效——请重新输入密码";
} else { } else {
@@ -79,7 +97,12 @@ async function load() {
} }
} }
function isRegistered(s: PlatformSubscription): boolean {
return (props.registeredIds ?? []).includes(s.id);
}
function onUse(s: PlatformSubscription) { function onUse(s: PlatformSubscription) {
if (isRegistered(s)) return;
emit("select", s); emit("select", s);
emit("update:open", false); emit("update:open", false);
} }
@@ -139,11 +162,14 @@ function onUse(s: PlatformSubscription) {
v-for="s in list" v-for="s in list"
:key="s.id" :key="s.id"
class="plat-pick__item" class="plat-pick__item"
:class="{ 'is-registered': isRegistered(s) }"
@click="onUse(s)" @click="onUse(s)"
> >
<div class="plat-pick__head"> <div class="plat-pick__head">
<strong>订阅 #{{ s.id }}</strong> <strong>订阅 #{{ s.id }}</strong>
<span class="plat-pick__status">{{ s.status }}</span> <span class="plat-pick__status">
{{ isRegistered(s) ? "已登记" : s.status }}
</span>
</div> </div>
<div class="plat-pick__meta"> <div class="plat-pick__meta">
{{ formatDateTime(s.start_time) }} ~ {{ formatDateTime(s.start_time) }} ~
@@ -181,6 +207,15 @@ function onUse(s: PlatformSubscription) {
background: #fafafa; 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 { .plat-pick__item:last-child {
border-bottom: none; 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>
+66 -18
View File
@@ -8,11 +8,19 @@ import {
import { fetchOverview } from "@/api/admin/overview.ts"; import { fetchOverview } from "@/api/admin/overview.ts";
import { fetchEngine, fetchQpuLatest } from "@/api/admin/engine.ts"; import { fetchEngine, fetchQpuLatest } from "@/api/admin/engine.ts";
import { import {
createPayment, createSubscription,
deletePayment, deleteSubscription,
fetchPayments, fetchSubscriptions,
updatePayment, updateSubscription,
} from "@/api/admin/payments.ts"; type SubscriptionBody,
} from "@/api/admin/subscriptions.ts";
import {
createTopup,
deleteTopup,
fetchTopups,
updateTopup,
type TopupBody,
} from "@/api/admin/topups.ts";
import { import {
createMember, createMember,
fetchMembers, fetchMembers,
@@ -94,40 +102,80 @@ export function useAdminLogs(
}); });
} }
export function usePayments() { export function useSubscriptions() {
return useQuery({ return useQuery({
queryKey: qk.admin.payments(), queryKey: qk.admin.subscriptions(),
queryFn: fetchPayments, queryFn: fetchSubscriptions,
staleTime: 0,
}); });
} }
export function useCreatePayment() { export function useCreateSubscription() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: createPayment, mutationFn: (body: SubscriptionBody) => createSubscription(body),
onSuccess: () => { onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.payments() }); void qc.invalidateQueries({ queryKey: qk.admin.subscriptions() });
}, },
}); });
} }
export function useUpdatePayment() { export function useUpdateSubscription() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ id, body }: { id: number; body: Record<string, unknown> }) => mutationFn: ({ id, body }: { id: number; body: SubscriptionBody }) =>
updatePayment(id, body), updateSubscription(id, body),
onSuccess: () => { onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.payments() }); void qc.invalidateQueries({ queryKey: qk.admin.subscriptions() });
}, },
}); });
} }
export function useDeletePayment() { export function useDeleteSubscription() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: deletePayment, mutationFn: deleteSubscription,
onSuccess: () => { onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.payments() }); void qc.invalidateQueries({ queryKey: qk.admin.subscriptions() });
},
});
}
export function useTopups() {
return useQuery({
queryKey: qk.admin.topups(),
queryFn: fetchTopups,
staleTime: 0,
});
}
export function useCreateTopup() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: TopupBody) => createTopup(body),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.topups() });
},
});
}
export function useUpdateTopup() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, body }: { id: number; body: TopupBody }) =>
updateTopup(id, body),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.topups() });
},
});
}
export function useDeleteTopup() {
const qc = useQueryClient();
return useMutation({
mutationFn: deleteTopup,
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.topups() });
}, },
}); });
} }
+4 -4
View File
@@ -16,7 +16,7 @@ import {
type RangeKind, type RangeKind,
type RangeValue, type RangeValue,
} from "@/composables/use-live-time-range.ts"; } from "@/composables/use-live-time-range.ts";
import { useAdminOverview, useEngine, usePayments } from "@/queries/admin.ts"; import { useAdminOverview, useEngine, useSubscriptions } from "@/queries/admin.ts";
import { formatMinute, toRequestIso } from "@/utils/time.ts"; import { formatMinute, toRequestIso } from "@/utils/time.ts";
import { formatInt } from "@/utils/format.ts"; import { formatInt } from "@/utils/format.ts";
@@ -25,7 +25,7 @@ const { range, setRange } = useLiveTimeRange("recent-30");
const overviewQuery = useAdminOverview(() => range.value); const overviewQuery = useAdminOverview(() => range.value);
const engineQuery = useEngine(); const engineQuery = useEngine();
const paymentsQuery = usePayments(); const paymentsQuery = useSubscriptions();
const overview = computed(() => overviewQuery.data.value); const overview = computed(() => overviewQuery.data.value);
const engine = computed(() => engineQuery.data.value); const engine = computed(() => engineQuery.data.value);
@@ -37,9 +37,9 @@ const loading = computed(
const extraPresets = computed(() => { const extraPresets = computed(() => {
const list = paymentsQuery.data.value ?? []; const list = paymentsQuery.data.value ?? [];
return list return list
.filter((p) => p.kind === "subscription" && p.period_start != null) .filter((p) => p.period_start != null)
.map((p) => ({ .map((p) => ({
label: `订阅 #${p.subscription_id ?? p.id} 期间`, label: `订阅 #${p.subscription_id} 期间`,
from: toRequestIso(new Date((p.period_start ?? 0) * 1000)), from: toRequestIso(new Date((p.period_start ?? 0) * 1000)),
to: toRequestIso( to: toRequestIso(
new Date((p.period_end ?? Math.floor(Date.now() / 1000)) * 1000), new Date((p.period_end ?? Math.floor(Date.now() / 1000)) * 1000),
+193 -127
View File
@@ -1,136 +1,162 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 支付记录(交互设计-支付记录):同构登记 · 编辑删除 · 核对提示 * 支付记录(交互设计-支付记录):订阅 / 充值两 Tab · 核对提示
*/ */
import { computed, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import { message } from "antdv-next"; import { message } from "antdv-next";
import type { PaymentItem } from "@new-api-key-usage/core"; import type { SubscriptionItem, TopupItem } from "@new-api-key-usage/core";
import type { ApiError } from "@/api/http.ts"; import type { ApiError } from "@/api/http.ts";
import { fetchLogs } from "@/api/admin/logs.ts"; import { fetchLogs } from "@/api/admin/logs.ts";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
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 PaymentFormModal from "@/components/admin/payment-form-modal.vue";
import QuotaText from "@/components/common/quota-text.vue"; import QuotaText from "@/components/common/quota-text.vue";
import { useDeletePayment, usePayments } from "@/queries/admin.ts"; import {
useDeleteSubscription,
useDeleteTopup,
useSubscriptions,
useTopups,
} from "@/queries/admin.ts";
import { formatDateTime, toRequestIso } from "@/utils/time.ts"; import { formatDateTime, toRequestIso } from "@/utils/time.ts";
import { formatPaidAmount } from "@/utils/format.ts"; import { formatPaidAmount } from "@/utils/format.ts";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const paymentsQuery = usePayments(); const subsQuery = useSubscriptions();
const deletePay = useDeletePayment(); const topupsQuery = useTopups();
const deleteSub = useDeleteSubscription();
const deleteTop = useDeleteTopup();
const tab = ref<"subs" | "topups">("subs");
const formOpen = ref(false); const formOpen = ref(false);
const editing = ref<PaymentItem | null>(null); const editingSub = ref<SubscriptionItem | null>(null);
const editingTop = ref<TopupItem | null>(null);
const missingSubs = ref<number[]>([]); const missingSubs = ref<number[]>([]);
const checking = ref(false); const checking = ref(false);
const filterKind = ref<"subscription" | "topup" | null>(null); function syncFromRoute() {
tab.value = route.query.tab === "topups" ? "topups" : "subs";
function syncFiltersFromRoute() {
const q = route.query;
filterKind.value =
q.kind === "subscription" || q.kind === "topup" ? q.kind : null;
} }
function pushFilterQuery() { function pushTab() {
const query: Record<string, string> = {}; void router.replace({
if (filterKind.value) query.kind = filterKind.value; path: "/admin/payments",
void router.replace({ path: "/admin/payments", query }); query: tab.value === "topups" ? { tab: "topups" } : {},
});
} }
function onKindChange(v: "subscription" | "topup" | null) { syncFromRoute();
filterKind.value = v; watch(() => route.query, () => syncFromRoute());
pushFilterQuery(); watch(tab, () => pushTab());
}
const rows = computed(() => { const subRows = computed(() => [...(subsQuery.data.value ?? [])]);
let list = [...(paymentsQuery.data.value ?? [])]; const topRows = computed(() => {
if (filterKind.value) { const list = [...(topupsQuery.data.value ?? [])];
list = list.filter((p) => p.kind === filterKind.value);
}
list.sort((a, b) => (b.paid_at ?? 0) - (a.paid_at ?? 0)); list.sort((a, b) => (b.paid_at ?? 0) - (a.paid_at ?? 0));
return list; return list;
}); });
const hasFilter = computed(() => filterKind.value != null); const subColumns = [
syncFiltersFromRoute();
watch(
() => route.query,
() => syncFiltersFromRoute(),
);
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: "订阅 ID", key: "subscription_id", width: 100 },
{ title: "期间", key: "period", width: 200 },
{ title: "额度", key: "quota", width: 160 }, { title: "额度", key: "quota", width: 160 },
{ title: "付款", key: "paid", width: 140 },
{ title: "备注", key: "remark" },
{ title: "登记", key: "created_at", width: 150 },
{ title: "操作", key: "actions", width: 120 },
];
const topColumns = [
{ title: "时间", key: "paid_at", width: 150 },
{ title: "计费单位", key: "amount", width: 100 },
{ title: "实付", key: "money", width: 100 },
{ title: "交易号 / 渠道", key: "trade", width: 180 },
{ title: "派生额度", key: "quota", width: 150 },
{ title: "备注", key: "remark" }, { title: "备注", key: "remark" },
{ title: "操作", key: "actions", width: 120 }, { title: "操作", key: "actions", width: 120 },
]; ];
function timeText(p: PaymentItem): string { function periodText(s: SubscriptionItem): string {
if (p.kind === "topup") return formatDateTime(p.paid_at); const end = s.period_end == null ? "进行中" : formatDateTime(s.period_end);
const start = formatDateTime(p.period_start); return `${formatDateTime(s.period_start)} ~ ${end}`;
const end = p.period_end == null ? "进行中" : formatDateTime(p.period_end);
return `${start} ~ ${end}`;
} }
function onCreate() { function onCreateSub() {
editing.value = null; editingSub.value = null;
editingTop.value = null;
formOpen.value = true; formOpen.value = true;
} }
function onEdit(p: PaymentItem) { function onCreateTop() {
editing.value = p; editingSub.value = null;
editingTop.value = null;
tab.value = "topups";
formOpen.value = true; formOpen.value = true;
} }
async function onDelete(p: PaymentItem) { function onEditSub(s: SubscriptionItem) {
editingSub.value = s;
editingTop.value = null;
formOpen.value = true;
}
function onEditTop(t: TopupItem) {
editingTop.value = t;
editingSub.value = null;
formOpen.value = true;
}
async function onDeleteSub(s: SubscriptionItem) {
try { try {
await deletePay.mutateAsync(p.id); await deleteSub.mutateAsync(s.id);
message.success("已删除"); message.success("已删除");
} catch (e) { } catch (e) {
message.error((e as ApiError).message || "删除失败"); message.error((e as ApiError).message || "删除失败");
} }
} }
/** 观测订阅号 ↔ 已登记:只提示不回填;payments 全空则静默 */ async function onDeleteTop(t: TopupItem) {
async function reconcile(pays: PaymentItem[]) { try {
if (pays.length === 0) { await deleteTop.mutateAsync(t.id);
message.success("已删除");
} catch (e) {
message.error((e as ApiError).message || "删除失败");
}
}
/** 观测订阅号 ↔ 已登记:只提示不回填;双表皆空则静默 */
async function reconcile(subs: SubscriptionItem[]) {
if (subs.length === 0 && topupsQuery.data.value?.length === 0) {
missingSubs.value = [];
return;
}
if (subs.length === 0) {
missingSubs.value = []; missingSubs.value = [];
return; return;
} }
checking.value = true; checking.value = true;
try { try {
const known = new Set( const known = new Set(subs.map((s) => s.subscription_id));
pays.map((p) => p.subscription_id).filter((v): v is number => v != null), const earliest = Math.min(
...subs.map((s) => s.period_start ?? 0),
Date.now() / 1000,
); );
const earliest = Math.min(...pays.map((p) => p.paid_at ?? 0), Date.now() / 1000);
const from = toRequestIso(new Date((earliest - 30 * 86400) * 1000)); const from = toRequestIso(new Date((earliest - 30 * 86400) * 1000));
const to = toRequestIso(new Date()); const to = toRequestIso(new Date());
const observed = new Set<number>(); const page = await fetchLogs({
let page = 1;
for (;;) {
const res = await fetchLogs({
from, from,
to, to,
page, page: 1,
page_size: 200, page_size: 200,
type: 2,
}); });
for (const it of res.items) { const observed = new Set<number>();
for (const it of page.items) {
if (it.type === 2 && it.subscription_id != null) { if (it.type === 2 && it.subscription_id != null) {
observed.add(it.subscription_id); observed.add(it.subscription_id);
} }
} }
if (page * 200 >= res.total || page >= 20) break; missingSubs.value = [...observed].filter((id) => !known.has(id)).sort((a, b) => a - b);
page += 1;
}
missingSubs.value = [...observed]
.filter((id) => !known.has(id))
.sort((a, b) => a - b);
} catch { } catch {
missingSubs.value = []; missingSubs.value = [];
} finally { } finally {
@@ -138,12 +164,11 @@ async function reconcile(pays: PaymentItem[]) {
} }
} }
// 数据到达/失效刷新后再核对(挂载时尚未 resolve)
watch( watch(
() => paymentsQuery.data.value, () => subsQuery.data.value,
(pays) => { (subs) => {
if (!pays) return; if (!subs) return;
void reconcile(pays); void reconcile(subs);
}, },
{ immediate: true }, { immediate: true },
); );
@@ -151,34 +176,26 @@ watch(
<template> <template>
<section> <section>
<PageHeader title="支付记录" subtitle="订阅与充值的人工登记"> <PageHeader title="支付记录" subtitle="订阅权益与充值入账登记">
<template #actions> <template #actions>
<a-button type="primary" @click="onCreate">登记支付</a-button> <a-button v-if="tab === 'subs'" type="primary" @click="onCreateSub">
登记订阅
</a-button>
<a-button v-else type="primary" @click="onCreateTop">登记充值</a-button>
</template> </template>
</PageHeader> </PageHeader>
<div class="payments-filters"> <a-tabs v-model:active-key="tab">
<a-select <a-tab-pane key="subs" tab="订阅" />
:value="filterKind" <a-tab-pane key="topups" tab="充值" />
:options="[ </a-tabs>
{ value: null, label: '全部类型' },
{ value: 'subscription', label: '订阅' },
{ value: 'topup', label: '充值' },
]"
style="width: 120px"
allow-clear
placeholder="类型"
@update:value="onKindChange"
/>
</div>
<a-alert <a-alert
v-if="missingSubs.length" v-if="tab === 'subs' && missingSubs.length"
type="warning" type="warning"
show-icon show-icon
style="margin-bottom: 16px" style="margin-bottom: 16px"
:message="`观测到 ${missingSubs.length} 个订阅有消耗但尚无对应登记`" :message="`观测到 ${missingSubs.length} 个订阅有消耗但尚无对应登记`"
description="若本部署不登记支付,可忽略此提示;需要入账的请核对补录。"
> >
<template #description> <template #description>
<div class="pay-missing__tags"> <div class="pay-missing__tags">
@@ -190,53 +207,51 @@ watch(
</template> </template>
</a-alert> </a-alert>
<template v-if="tab === 'subs'">
<a-empty <a-empty
v-if="!paymentsQuery.isPending.value && rows.length === 0" v-if="!subsQuery.isPending.value && subRows.length === 0"
:description="hasFilter ? '没有符合筛选的支付记录' : '还没有支付记录——订阅/充值同构登记'" description="还没有订阅登记——从平台选用或手工登记"
> >
<a-button v-if="!hasFilter" @click="onCreate">登记支付</a-button> <a-button @click="onCreateSub">登记订阅</a-button>
</a-empty> </a-empty>
<a-table <a-table
v-else v-else
:columns="columns" :columns="subColumns"
:data-source="rows" :data-source="subRows"
:row-key="(r: PaymentItem) => r.id" :row-key="(r: SubscriptionItem) => r.id"
:loading="paymentsQuery.isPending.value || paymentsQuery.isFetching.value || checking" :loading="subsQuery.isPending.value || subsQuery.isFetching.value || checking"
:pagination="false" :pagination="false"
size="middle" size="middle"
:scroll="{ x: 1000 }" :scroll="{ x: 960 }"
> >
<template #bodyCell="{ column, record }"> <template #bodyCell="{ column, record }">
<template v-if="column.key === 'kind'"> <template v-if="column.key === 'period'">
<a-tag :color="record.kind === 'subscription' ? 'blue' : 'gold'"> {{ periodText(record) }}
{{ 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>
<template v-else-if="column.key === 'quota'"> <template v-else-if="column.key === 'quota'">
<QuotaText :quota="record.quota_granted" :quota-usd="record.quota_usd" /> <QuotaText :quota="record.quota_granted" :quota-usd="record.quota_usd" />
</template> </template>
<template v-else-if="column.key === 'paid'">
{{
record.paid_amount != null
? `${formatPaidAmount(record.paid_amount)} · ${formatDateTime(record.paid_at)}`
: "—"
}}
</template>
<template v-else-if="column.key === 'remark'"> <template v-else-if="column.key === 'remark'">
{{ record.remark || "—" }} {{ record.remark || "—" }}
</template> </template>
<template v-else-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'actions'"> <template v-else-if="column.key === 'actions'">
<a-space> <a-space>
<a-button size="small" @click="() => onEdit(record)">编辑</a-button> <a-button size="small" @click="() => onEditSub(record)">编辑</a-button>
<a-popconfirm <a-popconfirm
title="删除后该条支付记录将消失" title="删除后该条将消失"
description="改错即修,删除即消失"
ok-text="删除" ok-text="删除"
cancel-text="取消" cancel-text="取消"
@confirm="() => onDelete(record)" @confirm="() => onDeleteSub(record)"
> >
<a-button size="small" danger>删除</a-button> <a-button size="small" danger>删除</a-button>
</a-popconfirm> </a-popconfirm>
@@ -244,20 +259,71 @@ watch(
</template> </template>
</template> </template>
</a-table> </a-table>
</template>
<PaymentFormModal v-model:open="formOpen" :editing="editing" /> <template v-else>
<a-empty
v-if="!topupsQuery.isPending.value && topRows.length === 0"
description="还没有充值登记"
>
<a-button @click="onCreateTop">登记充值</a-button>
</a-empty>
<a-table
v-else
:columns="topColumns"
:data-source="topRows"
:row-key="(r: TopupItem) => r.id"
:loading="topupsQuery.isPending.value || topupsQuery.isFetching.value"
:pagination="false"
size="middle"
:scroll="{ x: 900 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'paid_at'">
{{ formatDateTime(record.paid_at) }}
</template>
<template v-else-if="column.key === 'money'">
{{ formatPaidAmount(record.money) }}
</template>
<template v-else-if="column.key === 'trade'">
{{ record.trade_no || "—" }}
<span v-if="record.payment_method" class="pay-note">
· {{ record.payment_method }}
</span>
</template>
<template v-else-if="column.key === 'quota'">
<QuotaText :quota="record.quota_derived" :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="() => onEditTop(record)">编辑</a-button>
<a-popconfirm
title="删除后该条将消失"
ok-text="删除"
cancel-text="取消"
@confirm="() => onDeleteTop(record)"
>
<a-button size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</template>
<PaymentFormModal
v-model:open="formOpen"
:tab="tab"
:editing-sub="editingSub"
:editing-top="editingTop"
/>
</section> </section>
</template> </template>
<style scoped> <style scoped>
.payments-filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.pay-missing__tags { .pay-missing__tags {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -265,9 +331,9 @@ watch(
margin-bottom: 4px; margin-bottom: 4px;
} }
.pay-missing__note { .pay-missing__note,
.pay-note {
font-size: 12px; font-size: 12px;
line-height: 20px; color: rgba(0, 0, 0, 0.45);
color: rgba(0, 0, 0, 0.65);
} }
</style> </style>
+7 -6
View File
@@ -4,7 +4,7 @@
* 行展开 = 模型调用详情;来源徽章 = 订阅支付参数弹窗(R12) * 行展开 = 模型调用详情;来源徽章 = 订阅支付参数弹窗(R12)
*/ */
import { computed, h, nextTick, onMounted, onUnmounted, ref, watch } from "vue"; import { computed, h, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import type { LogItem, PaymentItem } from "@new-api-key-usage/core"; import type { LogItem, SubscriptionItem } from "@new-api-key-usage/core";
import { Tooltip } from "antdv-next"; import { Tooltip } from "antdv-next";
import { QuestionCircleOutlined } from "@antdv-next/icons"; import { QuestionCircleOutlined } from "@antdv-next/icons";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
@@ -28,7 +28,9 @@ const page = ref(1);
const pageSize = ref(50); const pageSize = ref(50);
const overviewQuery = useMemberOverview(() => range.value); const overviewQuery = useMemberOverview(() => range.value);
const payments = computed(() => overviewQuery.data.value?.payments ?? []); const payments = computed(
() => overviewQuery.data.value?.subscriptions ?? [],
);
const keys = computed(() => overviewQuery.data.value?.keys ?? []); const keys = computed(() => overviewQuery.data.value?.keys ?? []);
const params = computed(() => ({ const params = computed(() => ({
@@ -51,9 +53,9 @@ const keyOptions = computed(() => [
]); ]);
const paymentBySub = computed(() => { const paymentBySub = computed(() => {
const m = new Map<number, PaymentItem>(); const m = new Map<number, SubscriptionItem>();
for (const p of payments.value) { for (const p of payments.value) {
if (p.subscription_id != null) m.set(p.subscription_id, p); m.set(p.subscription_id, p);
} }
return m; return m;
}); });
@@ -115,8 +117,7 @@ function onBadge(record: LogItem, e: MouseEvent) {
payModalOpen.value = true; payModalOpen.value = true;
} }
function periodText(p: PaymentItem): string { function periodText(p: SubscriptionItem): string {
if (p.kind !== "subscription") return formatDateTime(p.paid_at);
const end = p.period_end == null ? "进行中" : formatDateTime(p.period_end); const end = p.period_end == null ? "进行中" : formatDateTime(p.period_end);
return `${formatDateTime(p.period_start)} ~ ${end}`; return `${formatDateTime(p.period_start)} ~ ${end}`;
} }
+7 -4
View File
@@ -20,15 +20,18 @@ const overviewQuery = useMemberOverview(() => range.value);
const usage = computed(() => overviewQuery.data.value?.usage); const usage = computed(() => overviewQuery.data.value?.usage);
const bySource = computed(() => overviewQuery.data.value?.by_source ?? []); const bySource = computed(() => overviewQuery.data.value?.by_source ?? []);
const keys = computed(() => overviewQuery.data.value?.keys ?? []); const keys = computed(() => overviewQuery.data.value?.keys ?? []);
const payments = computed(() => overviewQuery.data.value?.payments ?? []); const payments = computed(
() => overviewQuery.data.value?.subscriptions ?? [],
);
const extraPresets = computed(() => const extraPresets = computed(() =>
payments.value payments.value
.filter((p) => p.kind === "subscription" && p.period_start != null)
.map((p) => ({ .map((p) => ({
label: `订阅 #${p.subscription_id ?? p.id} 期间`, label: `订阅 #${p.subscription_id} 期间`,
from: new Date((p.period_start ?? 0) * 1000).toISOString(), from: new Date((p.period_start ?? 0) * 1000).toISOString(),
to: new Date((p.period_end ?? Math.floor(Date.now() / 1000)) * 1000).toISOString(), to: new Date(
(p.period_end ?? Math.floor(Date.now() / 1000)) * 1000,
).toISOString(),
})) }))
.slice(0, 4), .slice(0, 4),
); );
+85 -41
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 成员支付参数(交互设计-成员视图 §5):完全公开的登记参数表 * 成员支付参数(交互设计-成员视图 §5):订阅 + 充值清单
*/ */
import { computed } from "vue"; import { computed } from "vue";
import PageHeader from "@/components/common/page-header.vue"; import PageHeader from "@/components/common/page-header.vue";
@@ -13,31 +13,42 @@ import { formatPaidAmount } from "@/utils/format.ts";
const { range, onRangeChange } = useMemberRange(); const { range, onRangeChange } = useMemberRange();
const overviewQuery = useMemberOverview(() => range.value); const overviewQuery = useMemberOverview(() => range.value);
const payments = computed(() => overviewQuery.data.value?.payments ?? []); const subs = computed(() => overviewQuery.data.value?.subscriptions ?? []);
const tops = computed(() => overviewQuery.data.value?.topups ?? []);
const extraPresets = computed(() => const extraPresets = computed(() =>
payments.value subs.value
.filter((p) => p.kind === "subscription" && p.period_start != null)
.map((p) => ({ .map((p) => ({
label: `订阅 #${p.subscription_id ?? p.id} 期间`, label: `订阅 #${p.subscription_id} 期间`,
from: new Date((p.period_start ?? 0) * 1000).toISOString(), from: new Date((p.period_start ?? 0) * 1000).toISOString(),
to: new Date((p.period_end ?? Math.floor(Date.now() / 1000)) * 1000).toISOString(), to: new Date(
(p.period_end ?? Math.floor(Date.now() / 1000)) * 1000,
).toISOString(),
})) }))
.slice(0, 4), .slice(0, 4),
); );
function periodText(p: { kind: string; paid_at: number; period_start: number | null; period_end: number | null }): string { function periodText(p: {
if (p.kind !== "subscription") return "—"; period_start: number;
period_end: number | null;
}): string {
const end = p.period_end == null ? "进行中" : formatDateTime(p.period_end); const end = p.period_end == null ? "进行中" : formatDateTime(p.period_end);
return `${formatDateTime(p.period_start)} ~ ${end}`; return `${formatDateTime(p.period_start)} ~ ${end}`;
} }
const columns = [ const subColumns = [
{ title: "类型", key: "kind", width: 80 }, { title: "订阅 ID", key: "subscription_id", width: 100 },
{ title: "编号 / 时间", key: "id_or_time", width: 160 }, { title: "期间", key: "period", width: 200 },
{ title: "期间", key: "period", width: 220 },
{ title: "额度", key: "quota", width: 160 }, { title: "额度", key: "quota", width: 160 },
{ title: "金额", key: "amount", width: 100 }, { title: "付款", key: "paid", width: 140 },
{ title: "备注", key: "remark" },
];
const topColumns = [
{ title: "时间", key: "paid_at", width: 150 },
{ title: "计费单位 / 实付", key: "money", width: 140 },
{ title: "交易号 / 渠道", key: "trade", width: 180 },
{ title: "派生额度", key: "quota", width: 150 },
{ title: "备注", key: "remark" }, { title: "备注", key: "remark" },
]; ];
</script> </script>
@@ -77,52 +88,74 @@ const columns = [
:paragraph="{ rows: 4 }" :paragraph="{ rows: 4 }"
/> />
<a-empty <a-empty
v-else-if="!overviewQuery.isError.value && payments.length === 0" v-else-if="!overviewQuery.isError.value && subs.length === 0 && tops.length === 0"
description="暂无支付记录信息" description="暂无支付记录信息"
/> />
<template v-else-if="payments.length > 0"> <template v-else>
<h3 class="mv-sec">订阅</h3>
<a-empty v-if="subs.length === 0" description="暂无订阅登记" />
<a-table <a-table
:columns="columns" v-else
:data-source="payments" :columns="subColumns"
:data-source="subs"
:row-key="(r: { id: number }) => r.id" :row-key="(r: { id: number }) => r.id"
size="middle" size="middle"
:pagination="false" :pagination="false"
:scroll="{ x: 860 }" :scroll="{ x: 800 }"
:loading="overviewQuery.isFetching.value"
> >
<template #bodyCell="{ column, record }"> <template #bodyCell="{ column, record }">
<template v-if="column.key === 'kind'"> <template v-if="column.key === 'period'">
<a-tag :color="record.kind === 'subscription' ? 'blue' : 'gold'">
{{ record.kind === "subscription" ? "订阅" : "充值" }}
</a-tag>
</template>
<template v-else-if="column.key === 'id_or_time'">
<template v-if="record.kind === 'subscription'">
订阅 #{{ record.subscription_id ?? record.id }}
</template>
<template v-else>
{{ formatDateTime(record.paid_at) }}
</template>
</template>
<template v-else-if="column.key === 'period'">
{{ periodText(record) }} {{ periodText(record) }}
</template> </template>
<template v-else-if="column.key === 'quota'"> <template v-else-if="column.key === 'quota'">
<QuotaText <QuotaText :quota="record.quota_granted" :quota-usd="record.quota_usd" />
v-if="record.quota_granted != null"
:quota="record.quota_granted"
:quota-usd="record.quota_usd"
/>
<span v-else>—</span>
</template> </template>
<template v-else-if="column.key === 'amount'"> <template v-else-if="column.key === 'paid'">
{{ formatPaidAmount(record.paid_amount) }} {{
record.paid_amount != null
? `${formatPaidAmount(record.paid_amount)} · ${formatDateTime(record.paid_at)}`
: "—"
}}
</template> </template>
<template v-else-if="column.key === 'remark'"> <template v-else-if="column.key === 'remark'">
{{ record.remark || "—" }} {{ record.remark || "—" }}
</template> </template>
</template> </template>
</a-table> </a-table>
<h3 class="mv-sec">充值</h3>
<a-empty v-if="tops.length === 0" description="暂无充值登记" />
<a-table
v-else
:columns="topColumns"
:data-source="tops"
:row-key="(r: { id: number }) => r.id"
size="middle"
:pagination="false"
:scroll="{ x: 760 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'paid_at'">
{{ formatDateTime(record.paid_at) }}
</template>
<template v-else-if="column.key === 'money'">
{{ record.amount }} · {{ formatPaidAmount(record.money) }}
</template>
<template v-else-if="column.key === 'trade'">
{{ record.trade_no || "—" }}
<span v-if="record.payment_method" class="mv-note">
· {{ record.payment_method }}
</span>
</template>
<template v-else-if="column.key === 'quota'">
<QuotaText :quota="record.quota_derived" :quota-usd="record.quota_usd" />
</template>
<template v-else-if="column.key === 'remark'">
{{ record.remark || "—" }}
</template>
</template>
</a-table>
<div class="mv-foot"> <div class="mv-foot">
支付参数对成员完全公开;币种由成员与作者约定 支付参数对成员完全公开;币种由成员与作者约定
</div> </div>
@@ -136,6 +169,17 @@ const columns = [
color: rgba(0, 0, 0, 0.65); color: rgba(0, 0, 0, 0.65);
} }
.mv-sec {
margin: 20px 0 12px;
font-size: 14px;
font-weight: 600;
}
.mv-note {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.mv-foot { .mv-foot {
margin-top: 16px; margin-top: 16px;
font-size: 12px; font-size: 12px;