feat: 账本域 billing_domain 与成员支付可见性收紧

三表增加账本域标签,成员支付参数改为同域透明、跨域隔离、未标域默认藏;管理面登记/筛选/行内编辑与文档中心同步对齐。
This commit is contained in:
2026-10-13 20:42:26 +08:00
parent 843ab4433a
commit b0e9ea750b
31 changed files with 1463 additions and 53 deletions
+24 -1
View File
@@ -30,6 +30,7 @@ function toKeyItem(row: ApiKeyRow & { member_name?: string | null }): KeyItem {
status: row.status as "active" | "disabled",
member_id: row.member_id,
member_name: row.member_name ?? null,
billing_domain: row.billing_domain,
registered_at: toUnixSec(row.registered_at)!,
log_synced_at: toUnixSec(row.log_synced_at),
usage_synced_at: toUnixSec(row.usage_synced_at),
@@ -59,6 +60,7 @@ export function adminKeyRoutes() {
query: z.object({
member_id: z.string().optional(),
status: z.enum(["active", "disabled"]).optional(),
billing_domain: z.string().optional(),
recent_failed: z.enum(["true", "false"]).optional(),
}),
},
@@ -77,12 +79,15 @@ export function adminKeyRoutes() {
const filter: {
member_id?: number | "unassigned";
status?: string;
billing_domain?: string | "none";
} = {};
if (q.member_id === "unassigned") filter.member_id = "unassigned";
else if (q.member_id) filter.member_id = Number(q.member_id);
if (q.status === "active" || q.status === "disabled") {
filter.status = q.status;
}
if (q.billing_domain === "none") filter.billing_domain = "none";
else if (q.billing_domain) filter.billing_domain = q.billing_domain;
let rows = await c.get("repos").keys.listAll(filter);
if (q.recent_failed === "true") {
const snap = await c.get("repos").engine.getSnapshot();
@@ -144,6 +149,10 @@ export function adminKeyRoutes() {
}
member_id = n;
}
let billing_domain: string | null = null;
if (typeof body?.billing_domain === "string" && body.billing_domain.trim()) {
billing_domain = body.billing_domain.trim();
}
if (!sk_key || !sk_key.startsWith("sk-")) {
return jsonError(
c,
@@ -208,6 +217,7 @@ export function adminKeyRoutes() {
key_hash,
key_ciphertext,
member_id,
billing_domain,
token_name: usage.name ?? "",
});
// 首次登记即写入上游额度字段(表设计 §4:首轮日志同步异步补)
@@ -241,7 +251,7 @@ export function adminKeyRoutes() {
createRoute({
method: "patch",
path: "/keys/{id}",
summary: "启停 / 归属变更",
summary: "启停 / 归属 / 账本域变更",
description: "部分更新;审计按实际变更字段各落一行",
tags: ["Key 管理"],
security: [{ adminSession: [] }],
@@ -301,6 +311,19 @@ export function adminKeyRoutes() {
});
}
}
if (body && "billing_domain" in body) {
let nextDomain: string | null = null;
if (typeof body.billing_domain === "string" && body.billing_domain.trim()) {
nextDomain = body.billing_domain.trim();
}
if (nextDomain !== before.billing_domain) {
patch.billing_domain = nextDomain;
actions.push({
action: "key.reassign",
summary: `key #${before.id} · 账本域变更`,
});
}
}
if (Object.keys(patch).length === 0) {
return jsonError(
c,
+19 -1
View File
@@ -37,6 +37,7 @@ function toSubscriptionItem(
quota_usd,
paid_amount: row.paid_amount,
paid_at: toUnixSec(row.paid_at),
billing_domain: row.billing_domain,
remark: row.remark,
created_at: toUnixSec(row.created_at)!,
};
@@ -52,6 +53,7 @@ function parseSubBody(body: z.infer<typeof createSubscriptionBodySchema>):
quota_granted: number;
paid_amount: number | null;
paid_at: Date | null;
billing_domain: string | null;
remark: string;
};
} {
@@ -74,6 +76,10 @@ function parseSubBody(body: z.infer<typeof createSubscriptionBodySchema>):
quota_granted: body.quota_granted,
paid_amount: body.paid_amount ?? null,
paid_at: body.paid_at ? new Date(body.paid_at) : null,
billing_domain:
typeof body.billing_domain === "string" && body.billing_domain.trim()
? body.billing_domain.trim()
: null,
remark: body.remark ?? "",
},
};
@@ -90,6 +96,11 @@ export function adminSubscriptionRoutes() {
summary: "订阅权益清单",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
query: z.object({
billing_domain: z.string().optional(),
}),
},
responses: {
200: {
description: "subscription 数组",
@@ -103,7 +114,14 @@ export function adminSubscriptionRoutes() {
async (c) => {
const repos = c.get("repos");
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const rows = await repos.subscriptions.listAll();
const q = c.req.query();
const filter =
q.billing_domain === "none"
? { billing_domain: "none" as const }
: q.billing_domain
? { billing_domain: q.billing_domain }
: undefined;
const rows = await repos.subscriptions.listAll(filter);
return c.json(
rows.map((row) =>
toSubscriptionItem(
+19 -1
View File
@@ -36,6 +36,7 @@ function toTopupItem(row: TopupRow, qpu: number): TopupItem {
paid_at: toUnixSec(row.paid_at)!,
quota_derived,
quota_usd: quotaToUsdStr(quota_derived, qpu),
billing_domain: row.billing_domain,
remark: row.remark,
created_at: toUnixSec(row.created_at)!,
};
@@ -53,6 +54,7 @@ function parseTopupBody(body: z.infer<typeof createTopupBodySchema>):
payment_provider: string | null;
status: string | null;
paid_at: Date;
billing_domain: string | null;
remark: string;
};
} {
@@ -71,6 +73,10 @@ function parseTopupBody(body: z.infer<typeof createTopupBodySchema>):
payment_provider: body.payment_provider ?? null,
status: body.status ?? "success",
paid_at: new Date(body.paid_at),
billing_domain:
typeof body.billing_domain === "string" && body.billing_domain.trim()
? body.billing_domain.trim()
: null,
remark: body.remark ?? "",
},
};
@@ -87,6 +93,11 @@ export function adminTopupRoutes() {
summary: "充值入账清单",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
query: z.object({
billing_domain: z.string().optional(),
}),
},
responses: {
200: {
description: "topup 数组",
@@ -100,7 +111,14 @@ export function adminTopupRoutes() {
async (c) => {
const repos = c.get("repos");
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const rows = await repos.topups.listAll();
const q = c.req.query();
const filter =
q.billing_domain === "none"
? { billing_domain: "none" as const }
: q.billing_domain
? { billing_domain: q.billing_domain }
: undefined;
const rows = await repos.topups.listAll(filter);
return c.json(
rows.map((row) => toTopupItem(row, qpuAt(row.paid_at, timeline))),
200,
+15 -4
View File
@@ -71,7 +71,7 @@ export function memberViewRoutes() {
path: "/overview",
summary: "单页聚合",
description:
"名下 key 的 type=2 用量 + 来源分解 + 支付参数(完全公开透明)",
"名下 key 的 type=2 用量 + 来源分解 + 支付参数(同账本域内可见)",
tags: ["成员视图"],
security: [{ memberToken: [] }],
request: {
@@ -159,8 +159,13 @@ export function memberViewRoutes() {
usage: sumUsage(v.rows, timeline),
}));
// 「该时段涉及」:订阅 = 日志出现过的 subscription_id ∪ 进行中;充值 = paid_at ∈ [from,to)
// 「该时段涉及」:订阅 = 日志出现过的 subscription_id ∪ 我域内进行中;
// 充值 = 我域内且 paid_at ∈ [from,to)。未标域默认藏(仅「涉及我日志」的订阅例外)。
const { quotaToUsdStr, qpuAt } = await import("@new-api-key-usage/core");
const myDomains = new Set<string>();
for (const k of allKeys) {
if (k.billing_domain) myDomains.add(k.billing_domain);
}
const subRows = await repos.subscriptions.listAll();
const usedSubIds = new Set(
rows.map((x) => x.subscription_id).filter((v): v is number => v != null),
@@ -172,8 +177,11 @@ export function memberViewRoutes() {
s.period_end > new Date() ||
(s.period_start <= range.to &&
(s.period_end ?? new Date()) >= range.from);
const involved = usedSubIds.has(s.subscription_id) || ongoing;
if (!involved) continue;
const involved = usedSubIds.has(s.subscription_id);
const inMyDomain =
s.billing_domain != null && myDomains.has(s.billing_domain);
// 涉及我日志的始终可见(事实直连);其余仅我域内进行中可见
if (!involved && !(ongoing && inMyDomain)) continue;
const qpu = qpuAt(s.paid_at ?? s.created_at, timeline);
subscriptionsOut.push({
id: s.id,
@@ -184,6 +192,7 @@ export function memberViewRoutes() {
quota_usd: quotaToUsdStr(s.quota_granted, qpu),
paid_amount: s.paid_amount,
paid_at: toUnixSec(s.paid_at),
billing_domain: s.billing_domain,
remark: s.remark,
created_at: toUnixSec(s.created_at)!,
});
@@ -191,6 +200,7 @@ export function memberViewRoutes() {
const topupRows = await repos.topups.listAll();
const topupsOut = [];
for (const t of topupRows) {
if (t.billing_domain == null || !myDomains.has(t.billing_domain)) continue;
if (!(t.paid_at >= range.from && t.paid_at < range.to)) continue;
const qpu = qpuAt(t.paid_at, timeline);
const quota_derived = Math.round(t.amount * qpu);
@@ -204,6 +214,7 @@ export function memberViewRoutes() {
payment_provider: t.payment_provider,
status: t.status,
paid_at: toUnixSec(t.paid_at)!,
billing_domain: t.billing_domain,
quota_derived,
quota_usd: quotaToUsdStr(quota_derived, qpu),
remark: t.remark,
+7 -1
View File
@@ -4,6 +4,7 @@ import { adminHttp } from "../http.ts";
export type KeyListFilters = {
member_id?: number | "unassigned";
status?: "active" | "disabled";
billing_domain?: string | "none";
recent_failed?: boolean;
};
@@ -15,6 +16,7 @@ export async function fetchKeys(filters: KeyListFilters = {}): Promise<KeyItem[]
export async function registerKey(body: {
sk_key: string;
member_id?: number | null;
billing_domain?: string | null;
}): Promise<KeyItem> {
const { data } = await adminHttp.post<KeyItem>("/keys", body);
return data;
@@ -22,7 +24,11 @@ export async function registerKey(body: {
export async function patchKey(
id: number,
body: { status?: "active" | "disabled"; member_id?: number | null },
body: {
status?: "active" | "disabled";
member_id?: number | null;
billing_domain?: string | null;
},
): Promise<KeyItem> {
const { data } = await adminHttp.patch<KeyItem>(`/keys/${id}`, body);
return data;
+7 -2
View File
@@ -8,11 +8,16 @@ export type SubscriptionBody = {
quota_granted: number;
paid_amount?: number | null;
paid_at?: string | null;
billing_domain?: string | null;
remark?: string;
};
export async function fetchSubscriptions(): Promise<SubscriptionItem[]> {
const { data } = await adminHttp.get<SubscriptionItem[]>("/subscriptions");
export async function fetchSubscriptions(filter?: {
billing_domain?: string | "none";
}): Promise<SubscriptionItem[]> {
const { data } = await adminHttp.get<SubscriptionItem[]>("/subscriptions", {
params: filter,
});
return data;
}
+7 -2
View File
@@ -10,11 +10,16 @@ export type TopupBody = {
payment_provider?: string | null;
status?: string | null;
paid_at: string;
billing_domain?: string | null;
remark?: string;
};
export async function fetchTopups(): Promise<TopupItem[]> {
const { data } = await adminHttp.get<TopupItem[]>("/topups");
export async function fetchTopups(filter?: {
billing_domain?: string | "none";
}): Promise<TopupItem[]> {
const { data } = await adminHttp.get<TopupItem[]>("/topups", {
params: filter,
});
return data;
}
@@ -16,6 +16,7 @@ import type {
PlatformTopup,
} from "@/api/admin/platform.ts";
import {
useBillingDomainOptions,
useCreateSubscription,
useCreateTopup,
useQpuLatest,
@@ -78,6 +79,8 @@ const registeredTopIds = computed(
.filter((v): v is number => v != null),
);
const domainOptions = useBillingDomainOptions();
const subForm = reactive({
subscription_id: null as number | null,
period_start: null as Dayjs | null,
@@ -87,6 +90,7 @@ const subForm = reactive({
quota_usd: null as number | null,
paid_amount: null as number | null,
paid_at: null as Dayjs | null,
billing_domain: "",
remark: "",
});
@@ -98,6 +102,7 @@ const topForm = reactive({
payment_method: "",
payment_provider: "",
paid_at: null as Dayjs | null,
billing_domain: "",
remark: "",
});
@@ -111,6 +116,7 @@ function resetSub() {
quota_usd: null,
paid_amount: null,
paid_at: null,
billing_domain: "",
remark: "",
});
}
@@ -124,6 +130,7 @@ function resetTop() {
payment_method: "",
payment_provider: "",
paid_at: null,
billing_domain: "",
remark: "",
});
}
@@ -147,6 +154,7 @@ watch(
subForm.paid_amount = s.paid_amount;
subForm.paid_at =
s.paid_at != null ? unixToBeijingWall(s.paid_at) : null;
subForm.billing_domain = s.billing_domain ?? "";
subForm.remark = s.remark;
} else {
// 订阅的 paid_at 是「套餐付款时刻」,可空——不默认填今天
@@ -162,6 +170,7 @@ watch(
topForm.payment_method = t.payment_method ?? "";
topForm.payment_provider = t.payment_provider ?? "";
topForm.paid_at = unixToBeijingWall(t.paid_at);
topForm.billing_domain = t.billing_domain ?? "";
topForm.remark = t.remark;
} else {
resetTop();
@@ -239,6 +248,7 @@ async function onSubmit() {
quota_granted: quota,
paid_amount: subForm.paid_amount,
paid_at: iso(subForm.paid_at),
billing_domain: subForm.billing_domain.trim() || null,
remark: subForm.remark,
};
if (props.editingSub) {
@@ -268,6 +278,7 @@ async function onSubmit() {
payment_provider: topForm.payment_provider || null,
status: props.editingTop?.status ?? "success",
paid_at: iso(topForm.paid_at)!,
billing_domain: topForm.billing_domain.trim() || null,
remark: topForm.remark,
};
if (props.editingTop) {
@@ -381,6 +392,13 @@ async function onSubmit() {
社交层付款事实:金额数值、币种由你们约定;未知可留空。登记时刻由系统另记
</div>
</a-form-item>
<a-form-item label="账本域">
<a-auto-complete
v-model:value="subForm.billing_domain"
:options="domainOptions"
allow-clear
/>
</a-form-item>
<a-form-item label="备注">
<a-input v-model:value="subForm.remark" />
</a-form-item>
@@ -432,6 +450,13 @@ async function onSubmit() {
style="width: 120px; margin-left: 8px"
/>
</a-form-item>
<a-form-item label="账本域">
<a-auto-complete
v-model:value="topForm.billing_domain"
:options="domainOptions"
allow-clear
/>
</a-form-item>
<a-form-item label="备注">
<a-input v-model:value="topForm.remark" />
</a-form-item>
@@ -5,7 +5,7 @@
import { computed, reactive, ref, watch } from "vue";
import type { MemberItem } from "@new-api-key-usage/core";
import type { ApiError } from "@/api/http.ts";
import { useCreateMember, useRegisterKey } from "@/queries/admin.ts";
import { useCreateMember, useRegisterKey, useBillingDomainOptions } from "@/queries/admin.ts";
import MemberTokenModal from "@/components/admin/member-token-modal.vue";
const props = defineProps<{
@@ -20,10 +20,12 @@ const emit = defineEmits<{
const register = useRegisterKey();
const createMember = useCreateMember();
const domainOptions = useBillingDomainOptions();
const form = reactive({
sk_key: "",
member_id: null as number | "unassigned" | null,
billing_domain: "",
});
const resultMsg = ref<{ type: "success" | "error" | "warning"; text: string } | null>(
null,
@@ -41,6 +43,7 @@ watch(
if (!v) {
form.sk_key = "";
form.member_id = null;
form.billing_domain = "";
resultMsg.value = null;
showNewMember.value = false;
newMemberName.value = "";
@@ -67,6 +70,7 @@ async function onSubmit() {
form.member_id === "unassigned" || form.member_id == null
? null
: form.member_id,
billing_domain: form.billing_domain.trim() || null,
});
resultMsg.value = {
type: "success",
@@ -170,6 +174,13 @@ async function onCreateMember() {
+ 新建成员
</a-button>
</a-form-item>
<a-form-item label="账本域">
<a-auto-complete
v-model:value="form.billing_domain"
:options="domainOptions"
allow-clear
/>
</a-form-item>
<a-form-item v-if="showNewMember" label="新成员名称">
<a-input
v-model:value="newMemberName"
+30 -3
View File
@@ -1,3 +1,4 @@
import { computed, type ComputedRef } from "vue";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import {
fetchKeys,
@@ -84,7 +85,11 @@ export function usePatchKey() {
body,
}: {
id: number;
body: { status?: "active" | "disabled"; member_id?: number | null };
body: {
status?: "active" | "disabled";
member_id?: number | null;
billing_domain?: string | null;
};
}) => patchKey(id, body),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
@@ -106,7 +111,7 @@ export function useAdminLogs(
export function useSubscriptions() {
return useQuery({
queryKey: qk.admin.subscriptions(),
queryFn: fetchSubscriptions,
queryFn: () => fetchSubscriptions(),
staleTime: 0,
});
}
@@ -145,7 +150,7 @@ export function useDeleteSubscription() {
export function useTopups() {
return useQuery({
queryKey: qk.admin.topups(),
queryFn: fetchTopups,
queryFn: () => fetchTopups(),
staleTime: 0,
});
}
@@ -250,3 +255,25 @@ export function useAuditLogs(
queryFn: () => fetchAuditLogs(params()),
});
}
/** 账本域联想:keys ∪ 订阅 ∪ 充值 已出现过的值(不强制) */
export function useBillingDomainOptions(): ComputedRef<
Array<{ value: string }>
> {
const keysQuery = useKeys(() => ({}));
const subsQuery = useSubscriptions();
const topupsQuery = useTopups();
return computed(() => {
const set = new Set<string>();
for (const k of keysQuery.data.value ?? []) {
if (k.billing_domain) set.add(k.billing_domain);
}
for (const s of subsQuery.data.value ?? []) {
if (s.billing_domain) set.add(s.billing_domain);
}
for (const t of topupsQuery.data.value ?? []) {
if (t.billing_domain) set.add(t.billing_domain);
}
return [...set].sort().map((value) => ({ value }));
});
}
+67 -2
View File
@@ -11,7 +11,7 @@ import type { ApiError } from "@/api/http.ts";
import PageHeader from "@/components/common/page-header.vue";
import TableRefresh from "@/components/common/table-refresh.vue";
import RegisterKeyModal from "@/components/admin/register-key-modal.vue";
import { useEngine, useKeys, useMembers, usePatchKey } from "@/queries/admin.ts";
import { useEngine, useKeys, useMembers, usePatchKey, useBillingDomainOptions } from "@/queries/admin.ts";
import { formatInt } from "@/utils/format.ts";
import { formatMinute, hoursSince } from "@/utils/time.ts";
@@ -23,6 +23,8 @@ const highlightKeyId = ref<number | null>(null);
const filterMember = ref<number | "unassigned" | null>(null);
const filterStatus = ref<"active" | "disabled" | null>(null);
const filterDomain = ref<string | "none" | null>(null);
let domainSaveTimer: ReturnType<typeof setTimeout> | null = null;
const filterRecentFailed = ref(false);
/** URL → 筛选(总览跳入) */
@@ -35,6 +37,10 @@ function syncFromRoute() {
if (q.status === "active" || q.status === "disabled") filterStatus.value = q.status;
else filterStatus.value = null;
if (q.billing_domain === "none") filterDomain.value = "none";
else if (typeof q.billing_domain === "string" && q.billing_domain) {
filterDomain.value = q.billing_domain;
} else filterDomain.value = null;
filterRecentFailed.value = q.recent_failed === "1" || q.recent_failed === "true";
@@ -52,18 +58,21 @@ function pushQuery() {
if (filterMember.value === "unassigned") query.member_id = "unassigned";
else if (filterMember.value != null) query.member_id = String(filterMember.value);
if (filterStatus.value) query.status = filterStatus.value;
if (filterDomain.value === "none") query.billing_domain = "none";
else if (filterDomain.value) query.billing_domain = filterDomain.value;
if (filterRecentFailed.value) query.recent_failed = "1";
if (highlightKeyId.value != null) query.key_id = String(highlightKeyId.value);
void router.replace({ path: "/admin/keys", query });
}
watch([filterMember, filterStatus, filterRecentFailed], () => {
watch([filterMember, filterStatus, filterDomain, filterRecentFailed], () => {
pushQuery();
});
const filters = computed(() => ({
member_id: filterMember.value ?? undefined,
status: filterStatus.value ?? undefined,
billing_domain: filterDomain.value ?? undefined,
recent_failed: filterRecentFailed.value ? true : undefined,
}));
@@ -91,6 +100,7 @@ const hasActiveFilter = computed(
() =>
filterMember.value != null ||
filterStatus.value != null ||
filterDomain.value != null ||
filterRecentFailed.value,
);
@@ -99,6 +109,8 @@ const memberOptions = computed(() => [
...(membersQuery.data.value ?? []).map((m) => ({ value: m.id, label: m.name })),
]);
const domainOptions = useBillingDomainOptions();
function isStalled(logSyncedAt: number | null | undefined): boolean {
const h = hoursSince(logSyncedAt);
return h != null && h > 2;
@@ -122,6 +134,7 @@ function tipTitle(label: string, tip: string) {
const columns = [
{ title: "名称", key: "token_name", width: 160 },
{ title: "归属", key: "member_name", width: 140 },
{ title: "账本域", key: "billing_domain", width: 110 },
{ title: "状态", key: "status", width: 100 },
{
title: tipTitle(
@@ -156,6 +169,36 @@ async function onMemberChange(
}
}
async function onDomainChange(record: KeyItem, value: string) {
const next = value.trim() || null;
if ((record.billing_domain ?? null) === next) return;
if (domainSaveTimer != null) clearTimeout(domainSaveTimer);
const id = record.id;
domainSaveTimer = setTimeout(() => {
domainSaveTimer = null;
void saveDomain(id, next);
}, 400);
}
async function onDomainBlur(record: KeyItem, value: string) {
const next = value.trim() || null;
if (domainSaveTimer != null) {
clearTimeout(domainSaveTimer);
domainSaveTimer = null;
}
if ((record.billing_domain ?? null) === next) return;
await saveDomain(record.id, next);
}
async function saveDomain(id: number, billing_domain: string | null) {
try {
await patchKey.mutateAsync({ id, body: { billing_domain } });
message.success("账本域已更新");
} catch (e) {
message.error((e as ApiError).message || "账本域更新失败");
}
}
async function onToggleStatus(record: KeyItem) {
const next = record.status === "active" ? "disabled" : "active";
try {
@@ -227,6 +270,17 @@ function onRowClass(record: KeyItem) {
placeholder="状态"
allow-clear
/>
<a-select
v-model:value="filterDomain"
:options="[
{ value: null, label: '全部账本域' },
{ value: 'none', label: '未标注' },
...domainOptions,
]"
style="width: 140px"
placeholder="账本域"
allow-clear
/>
<a-checkbox v-model:checked="filterRecentFailed">最近轮次失败</a-checkbox>
</a-space>
</div>
@@ -273,6 +327,17 @@ function onRowClass(record: KeyItem) {
@change="(v: number | 'unassigned') => onMemberChange(record, v)"
/>
</template>
<template v-else-if="column.key === 'billing_domain'">
<a-auto-complete
:value="record.billing_domain ?? ''"
:options="domainOptions"
size="small"
style="width: 120px"
allow-clear
@change="(v: string) => onDomainChange(record, v)"
@blur="(e: FocusEvent) => onDomainBlur(record, (e.target as HTMLInputElement)?.value ?? '')"
/>
</template>
<template v-else-if="column.key === 'status'">
<a-badge
:status="record.status === 'active' ? 'success' : 'default'"
+58 -2
View File
@@ -25,6 +25,36 @@ const route = useRoute();
const router = useRouter();
const subsQuery = useSubscriptions();
const topupsQuery = useTopups();
const filterDomain = ref<string | "none" | null>(null);
const domainOptions = computed(() => {
const set = new Set<string>();
for (const s of subsQuery.data.value ?? []) {
if (s.billing_domain) set.add(s.billing_domain);
}
for (const t of topupsQuery.data.value ?? []) {
if (t.billing_domain) set.add(t.billing_domain);
}
return [...set].sort().map((v) => ({ value: v, label: v }));
});
const filteredSubs = computed(() => {
const list = subsQuery.data.value ?? [];
if (!filterDomain.value) return list;
if (filterDomain.value === "none") {
return list.filter((r) => r.billing_domain == null);
}
return list.filter((r) => r.billing_domain === filterDomain.value);
});
const filteredTops = computed(() => {
const list = topupsQuery.data.value ?? [];
if (!filterDomain.value) return list;
if (filterDomain.value === "none") {
return list.filter((r) => r.billing_domain == null);
}
return list.filter((r) => r.billing_domain === filterDomain.value);
});
const deleteSub = useDeleteSubscription();
const deleteTop = useDeleteTopup();
@@ -50,9 +80,9 @@ syncFromRoute();
watch(() => route.query, () => syncFromRoute());
watch(tab, () => pushTab());
const subRows = computed(() => [...(subsQuery.data.value ?? [])]);
const subRows = computed(() => [...filteredSubs.value]);
const topRows = computed(() => {
const list = [...(topupsQuery.data.value ?? [])];
const list = [...filteredTops.value];
list.sort((a, b) => (b.paid_at ?? 0) - (a.paid_at ?? 0));
return list;
});
@@ -62,6 +92,7 @@ const subColumns = [
{ title: "期间", key: "period", width: 200 },
{ title: "额度", key: "quota", width: 160 },
{ title: "付款金额 / 时间", key: "paid", width: 170 },
{ title: "账本域", key: "billing_domain", width: 110 },
{ title: "备注", key: "remark" },
{ title: "登记", key: "created_at", width: 150 },
{ title: "操作", key: "actions", width: 120 },
@@ -78,6 +109,7 @@ const topColumns = [
{ title: "实付金额", dataIndex: "money", key: "money", width: 100 },
{ title: "交易号 / 渠道", key: "trade", width: 180 },
{ title: "派生额度", key: "quota", width: 150 },
{ title: "账本域", key: "billing_domain", width: 110 },
{ title: "备注", key: "remark" },
{ title: "操作", key: "actions", width: 120 },
];
@@ -198,6 +230,20 @@ watch(
<a-tab-pane key="topups" tab="充值" />
</a-tabs>
<div class="payments-toolbar">
<a-select
v-model:value="filterDomain"
:options="[
{ value: null, label: '全部账本域' },
{ value: 'none', label: '未标注' },
...domainOptions,
]"
style="width: 160px"
placeholder="账本域"
allow-clear
/>
</div>
<a-alert
v-if="tab === 'subs' && missingSubs.length"
type="warning"
@@ -253,6 +299,9 @@ watch(
: "—"
}}
</template>
<template v-else-if="column.key === 'billing_domain'">
{{ record.billing_domain || "—" }}
</template>
<template v-else-if="column.key === 'remark'">
{{ record.remark || "—" }}
</template>
@@ -316,6 +365,9 @@ watch(
<template v-else-if="column.key === 'quota'">
<QuotaText :quota="record.quota_derived" :quota-usd="record.quota_usd" />
</template>
<template v-else-if="column.key === 'billing_domain'">
{{ record.billing_domain || "—" }}
</template>
<template v-else-if="column.key === 'remark'">
{{ record.remark || "—" }}
</template>
@@ -346,6 +398,10 @@ watch(
</template>
<style scoped>
.payments-toolbar {
margin-bottom: 12px;
}
.pay-period {
display: flex;
flex-direction: column;
+1 -1
View File
@@ -439,7 +439,7 @@ watch(
<span>{{ payModalPayment.remark }}</span>
</div>
<div class="member-logs__foot">
支付参数对成员完全公开;币种由成员与作者约定
支付参数在你所属账本域内公开;币种由成员与作者约定
</div>
</div>
<div v-else class="member-logs__note">该订阅无登记信息</div>
+4 -2
View File
@@ -49,6 +49,7 @@ const subColumns = [
{ title: "期间", key: "period", width: 200 },
{ title: "额度", key: "quota", width: 160 },
{ title: "付款金额 / 时间", key: "paid", width: 170 },
{ title: "账本域", dataIndex: "billing_domain", key: "billing_domain", width: 110 },
{ title: "备注", dataIndex: "remark", key: "remark", width: 140, ellipsis: true },
];
@@ -68,6 +69,7 @@ const topColumns = [
},
{ title: "交易号 / 渠道", key: "trade", width: 180 },
{ title: "派生额度", key: "quota", width: 150 },
{ title: "账本域", dataIndex: "billing_domain", key: "billing_domain", width: 110 },
{ title: "备注", dataIndex: "remark", key: "remark", width: 140, ellipsis: true },
];
</script>
@@ -76,7 +78,7 @@ const topColumns = [
<section>
<PageHeader
title="支付参数"
subtitle="订阅与充值的登记参数 · 对成员完全公开"
subtitle="订阅与充值的登记参数 · 同账本域内可见"
>
<template #actions>
<span class="mv-range-label">时间范围</span>
@@ -186,7 +188,7 @@ const topColumns = [
</a-table>
<div class="mv-foot">
支付参数对成员完全公开;币种由成员与作者约定
支付参数在你所属账本域内公开;币种由成员与作者约定
</div>
</template>
</section>