修复靓号

This commit is contained in:
zhx 2026-06-30 20:50:34 +08:00
parent 810ee6721c
commit b0b1a26ae8
12 changed files with 274 additions and 37 deletions

View File

@ -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) {

View File

@ -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())
}));

View File

@ -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;

84
databi/src/api.test.js Normal file
View File

@ -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
});
}

View File

@ -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(
<DatabiHeader
appCode="lalu"
appOptions={[
{ code: "", label: "全部", value: "" },
{ code: "lalu", label: "Lalu", value: "lalu" },
{ code: "aslan", label: "Aslan", value: "aslan" },
{ code: "yumi", label: "Yumi", value: "yumi" }
]}
countryId={0}
onAppChange={onAppChange}
onCountryChange={noop}
onRangeChange={noop}
onRegionChange={noop}
onTimeZoneChange={noop}
range={{ end: "2026-06-08", endTime: "23:59:59", start: "2026-06-08", startTime: "00:00:00" }}
regionId="all"
timeZone="UTC"
/>
);
await user.click(screen.getByRole("button", { name: /App\s*Lalu/ }));
await user.click(within(screen.getByRole("listbox")).getByRole("option", { name: "全部" }));
expect(onAppChange).toHaveBeenCalledWith("");
});

View File

@ -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",

View File

@ -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;
}

View File

@ -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 <span className={styles.meta}>-</span>;
}
return <AdminUserIdentity openInAppUserDetail rows={[assignedUserId]} user={assignedUser(item)} />;
return <AdminUserIdentity openInAppUserDetail user={user} />;
}
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 =

View File

@ -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",

View File

@ -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 <span className={[styles.empty, className].filter(Boolean).join(" ")}>-</span>;
}
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 (
<Root
@ -120,8 +123,8 @@ function normalizeUserIdentity({
}) {
const source = user || {};
// camelCase snake_case
//
const resolvedUserId = firstNonEmpty(
// 0
const resolvedUserId = firstNonEmptyIdentityValue(
userId,
source.userId,
source.user_id,
@ -130,12 +133,12 @@ function normalizeUserIdentity({
source.targetUserId,
source.target_user_id,
);
const resolvedDefaultDisplayUserId = firstNonEmpty(
const resolvedDefaultDisplayUserId = firstNonEmptyIdentityValue(
defaultDisplayUserId,
source.defaultDisplayUserId,
source.default_display_user_id,
);
const resolvedDisplayUserId = firstNonEmpty(
const resolvedDisplayUserId = firstNonEmptyIdentityValue(
displayUserId,
source.displayUserId,
source.display_user_id,
@ -153,31 +156,39 @@ function normalizeUserIdentity({
const resolvedShortId = hasPretty
? firstNonEmpty(resolvedDefaultDisplayUserId, resolvedUserId, resolvedDisplayUserId)
: firstNonEmpty(resolvedDisplayUserId, resolvedDefaultDisplayUserId, resolvedUserId);
const displayName = firstNonEmpty(
const resolvedAvatar = firstNonEmpty(avatar, source.avatar);
const explicitName = firstNonEmpty(
name,
source.username,
source.name,
source.account,
);
const displayName = firstNonEmpty(
explicitName,
resolvedDisplayUserId,
resolvedUserId,
"-",
);
const hasIdentity = Boolean(
resolvedAvatar || explicitName || resolvedDisplayUserId || resolvedDefaultDisplayUserId || resolvedUserId,
);
return {
avatar: firstNonEmpty(avatar, source.avatar),
avatar: resolvedAvatar,
detailUser: {
...source,
avatar: firstNonEmpty(avatar, source.avatar),
avatar: resolvedAvatar,
defaultDisplayUserId: resolvedDefaultDisplayUserId,
displayUserId: resolvedDisplayUserId,
prettyDisplayUserId: resolvedPrettyDisplayUserId,
prettyId: resolvedPrettyId,
userId: resolvedUserId,
username: firstNonEmpty(name, source.username, source.name, source.account),
username: explicitName,
},
displayUserId: resolvedDisplayUserId,
displayName,
hasPretty,
hasIdentity,
prettyDisplayUserId: resolvedPrettyDisplayUserId,
prettyId: resolvedPrettyId,
shortId: resolvedShortId || "-",
@ -232,3 +243,13 @@ function firstNonEmpty(...values) {
}
return "";
}
function firstNonEmptyIdentityValue(...values) {
for (const value of values) {
const normalized = String(value ?? "").trim();
if (normalized && normalized !== "0") {
return normalized;
}
}
return "";
}

View File

@ -8,6 +8,10 @@
text-align: left;
}
.empty {
color: var(--text-tertiary);
}
.button {
width: 100%;
padding: 0;

View File

@ -44,6 +44,15 @@ test("clickable identity opens detail handler", async () => {
expect(onClick).toHaveBeenCalledTimes(1);
});
test("empty identity renders dash without avatar or click target", () => {
const onClick = vi.fn();
render(<AdminUserIdentity onClick={onClick} openInAppUserDetail user={{ displayUserId: "0", userId: "0" }} />);
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(
<AdminUserIdentity