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 !=
selectUser(item.user?.id)">
-
+
{{ item.user?.displayName || item.user?.username || item.user?.id }}{{ item.user?.email || item.user?.username }} · {{ item.planId || 'default' }}{{ item.user?.role }} / {{ item.user?.status }}{{ formatNumber(item.credits?.available) }} cr