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(