diff --git a/web/hwlab-cloud-web/src/views/admin/BillingView.vue b/web/hwlab-cloud-web/src/views/admin/BillingView.vue index e06afaeb..93595a09 100644 --- a/web/hwlab-cloud-web/src/views/admin/BillingView.vue +++ b/web/hwlab-cloud-web/src/views/admin/BillingView.vue @@ -32,6 +32,7 @@ const createOpen = ref(false); const createdCode = ref(null); const filters = reactive({ status: typeof route.query.filter === "string" ? route.query.filter : "", userId: typeof route.query.userId === "string" ? route.query.userId : "", codeId: typeof route.query.redemptionCodeId === "string" ? route.query.redemptionCodeId : "" }); const form = reactive({ displayName: "", creditAmount: 10, reason: "redeem", maxRedemptions: 1, perUserLimit: 1, expiresAt: "" }); +let refreshSequence = 0; const activeTab = computed({ get: () => viewState.tab.value === "redemptions" ? "redemptions" : "codes", set: (value: string) => void viewState.update({ tab: value === "codes" ? undefined : "redemptions" }, false) }); const viewMode = computed({ get: () => viewState.view.value, set: (value: "summary" | "detail") => void viewState.update({ view: value === "summary" ? undefined : value }, false) }); const selectedCode = computed(() => selectedCodeId.value ? codes.value.find((item) => codeKey(item) === selectedCodeId.value) ?? null : null); @@ -41,8 +42,9 @@ const codeColumns: DataGridColumn[] = [{ key: "code", label: "Code" }, { key: "s const redemptionColumns: DataGridColumn[] = [{ key: "time", label: "Time" }, { key: "user", label: "User" }, { key: "code", label: "Code" }, { key: "credits", label: "Credits", align: "right" }, { key: "status", label: "Status" }, { key: "ledger", label: "Ledger" }]; onMounted(refreshAll); -watch(() => [route.query.filter, route.query.userId, route.query.redemptionCodeId, route.query.codeId].join("\0"), () => { filters.status = typeof route.query.filter === "string" ? route.query.filter : ""; filters.userId = typeof route.query.userId === "string" ? route.query.userId : ""; filters.codeId = typeof route.query.redemptionCodeId === "string" ? route.query.redemptionCodeId : ""; selectedCodeId.value = typeof route.query.codeId === "string" ? route.query.codeId : null; void refreshAll(); }); -async function refreshAll(): Promise { loading.value = true; error.value = null; const [codeResult, redemptionResult] = await Promise.all([billingAPI.adminRedeemCodes({ status: filters.status || undefined, limit: 100 }), billingAPI.adminRedeemRedemptions({ userId: filters.userId || undefined, codeId: filters.codeId || undefined, limit: 100 })]); const errors: string[] = []; if (codeResult.ok) codes.value = codeResult.data?.codes ?? []; else errors.push(`Codes: ${codeResult.error ?? `HTTP ${codeResult.status}`}`); if (redemptionResult.ok) redemptions.value = redemptionResult.data?.redemptions ?? []; else errors.push(`Redemptions: ${redemptionResult.error ?? `HTTP ${redemptionResult.status}`}`); error.value = errors.join(" · ") || null; loading.value = false; loaded.value = true; } +watch(() => [route.query.filter, route.query.userId, route.query.redemptionCodeId].join("\0"), () => { filters.status = typeof route.query.filter === "string" ? route.query.filter : ""; filters.userId = typeof route.query.userId === "string" ? route.query.userId : ""; filters.codeId = typeof route.query.redemptionCodeId === "string" ? route.query.redemptionCodeId : ""; void refreshAll(); }); +watch(() => route.query.codeId, (value) => { selectedCodeId.value = typeof value === "string" ? value : null; }); +async function refreshAll(): Promise { const requestSequence = ++refreshSequence; loading.value = true; error.value = null; const [codeResult, redemptionResult] = await Promise.all([billingAPI.adminRedeemCodes({ status: filters.status || undefined, limit: 100 }), billingAPI.adminRedeemRedemptions({ userId: filters.userId || undefined, codeId: filters.codeId || undefined, limit: 100 })]); if (requestSequence !== refreshSequence) return; const errors: string[] = []; if (codeResult.ok) codes.value = codeResult.data?.codes ?? []; else errors.push(`Codes: ${codeResult.error ?? `HTTP ${codeResult.status}`}`); if (redemptionResult.ok) redemptions.value = redemptionResult.data?.redemptions ?? []; else errors.push(`Redemptions: ${redemptionResult.error ?? `HTTP ${redemptionResult.status}`}`); error.value = errors.join(" · ") || null; loading.value = false; loaded.value = true; } async function persistFilters(): Promise { await router.push({ query: { ...route.query, filter: filters.status || undefined, userId: filters.userId.trim() || undefined, redemptionCodeId: filters.codeId.trim() || undefined } }); } async function selectCode(code: RedeemCodeSummary): Promise { const codeId = codeKey(code); selectedCodeId.value = codeId; await router.push({ query: { ...route.query, codeId } }); } async function closeInspector(): Promise { selectedCodeId.value = null; await router.push({ query: { ...route.query, codeId: undefined } }); } diff --git a/web/hwlab-cloud-web/src/views/admin/SecretsView.vue b/web/hwlab-cloud-web/src/views/admin/SecretsView.vue index d0791b53..db9319d0 100644 --- a/web/hwlab-cloud-web/src/views/admin/SecretsView.vue +++ b/web/hwlab-cloud-web/src/views/admin/SecretsView.vue @@ -28,6 +28,7 @@ const rows = ref([]); const planPanelOpen = ref(false); const route = useRoute(); const router = useRouter(); +let reloadSequence = 0; const viewState = useConsoleViewState<"flow" | "list">({ view: "flow", viewValues: ["flow", "list"], filter: "all" }); const canPlan = computed(() => summary.value?.permissions.plan === true); const query = computed({ get: () => viewState.q.value, set: (value: string) => void viewState.update({ q: value.trim() }) }); @@ -59,9 +60,11 @@ const factSteps = computed(() => selected.value ? [ onMounted(() => void reload()); async function reload(): Promise { + const requestSequence = ++reloadSequence; refreshing.value = true; error.value = null; apiError.value = null; diagnostic.value = null; const [summaryResult, listResult] = await Promise.all([adminSecretsAPI.summary(), adminSecretsAPI.externalSecrets()]); + if (requestSequence !== reloadSequence) return; if (!summaryResult.ok) { error.value = summaryResult.error ?? `HTTP ${summaryResult.status}`; apiError.value = summaryResult.apiError ?? null; diagnostic.value = summaryResult.diagnostic ?? null; } else { summary.value = normalizeAdminSecretsSummary(summaryResult.data); diff --git a/web/hwlab-cloud-web/src/views/admin/UsersView.vue b/web/hwlab-cloud-web/src/views/admin/UsersView.vue index 0328c002..95745f03 100644 --- a/web/hwlab-cloud-web/src/views/admin/UsersView.vue +++ b/web/hwlab-cloud-web/src/views/admin/UsersView.vue @@ -51,6 +51,9 @@ const auditFilters = reactive({ search: textQuery("auditSearch"), userId: textQu const createForm = reactive({ email: "", username: "", displayName: "", password: "", role: "user", status: "active", initialCredits: 0 }); const editForm = reactive({ email: "", username: "", displayName: "", password: "", role: "user", status: "active", planId: "default" }); const creditForm = reactive({ deltaCredits: 0, reason: "admin_adjust", idempotencyKey: "" }); +let usersRequestSequence = 0; +let detailRequestSequence = 0; +let auditRequestSequence = 0; const selectedSummary = computed(() => detail.value?.summary ?? users.value.find((item) => item.user?.id === selectedUserId.value) ?? null); const selectedUser = computed(() => selectedSummary.value?.user ?? null); @@ -74,7 +77,7 @@ onMounted(async () => { watch(() => route.params.userId, (value) => { const userId = typeof value === "string" ? value : ""; if (userId && userId !== selectedUserId.value) void selectUser(userId, false); - if (!userId) { selectedUserId.value = null; detail.value = null; } + if (!userId) { detailRequestSequence += 1; selectedUserId.value = null; detail.value = null; detailLoading.value = false; } }); watch(() => route.query, () => { @@ -91,9 +94,12 @@ const auditQueryMap = { search: "auditSearch", userId: "auditUser", serviceId: " function textQuery(key: string): string { return typeof route.query[key] === "string" ? String(route.query[key]) : ""; } async function loadPlans(): Promise { const response = await usageAPI.adminPlans(); if (response.ok) plans.value = response.data?.plans ?? []; } async function loadUsers(updateRoute = true): Promise { + const requestSequence = ++usersRequestSequence; if (updateRoute) await syncUserQuery(); + if (requestSequence !== usersRequestSequence) return; loading.value = true; const response = await usageAPI.adminUsers({ page: page.value, pageSize, search: filters.search.trim(), status: filters.status, role: filters.role }); + if (requestSequence !== usersRequestSequence) return; if (!response.ok) error.value = response.error ?? `HTTP ${response.status}`; else { users.value = response.data?.users ?? []; total.value = Number(response.data?.total ?? response.data?.count ?? users.value.length); stateAuthority.value = String(response.data?.state?.stateAuthority ?? "pk01-postgres"); error.value = null; } loading.value = false; loaded.value = true; @@ -103,21 +109,25 @@ async function setPage(value: number): Promise { page.value = value; await async function syncUserQuery(): Promise { const query = { ...route.query, q: filters.search.trim() || undefined, filter: filters.status || undefined, role: filters.role || undefined, cursor: page.value > 1 ? String(page.value) : undefined }; await router.push({ query }); } async function selectUser(userId: string | undefined, updateRoute = true): Promise { if (!userId) return; + const requestSequence = ++detailRequestSequence; + selectedUserId.value = userId; if (updateRoute && route.params.userId !== userId) await router.push({ name: "UserDetail", params: { userId }, query: route.query }); - selectedUserId.value = userId; detailLoading.value = true; mutationError.value = null; + if (requestSequence !== detailRequestSequence || selectedUserId.value !== userId) return; + detailLoading.value = true; mutationError.value = null; const response = await usageAPI.adminUser(userId); + if (requestSequence !== detailRequestSequence || selectedUserId.value !== userId) return; if (!response.ok) { mutationError.value = response.error ?? `HTTP ${response.status}`; detail.value = null; } else { detail.value = response.data?.detail ?? null; fillEditForm(detail.value?.summary?.user ?? users.value.find((item) => item.user?.id === userId)?.user); } detailLoading.value = false; } -function closeInspector(): void { selectedUserId.value = null; detail.value = null; void router.push({ name: "Users", query: route.query }); } +function closeInspector(): void { detailRequestSequence += 1; selectedUserId.value = null; detail.value = null; detailLoading.value = false; void router.push({ name: "Users", query: route.query }); } function fillEditForm(user: (AuthUser & { email?: string }) | null | undefined): void { editForm.email = user?.email ?? ""; editForm.username = user?.username ?? ""; editForm.displayName = user?.displayName ?? ""; editForm.password = ""; editForm.role = user?.role === "admin" ? "admin" : "user"; editForm.status = String(user?.status ?? "active"); editForm.planId = selectedSummary.value?.planId ?? "default"; } async function createUser(): Promise { pending.value = "create"; mutationError.value = null; const response = await usageAPI.createAdminUser({ ...createForm, initialCredits: Number(createForm.initialCredits || 0) }); pending.value = null; if (!response.ok) { mutationError.value = response.error ?? `HTTP ${response.status}`; return; } Object.assign(createForm, { email: "", username: "", displayName: "", password: "", role: "user", status: "active", initialCredits: 0 }); createOpen.value = false; await loadUsers(false); if (response.data?.user?.id) await selectUser(response.data.user.id); } async function saveUser(): Promise { const userId = selectedUser.value?.id; if (!userId) return; pending.value = `save:${userId}`; mutationError.value = null; const body: Record = { email: editForm.email.trim(), username: editForm.username.trim(), displayName: editForm.displayName.trim(), role: editForm.role, status: editForm.status, planId: editForm.planId || "default" }; if (editForm.password) body.password = editForm.password; const response = await usageAPI.updateAdminUser(userId, body); pending.value = null; if (!response.ok) mutationError.value = response.error ?? `HTTP ${response.status}`; else { await loadUsers(false); await selectUser(userId, false); } } async function setUserStatus(item: AdminBillingUserSummary): Promise { const userId = item.user?.id; if (!userId) return; const status: AccessUserStatus = item.user?.status === "disabled" ? "active" : "disabled"; pending.value = `status:${userId}`; mutationError.value = null; const response = await usageAPI.updateAdminUserStatus(userId, status); pending.value = null; if (!response.ok) mutationError.value = response.error ?? `HTTP ${response.status}`; else { await loadUsers(false); if (selectedUserId.value === userId) await selectUser(userId, false); } } async function adjustCredits(): Promise { const userId = selectedUser.value?.id; if (!userId) return; pending.value = `credits:${userId}`; mutationError.value = null; const response = await usageAPI.adjustAdminCredits(userId, { deltaCredits: Number(creditForm.deltaCredits), reason: creditForm.reason.trim() || "admin_adjust", idempotencyKey: creditForm.idempotencyKey.trim() || undefined }); pending.value = null; if (!response.ok) mutationError.value = response.error ?? `HTTP ${response.status}`; else { creditForm.deltaCredits = 0; creditForm.idempotencyKey = ""; await Promise.all([loadUsers(false), selectUser(userId, false), loadAudit(false)]); } } function auditParams(extra: Record = {}): Record { const params: Record = { page: auditPage.value, pageSize: auditPageSize, ...extra }; for (const [key, value] of Object.entries(auditFilters)) if (String(value).trim()) params[key] = String(value).trim(); if (auditTab.value === "usage") { delete params.kind; delete params.source; } else { delete params.serviceId; delete params.resourceType; delete params.apiKeyId; } return params; } -async function loadAudit(updateRoute = true): Promise { if (updateRoute) await syncAuditQuery(); auditLoading.value = true; auditError.value = null; const response = auditTab.value === "usage" ? await usageAPI.adminUsage(auditParams()) : await usageAPI.adminLedger(auditParams()); auditLoading.value = false; if (!response.ok) { auditError.value = response.error ?? `HTTP ${response.status}`; return; } auditTotal.value = Number(response.data?.total ?? response.data?.count ?? 0); if (auditTab.value === "usage") auditUsageRows.value = response.data?.rows ?? []; else auditLedgerRows.value = response.data?.rows ?? []; } +async function loadAudit(updateRoute = true): Promise { const requestSequence = ++auditRequestSequence; if (updateRoute) await syncAuditQuery(); if (requestSequence !== auditRequestSequence) return; auditLoading.value = true; auditError.value = null; const response = auditTab.value === "usage" ? await usageAPI.adminUsage(auditParams()) : await usageAPI.adminLedger(auditParams()); if (requestSequence !== auditRequestSequence) return; auditLoading.value = false; if (!response.ok) { auditError.value = response.error ?? `HTTP ${response.status}`; return; } auditTotal.value = Number(response.data?.total ?? response.data?.count ?? 0); if (auditTab.value === "usage") auditUsageRows.value = response.data?.rows ?? []; else auditLedgerRows.value = response.data?.rows ?? []; } async function syncAuditQuery(): Promise { const query: LocationQueryRaw = { ...route.query, audit: auditTab.value === "ledger" ? "ledger" : undefined, auditPage: auditPage.value > 1 ? String(auditPage.value) : undefined }; for (const [key, queryKey] of Object.entries(auditQueryMap)) query[queryKey] = auditFilters[key as keyof typeof auditFilters].trim() || undefined; await router.push({ query }); } async function setAuditTab(value: "usage" | "ledger"): Promise { auditTab.value = value; auditPage.value = 1; await loadAudit(); } async function setAuditPage(value: number): Promise { auditPage.value = value; await loadAudit(); }