fix(web): 恢复管理页 URL 事实合同

This commit is contained in:
root
2026-07-13 13:09:54 +02:00
parent 49e8a63230
commit d897a1dc8c
4 changed files with 39 additions and 9 deletions
@@ -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: "<div />" } }]
});
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: "<div />"
});
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();
});
});
@@ -56,6 +56,7 @@ export function useConsoleViewState<TView extends string = ConsoleCollectionView
}
if ("q" in patch || "filter" in patch || "sort" in patch) delete query.cursor;
const location = { path: route.path, query, hash: route.hash };
if (router.resolve(location).fullPath === route.fullPath) return;
if (replace) await router.replace(location);
else await router.push(location);
}
@@ -27,10 +27,10 @@ const loading = ref(true);
const loaded = ref(false);
const error = ref<string | null>(null);
const pending = ref<string | null>(null);
const selectedCodeId = ref<string | null>(typeof route.query.codeId === "string" ? route.query.codeId : null);
const selectedCodeId = ref<string | null>(typeof route.query.selectedCodeId === "string" ? route.query.selectedCodeId : null);
const createOpen = ref(false);
const createdCode = ref<string | null>(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<void> { 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<void> { 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<void> { const codeId = codeKey(code); selectedCodeId.value = codeId; await router.push({ query: { ...route.query, codeId } }); }
async function closeInspector(): Promise<void> { selectedCodeId.value = null; await router.push({ query: { ...route.query, codeId: undefined } }); }
async function persistFilters(): Promise<void> { 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<void> { const codeId = codeKey(code); selectedCodeId.value = codeId; await router.push({ query: { ...route.query, selectedCodeId: codeId } }); }
async function closeInspector(): Promise<void> { selectedCodeId.value = null; await router.push({ query: { ...route.query, selectedCodeId: undefined } }); }
async function setStatus(code: RedeemCodeSummary): Promise<void> { 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<void> { pending.value = "create"; const body: Record<string, unknown> = { ...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"; }
@@ -152,7 +152,7 @@ function metadataPreview(value: unknown): string { if (!value || typeof value !=
<OperationsWorkspace label="用户身份运营" :inspector-open="Boolean(selectedUserId)">
<template #index>
<EntityIndex :items="users" :item-key="userKey" :selected-id="selectedUserId" label="用户索引" @select="(item) => selectUser(item.user?.id)">
<template #toolbar><div class="user-index-filters"><input v-model="filters.search" class="input" aria-label="搜索用户" placeholder="用户名、邮箱或 user id" @keyup.enter="applyFilters"><select v-model="filters.status" class="input" aria-label="用户状态" @change="applyFilters"><option value="">全部状态</option><option value="active">active</option><option value="disabled">disabled</option><option value="pending">pending</option></select><select v-model="filters.role" class="input" aria-label="用户角色" @change="applyFilters"><option value="">全部角色</option><option value="user">user</option><option value="admin">admin</option></select></div></template>
<template #toolbar><div class="user-index-filters admin-users-filters"><input v-model="filters.search" class="input" aria-label="搜索用户" placeholder="搜索用户名、邮箱或 user id" @keyup.enter="applyFilters"><select v-model="filters.status" class="input" aria-label="用户状态" @change="applyFilters"><option value="">全部状态</option><option value="active">active</option><option value="disabled">disabled</option><option value="pending">pending</option></select><select v-model="filters.role" class="input" aria-label="用户角色" @change="applyFilters"><option value="">全部角色</option><option value="user">user</option><option value="admin">admin</option></select></div></template>
<template #item="{ item }"><strong>{{ item.user?.displayName || item.user?.username || item.user?.id }}</strong><small>{{ item.user?.email || item.user?.username }} · {{ item.planId || 'default' }}</small><span class="entity-index-meta"><span class="status-pill" :data-status="item.user?.status">{{ item.user?.role }} / {{ item.user?.status }}</span><code>{{ formatNumber(item.credits?.available) }} cr</code></span></template>
<template #footer><Pagination :page="page" :page-size="pageSize" :total="total" @update:page="setPage" /></template>
</EntityIndex>