diff --git a/apps/web/src/api/admin/payments.ts b/apps/web/src/api/admin/payments.ts deleted file mode 100644 index 513cd95..0000000 --- a/apps/web/src/api/admin/payments.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { PaymentItem } from "@new-api-key-usage/core"; -import { adminHttp } from "../http.ts"; - -export type PaymentBody = Record; - -export async function fetchPayments(): Promise { - const { data } = await adminHttp.get("/payments"); - return data; -} - -export async function createPayment(body: PaymentBody): Promise { - const { data } = await adminHttp.post("/payments", body); - return data; -} - -export async function updatePayment( - id: number, - body: PaymentBody, -): Promise { - const { data } = await adminHttp.put(`/payments/${id}`, body); - return data; -} - -export async function deletePayment(id: number): Promise { - await adminHttp.delete(`/payments/${id}`); -} diff --git a/apps/web/src/api/admin/platform.ts b/apps/web/src/api/admin/platform.ts index 20ed4a9..5c24303 100644 --- a/apps/web/src/api/admin/platform.ts +++ b/apps/web/src/api/admin/platform.ts @@ -10,6 +10,18 @@ export type PlatformSubscription = { 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 }; /** 登录控制台(密码不落库);token 由调用方短暂保存 */ @@ -33,3 +45,26 @@ export async function listPlatformSubscriptions( ); 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; +} diff --git a/apps/web/src/api/admin/subscriptions.ts b/apps/web/src/api/admin/subscriptions.ts new file mode 100644 index 0000000..c82780c --- /dev/null +++ b/apps/web/src/api/admin/subscriptions.ts @@ -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 { + const { data } = await adminHttp.get("/subscriptions"); + return data; +} + +export async function createSubscription( + body: SubscriptionBody, +): Promise { + const { data } = await adminHttp.post("/subscriptions", body); + return data; +} + +export async function updateSubscription( + id: number, + body: SubscriptionBody, +): Promise { + const { data } = await adminHttp.put( + `/subscriptions/${id}`, + body, + ); + return data; +} + +export async function deleteSubscription(id: number): Promise { + await adminHttp.delete(`/subscriptions/${id}`); +} diff --git a/apps/web/src/api/admin/topups.ts b/apps/web/src/api/admin/topups.ts new file mode 100644 index 0000000..efa0cdb --- /dev/null +++ b/apps/web/src/api/admin/topups.ts @@ -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 { + const { data } = await adminHttp.get("/topups"); + return data; +} + +export async function createTopup(body: TopupBody): Promise { + const { data } = await adminHttp.post("/topups", body); + return data; +} + +export async function updateTopup( + id: number, + body: TopupBody, +): Promise { + const { data } = await adminHttp.put(`/topups/${id}`, body); + return data; +} + +export async function deleteTopup(id: number): Promise { + await adminHttp.delete(`/topups/${id}`); +} diff --git a/apps/web/src/api/http.ts b/apps/web/src/api/http.ts index 6b0f778..0378b84 100644 --- a/apps/web/src/api/http.ts +++ b/apps/web/src/api/http.ts @@ -77,7 +77,11 @@ function attachInterceptors(client: AxiosInstance, world: AuthWorld): void { (response) => response, (error: AxiosError) => { 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"); } return Promise.reject(apiError); diff --git a/apps/web/src/api/member/me.ts b/apps/web/src/api/member/me.ts index 045c8ea..5523c67 100644 --- a/apps/web/src/api/member/me.ts +++ b/apps/web/src/api/member/me.ts @@ -1,7 +1,8 @@ import type { LogItem, Paginated, - PaymentItem, + SubscriptionItem, + TopupItem, UnixSec, UsageTotals, } from "@new-api-key-usage/core"; @@ -27,7 +28,8 @@ export type MemberOverview = { log_synced_at: UnixSec | null; usage: UsageTotals; }>; - payments: PaymentItem[]; + subscriptions: SubscriptionItem[]; + topups: TopupItem[]; }; export async function fetchMe(): Promise { diff --git a/apps/web/src/api/query-keys.ts b/apps/web/src/api/query-keys.ts index 405e83f..c652196 100644 --- a/apps/web/src/api/query-keys.ts +++ b/apps/web/src/api/query-keys.ts @@ -40,7 +40,8 @@ export const qk = { range.subscription_id ?? "", range.type ?? "", ] as const, - payments: () => ["admin", "payments"] as const, + subscriptions: () => ["admin", "subscriptions"] as const, + topups: () => ["admin", "topups"] as const, qpuLatest: () => ["admin", "qpu-latest"] as const, members: () => ["admin", "members"] as const, auditLogs: (q: PageQuery & { domain?: string; from?: string; to?: string }) => diff --git a/apps/web/src/components/admin/payment-form-modal.vue b/apps/web/src/components/admin/payment-form-modal.vue index c23716f..ee03f9f 100644 --- a/apps/web/src/components/admin/payment-form-modal.vue +++ b/apps/web/src/components/admin/payment-form-modal.vue @@ -1,184 +1,290 @@ @@ -186,117 +292,167 @@ async function onSubmit() { diff --git a/apps/web/src/components/admin/platform-subscription-picker.vue b/apps/web/src/components/admin/platform-subscription-picker.vue index 83740ee..962fc2d 100644 --- a/apps/web/src/components/admin/platform-subscription-picker.vue +++ b/apps/web/src/components/admin/platform-subscription-picker.vue @@ -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)" >
订阅 #{{ s.id }} - {{ s.status }} + + {{ isRegistered(s) ? "已登记" : s.status }} +
{{ 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; } diff --git a/apps/web/src/components/admin/platform-topup-picker.vue b/apps/web/src/components/admin/platform-topup-picker.vue new file mode 100644 index 0000000..e084ce9 --- /dev/null +++ b/apps/web/src/components/admin/platform-topup-picker.vue @@ -0,0 +1,285 @@ + + + + + diff --git a/apps/web/src/queries/admin.ts b/apps/web/src/queries/admin.ts index 41f94a2..5ef80f4 100644 --- a/apps/web/src/queries/admin.ts +++ b/apps/web/src/queries/admin.ts @@ -8,11 +8,19 @@ import { import { fetchOverview } from "@/api/admin/overview.ts"; import { fetchEngine, fetchQpuLatest } from "@/api/admin/engine.ts"; import { - createPayment, - deletePayment, - fetchPayments, - updatePayment, -} from "@/api/admin/payments.ts"; + createSubscription, + deleteSubscription, + fetchSubscriptions, + updateSubscription, + type SubscriptionBody, +} from "@/api/admin/subscriptions.ts"; +import { + createTopup, + deleteTopup, + fetchTopups, + updateTopup, + type TopupBody, +} from "@/api/admin/topups.ts"; import { createMember, fetchMembers, @@ -94,40 +102,80 @@ export function useAdminLogs( }); } -export function usePayments() { +export function useSubscriptions() { return useQuery({ - queryKey: qk.admin.payments(), - queryFn: fetchPayments, + queryKey: qk.admin.subscriptions(), + queryFn: fetchSubscriptions, + staleTime: 0, }); } -export function useCreatePayment() { +export function useCreateSubscription() { const qc = useQueryClient(); return useMutation({ - mutationFn: createPayment, + mutationFn: (body: SubscriptionBody) => createSubscription(body), onSuccess: () => { - void qc.invalidateQueries({ queryKey: qk.admin.payments() }); + void qc.invalidateQueries({ queryKey: qk.admin.subscriptions() }); }, }); } -export function useUpdatePayment() { +export function useUpdateSubscription() { const qc = useQueryClient(); return useMutation({ - mutationFn: ({ id, body }: { id: number; body: Record }) => - updatePayment(id, body), + mutationFn: ({ id, body }: { id: number; body: SubscriptionBody }) => + updateSubscription(id, body), onSuccess: () => { - void qc.invalidateQueries({ queryKey: qk.admin.payments() }); + void qc.invalidateQueries({ queryKey: qk.admin.subscriptions() }); }, }); } -export function useDeletePayment() { +export function useDeleteSubscription() { const qc = useQueryClient(); return useMutation({ - mutationFn: deletePayment, + mutationFn: deleteSubscription, 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() }); }, }); } diff --git a/apps/web/src/views/admin/overview-view.vue b/apps/web/src/views/admin/overview-view.vue index 3c19756..c0c333b 100644 --- a/apps/web/src/views/admin/overview-view.vue +++ b/apps/web/src/views/admin/overview-view.vue @@ -16,7 +16,7 @@ import { type RangeKind, type RangeValue, } 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 { formatInt } from "@/utils/format.ts"; @@ -25,7 +25,7 @@ const { range, setRange } = useLiveTimeRange("recent-30"); const overviewQuery = useAdminOverview(() => range.value); const engineQuery = useEngine(); -const paymentsQuery = usePayments(); +const paymentsQuery = useSubscriptions(); const overview = computed(() => overviewQuery.data.value); const engine = computed(() => engineQuery.data.value); @@ -37,9 +37,9 @@ const loading = computed( const extraPresets = computed(() => { const list = paymentsQuery.data.value ?? []; return list - .filter((p) => p.kind === "subscription" && p.period_start != null) + .filter((p) => p.period_start != null) .map((p) => ({ - label: `订阅 #${p.subscription_id ?? p.id} 期间`, + label: `订阅 #${p.subscription_id} 期间`, from: toRequestIso(new Date((p.period_start ?? 0) * 1000)), to: toRequestIso( new Date((p.period_end ?? Math.floor(Date.now() / 1000)) * 1000), diff --git a/apps/web/src/views/admin/payments-view.vue b/apps/web/src/views/admin/payments-view.vue index 5130921..b90b478 100644 --- a/apps/web/src/views/admin/payments-view.vue +++ b/apps/web/src/views/admin/payments-view.vue @@ -1,136 +1,162 @@ @@ -77,52 +88,74 @@ const columns = [ :paragraph="{ rows: 4 }" /> -