From b0b1a26ae815a75b879463b9196e9cf7c33be6f1 Mon Sep 17 00:00:00 2001 From: zhx Date: Tue, 30 Jun 2026 20:50:34 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=9D=93=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- databi/src/DatabiApp.jsx | 15 +++- databi/src/DatabiApp.test.jsx | 6 ++ databi/src/api.js | 43 ++++++++-- databi/src/api.test.js | 84 +++++++++++++++++++ databi/src/components/DatabiHeader.test.jsx | 31 +++++++ src/features/app-users/api.test.ts | 36 ++++++++ src/features/app-users/api.ts | 13 ++- .../app-users/pages/AppUserPrettyIdsPage.jsx | 23 +++-- .../pages/AppUserPrettyIdsPage.test.jsx | 4 +- src/shared/ui/AdminUserIdentity.jsx | 43 +++++++--- src/shared/ui/AdminUserIdentity.module.css | 4 + src/shared/ui/AdminUserIdentity.test.jsx | 9 ++ 12 files changed, 274 insertions(+), 37 deletions(-) create mode 100644 databi/src/api.test.js diff --git a/databi/src/DatabiApp.jsx b/databi/src/DatabiApp.jsx index 689f037..f394532 100644 --- a/databi/src/DatabiApp.jsx +++ b/databi/src/DatabiApp.jsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { fetchFilterOptions, fetchSelfGameStatisticsOverview, fetchStatisticsOverview, getCurrentAppCode, setCurrentAppCode } from "./api.js"; +import { fetchFilterOptions, fetchSelfGameStatisticsOverview, fetchStatisticsOverview, getCurrentAppCode, getDefaultAppOptions, setCurrentAppCode } from "./api.js"; import { CountryPerformanceTable } from "./components/CountryPerformanceTable.jsx"; import { DatabiHeader } from "./components/DatabiHeader.jsx"; import { FunnelPanel } from "./components/FunnelPanel.jsx"; @@ -15,7 +15,7 @@ import { createDashboardModel } from "./data/createDashboardModel.js"; import { lastDaysRange, rangeEndMs, rangeStartMs, readStoredTimeZone, sameRange, thisMonthRange, TIME_ZONE_OPTIONS, todayRange, writeStoredTimeZone } from "./utils/time.js"; const initialFilterOptions = { - appOptions: [], + appOptions: getDefaultAppOptions(), countryOptions: [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }], regionOptions: [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }] }; @@ -333,10 +333,17 @@ function BigscreenOverview({ loading, model, onMetricTrend, onOpenLuckyGiftPools function resolveSelectedAppCode(options, current) { const normalized = String(current || "").trim().toLowerCase(); - if (options.some((item) => String(item.code || item.value || "").trim().toLowerCase() === normalized)) { + if (options.some((item) => optionAppCode(item) === normalized)) { return normalized; } - return String(options[0]?.code || options[0]?.value || normalized || "lalu").trim().toLowerCase(); + if (options.length) { + return optionAppCode(options[0]); + } + return normalized || "lalu"; +} + +function optionAppCode(option) { + return String(option?.code ?? option?.value ?? "").trim().toLowerCase(); } function resolveRangePresetValue(range, timeZone) { diff --git a/databi/src/DatabiApp.test.jsx b/databi/src/DatabiApp.test.jsx index b149bc3..40a03db 100644 --- a/databi/src/DatabiApp.test.jsx +++ b/databi/src/DatabiApp.test.jsx @@ -8,6 +8,12 @@ vi.mock("./api.js", () => ({ fetchSelfGameStatisticsOverview: vi.fn(), fetchStatisticsOverview: vi.fn(), getCurrentAppCode: vi.fn(() => "lalu"), + getDefaultAppOptions: vi.fn(() => [ + { code: "", label: "全部", value: "" }, + { code: "lalu", label: "Lalu", value: "lalu" }, + { code: "aslan", label: "Aslan", value: "aslan" }, + { code: "yumi", label: "Yumi", value: "yumi" } + ]), setCurrentAppCode: vi.fn((value) => String(value || "lalu").toLowerCase()) })); diff --git a/databi/src/api.js b/databi/src/api.js index 54c3728..ee0bc9a 100644 --- a/databi/src/api.js +++ b/databi/src/api.js @@ -5,19 +5,31 @@ const API_BASE_URL = import.meta.env?.VITE_API_BASE_URL || "/api"; const TOKEN_KEY = "hyapp-admin.access-token"; const APP_CODE_KEY = "hyapp-admin.app-code"; const APP_CODE_HEADER = "X-App-Code"; +const DEFAULT_APP_CODE = "lalu"; +const ALL_APP_OPTION = { code: "", label: "全部", value: "" }; +const DEFAULT_APP_OPTION = { code: DEFAULT_APP_CODE, label: "Lalu", value: DEFAULT_APP_CODE }; +const FIXED_APP_OPTIONS = [ + { code: "aslan", label: "Aslan", value: "aslan" }, + { code: "yumi", label: "Yumi", value: "yumi" } +]; + +export function getDefaultAppOptions() { + return mergeAppOptions([ALL_APP_OPTION, DEFAULT_APP_OPTION, ...FIXED_APP_OPTIONS]); +} export function getCurrentAppCode() { - return normalizeAppCode(window.localStorage.getItem(APP_CODE_KEY) || "lalu"); + const stored = window.localStorage.getItem(APP_CODE_KEY); + return stored === null ? DEFAULT_APP_CODE : normalizeAppCode(stored); } export function setCurrentAppCode(value) { - const appCode = normalizeAppCode(value) || "lalu"; + const appCode = normalizeAppCode(value); window.localStorage.setItem(APP_CODE_KEY, appCode); return appCode; } export async function fetchStatisticsOverview({ appCode, countryId, endMs, regionId, seriesEndMs, seriesStartMs, startMs, statTz }) { - const query = { app_code: normalizeAppCode(appCode) || "lalu" }; + const query = appScopedQuery(appCode); if (statTz) { query.stat_tz = statTz; } @@ -58,7 +70,7 @@ export async function fetchPlatformGrantRecords({ appCode, countryId, endMs, pag } export async function fetchSelfGameStatisticsOverview({ appCode, countryId, endMs, gameId, regionId, startMs, statTz }) { - const query = { app_code: normalizeAppCode(appCode) || "lalu" }; + const query = appScopedQuery(appCode); if (statTz) { query.stat_tz = statTz; } @@ -82,7 +94,7 @@ export async function fetchSelfGameStatisticsOverview({ appCode, countryId, endM } function platformGrantQuery({ appCode, countryId, endMs, page, pageSize, regionId, source, startMs, statDay, statTz, userId }) { - const query = { app_code: normalizeAppCode(appCode) || "lalu" }; + const query = appScopedQuery(appCode); if (statTz) { query.stat_tz = statTz; } @@ -194,6 +206,12 @@ function normalizeAppCode(value) { return String(value || "").trim().toLowerCase(); } +function appScopedQuery(appCode) { + const normalized = normalizeAppCode(appCode); + // “全部”使用空 appCode,让请求层省略 X-App-Code 和 app_code,避免把 all 当成真实 App 传给后端。 + return normalized ? { app_code: normalized } : {}; +} + function normalizeFilterOptions({ apps, countries, regions }) { const regionItems = normalizeList(regions); const regionOptions = [ @@ -218,7 +236,7 @@ function normalizeFilterOptions({ apps, countries, regions }) { .map((country) => normalizeCountryOption(country, countryRegionIds)) .filter(Boolean) ]; - const appOptions = normalizeList(apps) + const apiAppOptions = normalizeList(apps) .map((app) => { const code = normalizeAppCode(app.appCode ?? app.app_code ?? app.code); return { @@ -228,6 +246,7 @@ function normalizeFilterOptions({ apps, countries, regions }) { }; }) .filter((app) => app.code); + const appOptions = mergeAppOptions([ALL_APP_OPTION, ...apiAppOptions, DEFAULT_APP_OPTION, ...FIXED_APP_OPTIONS]); return { appOptions, @@ -236,6 +255,18 @@ function normalizeFilterOptions({ apps, countries, regions }) { }; } +function mergeAppOptions(options) { + const seen = new Set(); + return options.filter((option) => { + const code = normalizeAppCode(option.code ?? option.value); + if (seen.has(code)) { + return false; + } + seen.add(code); + return true; + }); +} + function normalizeList(value) { if (Array.isArray(value)) { return value; diff --git a/databi/src/api.test.js b/databi/src/api.test.js new file mode 100644 index 0000000..a6b24e3 --- /dev/null +++ b/databi/src/api.test.js @@ -0,0 +1,84 @@ +import { afterEach, expect, test, vi } from "vitest"; +import { fetchFilterOptions, fetchStatisticsOverview, getCurrentAppCode, getDefaultAppOptions, setCurrentAppCode } from "./api.js"; + +afterEach(() => { + window.localStorage.clear(); + vi.unstubAllGlobals(); +}); + +test("stores the last selected databi app including all apps", () => { + expect(getCurrentAppCode()).toBe("lalu"); + + expect(setCurrentAppCode("YUMI")).toBe("yumi"); + expect(window.localStorage.getItem("hyapp-admin.app-code")).toBe("yumi"); + expect(getCurrentAppCode()).toBe("yumi"); + + expect(setCurrentAppCode("")).toBe(""); + expect(window.localStorage.getItem("hyapp-admin.app-code")).toBe(""); + expect(getCurrentAppCode()).toBe(""); +}); + +test("adds fixed databi app options without losing API apps", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input) => { + const url = String(input); + if (url.includes("/v1/admin/apps")) { + return jsonResponse({ items: [{ appCode: "huwaa", appName: "Huwaa" }, { app_code: "lalu", name: "Lalu" }] }); + } + if (url.includes("/v1/admin/regions")) { + return jsonResponse({ items: [] }); + } + if (url.includes("/v1/admin/countries")) { + return jsonResponse({ items: [] }); + } + return jsonResponse(null, 404); + }) + ); + + const options = await fetchFilterOptions({ appCode: "lalu" }); + + expect(options.appOptions).toEqual([ + { code: "", label: "全部", value: "" }, + { code: "huwaa", label: "Huwaa", value: "huwaa" }, + { code: "lalu", label: "Lalu", value: "lalu" }, + { code: "aslan", label: "Aslan", value: "aslan" }, + { code: "yumi", label: "Yumi", value: "yumi" } + ]); +}); + +test("provides static databi app options before filter API loads", () => { + expect(getDefaultAppOptions()).toEqual([ + { code: "", label: "全部", value: "" }, + { code: "lalu", label: "Lalu", value: "lalu" }, + { code: "aslan", label: "Aslan", value: "aslan" }, + { code: "yumi", label: "Yumi", value: "yumi" } + ]); +}); + +test("omits app scope headers and query params for all databi apps", async () => { + vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ updated_at_ms: 1 }))); + + await fetchStatisticsOverview({ + appCode: "", + countryId: 0, + endMs: 2, + regionId: "all", + seriesEndMs: 2, + seriesStartMs: 1, + startMs: 1, + statTz: "Asia/Shanghai" + }); + + const [url, init] = fetch.mock.calls[0]; + const requestURL = new URL(String(url)); + expect(requestURL.searchParams.has("app_code")).toBe(false); + expect(init.headers["X-App-Code"]).toBeUndefined(); +}); + +function jsonResponse(data, status = 200) { + return new Response(JSON.stringify({ code: status === 200 ? 0 : status, data, message: status === 200 ? "" : "error" }), { + headers: { "content-type": "application/json" }, + status + }); +} diff --git a/databi/src/components/DatabiHeader.test.jsx b/databi/src/components/DatabiHeader.test.jsx index d47601a..8e67981 100644 --- a/databi/src/components/DatabiHeader.test.jsx +++ b/databi/src/components/DatabiHeader.test.jsx @@ -34,3 +34,34 @@ test("switches to end time after selecting a start date", async () => { expect(within(dialog).getByRole("tab", { name: /结束时间/ })).toHaveAttribute("aria-selected", "true"); }); + +test("selects all apps from app filter", async () => { + const user = userEvent.setup(); + const onAppChange = vi.fn(); + + render( + + ); + + await user.click(screen.getByRole("button", { name: /App\s*Lalu/ })); + await user.click(within(screen.getByRole("listbox")).getByRole("option", { name: "全部" })); + + expect(onAppChange).toHaveBeenCalledWith(""); +}); diff --git a/src/features/app-users/api.test.ts b/src/features/app-users/api.test.ts index be046f9..6ed3017 100644 --- a/src/features/app-users/api.test.ts +++ b/src/features/app-users/api.test.ts @@ -156,6 +156,42 @@ test("recyclePrettyDisplayID uses generated recycle endpoint", async () => { }); }); +test("listPrettyDisplayIDs normalizes zero assigned user as empty", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ + code: 0, + data: { + items: [ + { + assigned_user_id: "0", + display_user_id: "00002", + pretty_id: "pretty-0", + source: "pool", + status: "available", + }, + ], + page: 1, + page_size: 20, + total: 1, + }, + }), + ), + ), + ); + + const result = await listPrettyDisplayIDs({ page: 1, page_size: 20 }); + + expect(result.items[0]).toMatchObject({ + assignedUser: undefined, + assignedUserId: "", + displayUserId: "00002", + }); +}); + test("listAppUsers sends country region and time filters", async () => { vi.stubGlobal( "fetch", diff --git a/src/features/app-users/api.ts b/src/features/app-users/api.ts index 4d7cf92..5c0dffc 100644 --- a/src/features/app-users/api.ts +++ b/src/features/app-users/api.ts @@ -630,7 +630,7 @@ function normalizeOptionalAppUserBrief(item?: RawAppUserBrief | null) { return undefined; } const user = normalizeAppUserBrief(item); - return user.userId || user.displayUserId || user.username ? user : undefined; + return nonZeroStringValue(user.userId) || nonZeroStringValue(user.displayUserId) || user.username ? user : undefined; } function normalizeAppUserBanRecord(item: RawAppUserBanRecord = {}): AppUserBanRecordDto { @@ -708,7 +708,7 @@ function normalizePrettyDisplayID(item: RawPrettyDisplayID): PrettyDisplayIDDto assignedAtMs: item.assignedAtMs ?? item.assigned_at_ms ?? 0, assignedLeaseId: item.assignedLeaseId ?? item.assigned_lease_id ?? "", assignedUser: normalizeOptionalAppUserBrief(item.assignedUser ?? item.assigned_user), - assignedUserId: item.assignedUserId ?? item.assigned_user_id ?? "", + assignedUserId: optionalEntityID(item.assignedUserId ?? item.assigned_user_id), createdAtMs: item.createdAtMs ?? item.created_at_ms ?? 0, createdByAdminId, displayUserId: item.displayUserId ?? item.display_user_id ?? "", @@ -764,3 +764,12 @@ function normalizeAdminGrantPrettyDisplayIDResult( prettyId: item.prettyId ?? item.pretty_id ?? "", }; } + +function optionalEntityID(value: unknown): string { + return nonZeroStringValue(value); +} + +function nonZeroStringValue(value: unknown): string { + const normalized = stringValue(value); + return normalized === "0" ? "" : normalized; +} diff --git a/src/features/app-users/pages/AppUserPrettyIdsPage.jsx b/src/features/app-users/pages/AppUserPrettyIdsPage.jsx index c9d506e..7465a8c 100644 --- a/src/features/app-users/pages/AppUserPrettyIdsPage.jsx +++ b/src/features/app-users/pages/AppUserPrettyIdsPage.jsx @@ -397,12 +397,12 @@ function idColumns(page) { } function AssignedUserCell({ item }) { - const assignedUserId = formatEntityID(item.assignedUserId); - if (!item.assignedUserId) { + const user = assignedUser(item); + if (!user) { return -; } - return ; + return ; } function OperatorCell({ item }) { @@ -433,12 +433,16 @@ function PrettyIDTimeCell({ item }) { } function assignedUser(item) { + const user = item.assignedUser || null; + if (!user) { + return null; + } return { - ...(item.assignedUser || {}), - displayUserId: item.assignedUser?.displayUserId || item.assignedUser?.defaultDisplayUserId || item.assignedUserId, + ...user, + displayUserId: user.displayUserId || user.defaultDisplayUserId, prettyDisplayUserId: item.displayUserId, prettyId: item.prettyId, - userId: item.assignedUser?.userId || item.assignedUserId, + userId: user.userId, }; } @@ -713,13 +717,6 @@ function ruleLabel(ruleType) { return prettyRuleOptions.find(([value]) => value === ruleType)?.[1] || ruleType || "-"; } -function formatEntityID(value) { - if (value === 0 || value === "0" || value === "" || value === undefined || value === null) { - return "-"; - } - return String(value); -} - function canChangePrettyStatus(item) { const assigned = item.assignedUserId; const unassigned = diff --git a/src/features/app-users/pages/AppUserPrettyIdsPage.test.jsx b/src/features/app-users/pages/AppUserPrettyIdsPage.test.jsx index 26fcafa..a9bdaec 100644 --- a/src/features/app-users/pages/AppUserPrettyIdsPage.test.jsx +++ b/src/features/app-users/pages/AppUserPrettyIdsPage.test.jsx @@ -19,8 +19,10 @@ test("pretty id page uses tabs and hides internal pretty record ids", () => { expect(screen.getByRole("tab", { name: "靓号列表 1" })).toHaveAttribute("aria-selected", "true"); expect(screen.getByRole("tab", { name: "靓号池 1" })).toBeInTheDocument(); expect(screen.getAllByText("777777").length).toBeGreaterThan(0); + expect(screen.getByText("10001")).toBeInTheDocument(); expect(screen.queryByText("pretty_1782492525506_0b70bb6c6dbc63cf")).not.toBeInTheDocument(); expect(screen.getByText("占用人 A")).toBeInTheDocument(); + expect(document.querySelector('img[src="https://cdn.example/owner-a.png"]')).toBeInTheDocument(); expect(screen.getByText("Admin ID 8")).toBeInTheDocument(); expect(screen.getByText("释放 2026-06-27 01:00:00")).toBeInTheDocument(); expect(screen.getAllByLabelText("回收").length).toBeGreaterThan(0); @@ -143,7 +145,7 @@ function prettyIdFixture() { return { assignedAtMs: 1782499200000, assignedUser: { - avatar: "", + avatar: "https://cdn.example/owner-a.png", defaultDisplayUserId: "10001", displayUserId: "777777", userId: "9001", diff --git a/src/shared/ui/AdminUserIdentity.jsx b/src/shared/ui/AdminUserIdentity.jsx index f61a93c..e15d132 100644 --- a/src/shared/ui/AdminUserIdentity.jsx +++ b/src/shared/ui/AdminUserIdentity.jsx @@ -28,6 +28,12 @@ export function AdminUserIdentity({ user, userId, }); + const visibleRows = Array.isArray(rows) + ? resolveVisibleRows(rows, normalized) + : [normalized.shortId].filter(Boolean); + if (!normalized.hasIdentity) { + return -; + } const shouldOpenAppUserDetail = openInAppUserDetail && normalized.userId && openAppUserDetail; const Root = onClick || shouldOpenAppUserDetail ? "button" : "div"; const rootProps = resolveRootProps({ @@ -37,9 +43,6 @@ export function AdminUserIdentity({ shouldOpenAppUserDetail, user: normalized.detailUser, }); - const visibleRows = Array.isArray(rows) - ? resolveVisibleRows(rows, normalized) - : [normalized.shortId].filter(Boolean); return ( { expect(onClick).toHaveBeenCalledTimes(1); }); +test("empty identity renders dash without avatar or click target", () => { + const onClick = vi.fn(); + render(); + + expect(screen.getByText("-")).toBeInTheDocument(); + expect(screen.queryByText("0")).not.toBeInTheDocument(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); +}); + test("uses original id before pretty id when rows pass current display id", () => { render(