diff --git a/web/hwlab-cloud-web/src/composables/useConsoleViewState.test.ts b/web/hwlab-cloud-web/src/composables/useConsoleViewState.test.ts index cee6277c..0f61bb13 100644 --- a/web/hwlab-cloud-web/src/composables/useConsoleViewState.test.ts +++ b/web/hwlab-cloud-web/src/composables/useConsoleViewState.test.ts @@ -4,7 +4,7 @@ // Responsibility: 验证集合视图 query 的恢复、清理与 cursor 重置行为。 import { mount } from "@vue/test-utils"; -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { createMemoryHistory, createRouter } from "vue-router"; import { defineComponent, nextTick } from "vue"; import { useConsoleViewState, type ConsoleViewState } from "./useConsoleViewState"; @@ -55,4 +55,33 @@ describe("useConsoleViewState", () => { expect(state.density.value).toBe("comfortable"); wrapper.unmount(); }); + + test("supports page-specific view modes and avoids duplicate history entries", async () => { + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: "/resources", component: { template: "
" } }] + }); + await router.push("/resources?view=list"); + await router.isReady(); + + let state!: ConsoleViewState<"matrix" | "list">; + const Host = defineComponent({ + setup() { + state = useConsoleViewState({ view: "matrix", viewValues: ["matrix", "list"] }); + return {}; + }, + template: "
" + }); + const wrapper = mount(Host, { global: { plugins: [router] } }); + expect(state.view.value).toBe("list"); + + const replace = vi.spyOn(router, "replace"); + await state.update({ view: "list" }); + expect(replace).not.toHaveBeenCalled(); + + await state.update({ view: undefined }); + expect(router.currentRoute.value.query.view).toBeUndefined(); + expect(state.view.value).toBe("matrix"); + wrapper.unmount(); + }); }); diff --git a/web/hwlab-cloud-web/src/composables/useConsoleViewState.ts b/web/hwlab-cloud-web/src/composables/useConsoleViewState.ts index ebf52cda..efe6e2ee 100644 --- a/web/hwlab-cloud-web/src/composables/useConsoleViewState.ts +++ b/web/hwlab-cloud-web/src/composables/useConsoleViewState.ts @@ -56,6 +56,7 @@ export function useConsoleViewState(null); const pending = ref(null); -const selectedCodeId = ref(typeof route.query.codeId === "string" ? route.query.codeId : null); +const selectedCodeId = ref(typeof route.query.selectedCodeId === "string" ? route.query.selectedCodeId : null); 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 filters = reactive({ status: typeof route.query.filter === "string" ? route.query.filter : "", userId: typeof route.query.userId === "string" ? route.query.userId : "", codeId: typeof route.query.codeId === "string" ? route.query.codeId : "" }); 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) }); @@ -42,12 +42,12 @@ 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].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; }); +watch(() => [route.query.filter, route.query.userId, 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.codeId === "string" ? route.query.codeId : ""; void refreshAll(); }); +watch(() => route.query.selectedCodeId, (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 } }); } +async function persistFilters(): Promise { await router.push({ query: { ...route.query, filter: filters.status || undefined, userId: filters.userId.trim() || undefined, codeId: filters.codeId.trim() || undefined } }); } +async function selectCode(code: RedeemCodeSummary): Promise { const codeId = codeKey(code); selectedCodeId.value = codeId; await router.push({ query: { ...route.query, selectedCodeId: codeId } }); } +async function closeInspector(): Promise { selectedCodeId.value = null; await router.push({ query: { ...route.query, selectedCodeId: undefined } }); } async function setStatus(code: RedeemCodeSummary): Promise { if (!code.id) return; pending.value = `status:${code.id}`; const response = await billingAPI.updateAdminRedeemCode(code.id, { status: code.status === "disabled" ? "active" : "disabled" }); pending.value = null; if (!response.ok) error.value = response.error ?? `HTTP ${response.status}`; else await refreshAll(); } async function createCode(): Promise { pending.value = "create"; const body: Record = { ...form, creditAmount: Number(form.creditAmount), maxRedemptions: Number(form.maxRedemptions), perUserLimit: Number(form.perUserLimit) }; if (form.expiresAt) body.expiresAt = new Date(form.expiresAt).toISOString(); else delete body.expiresAt; const response = await billingAPI.createAdminRedeemCode(body); pending.value = null; if (!response.ok) { error.value = response.error ?? `HTTP ${response.status}`; return; } createdCode.value = response.data?.redeemCode ?? null; await refreshAll(); } function codeKey(item: RedeemCodeSummary): string { return item.id || item.codePrefix || "redeem-code"; } diff --git a/web/hwlab-cloud-web/src/views/admin/UsersView.vue b/web/hwlab-cloud-web/src/views/admin/UsersView.vue index 95745f03..6dbcc0be 100644 --- a/web/hwlab-cloud-web/src/views/admin/UsersView.vue +++ b/web/hwlab-cloud-web/src/views/admin/UsersView.vue @@ -152,7 +152,7 @@ function metadataPreview(value: unknown): string { if (!value || typeof value !=