feat(ops): add lucky gift user portraits
This commit is contained in:
parent
952cfd3e67
commit
e90447c5fe
@ -366,6 +366,30 @@
|
|||||||
"x-permissions": ["lucky-gift:view"]
|
"x-permissions": ["lucky-gift:view"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/admin/ops-center/lucky-gifts/user-profiles": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "listLuckyGiftUserProfiles",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "lucky-gift:view",
|
||||||
|
"x-permissions": ["lucky-gift:view"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/admin/ops-center/lucky-gifts/user-profile": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getLuckyGiftUserProfile",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "lucky-gift:view",
|
||||||
|
"x-permissions": ["lucky-gift:view"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/admin/ops-center/lucky-gifts/summary": {
|
"/admin/ops-center/lucky-gifts/summary": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getLuckyGiftDrawSummary",
|
"operationId": "getLuckyGiftDrawSummary",
|
||||||
|
|||||||
@ -2,8 +2,9 @@ import AppsOutlined from "@mui/icons-material/AppsOutlined";
|
|||||||
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
|
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
|
||||||
import CasinoOutlined from "@mui/icons-material/CasinoOutlined";
|
import CasinoOutlined from "@mui/icons-material/CasinoOutlined";
|
||||||
import InsightsOutlined from "@mui/icons-material/InsightsOutlined";
|
import InsightsOutlined from "@mui/icons-material/InsightsOutlined";
|
||||||
|
import PeopleAltOutlined from "@mui/icons-material/PeopleAltOutlined";
|
||||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { PERMISSIONS } from "@/app/permissions";
|
import { PERMISSIONS } from "@/app/permissions";
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
@ -19,9 +20,15 @@ import { OpsShell } from "./components/OpsShell.jsx";
|
|||||||
import { OverviewView } from "./components/OverviewView.jsx";
|
import { OverviewView } from "./components/OverviewView.jsx";
|
||||||
import { formatNumber } from "./format.js";
|
import { formatNumber } from "./format.js";
|
||||||
|
|
||||||
|
// 用户画像包含高密度表格和大奖 trace,仅进入该视图才加载代码与数据,避免拖慢配置页首屏。
|
||||||
|
const UserProfilesView = lazy(() =>
|
||||||
|
import("./components/UserProfilesView.jsx").then((module) => ({ default: module.UserProfilesView })),
|
||||||
|
);
|
||||||
|
|
||||||
const views = [
|
const views = [
|
||||||
{ icon: InsightsOutlined, key: "overview", label: "数据总览" },
|
{ icon: InsightsOutlined, key: "overview", label: "数据总览" },
|
||||||
{ icon: CardGiftcardOutlined, key: "configs", label: "幸运礼物配置" },
|
{ icon: CardGiftcardOutlined, key: "configs", label: "幸运礼物配置" },
|
||||||
|
{ icon: PeopleAltOutlined, key: "profiles", label: "用户画像" },
|
||||||
{ icon: CasinoOutlined, key: "draws", label: "抽奖记录" },
|
{ icon: CasinoOutlined, key: "draws", label: "抽奖记录" },
|
||||||
{ icon: AppsOutlined, key: "apps", label: "应用列表" },
|
{ icon: AppsOutlined, key: "apps", label: "应用列表" },
|
||||||
];
|
];
|
||||||
@ -48,6 +55,7 @@ export function OpsCenterApp({ can = denyPermission }) {
|
|||||||
const [state, setState] = useState({ data: initialDashboard, error: "", loaded: false, loading: true });
|
const [state, setState] = useState({ data: initialDashboard, error: "", loaded: false, loading: true });
|
||||||
const [dialog, setDialog] = useState(null);
|
const [dialog, setDialog] = useState(null);
|
||||||
const [poolDialog, setPoolDialog] = useState(null);
|
const [poolDialog, setPoolDialog] = useState(null);
|
||||||
|
const [profileRefreshKey, setProfileRefreshKey] = useState(0);
|
||||||
const [adjustingPool, setAdjustingPool] = useState(false);
|
const [adjustingPool, setAdjustingPool] = useState(false);
|
||||||
const [savingConfig, setSavingConfig] = useState(false);
|
const [savingConfig, setSavingConfig] = useState(false);
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
@ -75,6 +83,14 @@ export function OpsCenterApp({ can = denyPermission }) {
|
|||||||
loadDashboard();
|
loadDashboard();
|
||||||
}, [loadDashboard]);
|
}, [loadDashboard]);
|
||||||
|
|
||||||
|
const refreshCurrentView = useCallback(() => {
|
||||||
|
if (activeView === "profiles") {
|
||||||
|
setProfileRefreshKey((current) => current + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadDashboard();
|
||||||
|
}, [activeView, loadDashboard]);
|
||||||
|
|
||||||
const { data } = state;
|
const { data } = state;
|
||||||
|
|
||||||
const appOptions = useMemo(
|
const appOptions = useMemo(
|
||||||
@ -201,7 +217,7 @@ export function OpsCenterApp({ can = denyPermission }) {
|
|||||||
<Button
|
<Button
|
||||||
disabled={state.loading}
|
disabled={state.loading}
|
||||||
startIcon={<RefreshOutlined fontSize="small" />}
|
startIcon={<RefreshOutlined fontSize="small" />}
|
||||||
onClick={loadDashboard}
|
onClick={refreshCurrentView}
|
||||||
>
|
>
|
||||||
刷新
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
@ -246,6 +262,16 @@ export function OpsCenterApp({ can = denyPermission }) {
|
|||||||
{activeView === "draws" ? (
|
{activeView === "draws" ? (
|
||||||
<DrawsView apps={data.apps} defaultAppCode={defaultAppCode} poolIDsByApp={poolIDsByApp} />
|
<DrawsView apps={data.apps} defaultAppCode={defaultAppCode} poolIDsByApp={poolIDsByApp} />
|
||||||
) : null}
|
) : null}
|
||||||
|
{activeView === "profiles" ? (
|
||||||
|
<Suspense fallback={<DataState loading onRetry={refreshCurrentView} />}>
|
||||||
|
<UserProfilesView
|
||||||
|
apps={data.apps}
|
||||||
|
defaultAppCode={defaultAppCode}
|
||||||
|
key={profileRefreshKey}
|
||||||
|
poolIDsByApp={poolIDsByApp}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
) : null}
|
||||||
{activeView === "apps" ? <AppsView apps={data.apps} /> : null}
|
{activeView === "apps" ? <AppsView apps={data.apps} /> : null}
|
||||||
</DataState>
|
</DataState>
|
||||||
{dialog ? (
|
{dialog ? (
|
||||||
|
|||||||
@ -77,6 +77,99 @@ export async function fetchLuckyGiftDraws({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchLuckyGiftUserProfiles({
|
||||||
|
appCode,
|
||||||
|
cursor = "",
|
||||||
|
identityType = "all",
|
||||||
|
keyword = "",
|
||||||
|
page = 1,
|
||||||
|
pageSize = 20,
|
||||||
|
poolId,
|
||||||
|
sortBy = "last_draw_at",
|
||||||
|
sortDirection = "desc",
|
||||||
|
stage = "",
|
||||||
|
status = "",
|
||||||
|
} = {}) {
|
||||||
|
const operationPath = apiEndpointPath(API_OPERATIONS.listLuckyGiftUserProfiles);
|
||||||
|
const payload = await opsRequest(opsRelativePath(operationPath), {
|
||||||
|
query: {
|
||||||
|
app_code: String(appCode || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase(),
|
||||||
|
cursor: String(cursor || "").trim(),
|
||||||
|
identity_type: normalizeIdentityType(identityType),
|
||||||
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
|
pool_id: String(poolId || "").trim(),
|
||||||
|
sort_by: String(sortBy || "last_draw_at").trim(),
|
||||||
|
sort_direction: String(sortDirection || "desc").trim(),
|
||||||
|
stage: String(stage || "").trim(),
|
||||||
|
status: String(status || "").trim(),
|
||||||
|
user_keyword: String(keyword || "").trim(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const snapshotAtMs = numberValue(payload.snapshot_at_ms ?? payload.snapshotAtMs);
|
||||||
|
return {
|
||||||
|
items: normalizeProfileItems(payload).map((item) => normalizeLuckyGiftUserProfile(item, snapshotAtMs)),
|
||||||
|
page: numberValue(payload.page) || page,
|
||||||
|
pageSize: numberValue(payload.page_size ?? payload.pageSize) || pageSize,
|
||||||
|
nextCursor: String(payload.next_cursor ?? payload.nextCursor ?? "").trim(),
|
||||||
|
hasMore: Boolean(payload.has_more ?? payload.hasMore),
|
||||||
|
snapshotAtMs,
|
||||||
|
total: numberValue(payload.total),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLuckyGiftUserProfile({
|
||||||
|
appCode,
|
||||||
|
identityType,
|
||||||
|
externalUserId,
|
||||||
|
jackpotCursor = "",
|
||||||
|
jackpotPage = 1,
|
||||||
|
jackpotPageSize = 20,
|
||||||
|
poolId,
|
||||||
|
userId,
|
||||||
|
} = {}) {
|
||||||
|
const normalizedIdentityType = normalizeIdentityType(identityType);
|
||||||
|
const operationPath = apiEndpointPath(API_OPERATIONS.getLuckyGiftUserProfile);
|
||||||
|
const payload = await opsRequest(opsRelativePath(operationPath), {
|
||||||
|
query: {
|
||||||
|
app_code: String(appCode || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase(),
|
||||||
|
// 外部稳定用户键仅用于 owner 内部关联,前端查询和展示始终使用接入方原始 external_user_id。
|
||||||
|
external_user_id: normalizedIdentityType === "external" ? String(externalUserId || "").trim() : "",
|
||||||
|
identity_type: normalizedIdentityType,
|
||||||
|
internal_user_id: normalizedIdentityType === "internal" ? String(userId || "").trim() : "",
|
||||||
|
jackpot_page: jackpotPage,
|
||||||
|
jackpot_page_size: jackpotPageSize,
|
||||||
|
jackpot_cursor: String(jackpotCursor || "").trim(),
|
||||||
|
pool_id: String(poolId || "").trim(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const profileSource = payload.profile || payload.item || payload;
|
||||||
|
return {
|
||||||
|
jackpotHits: normalizeItems(payload.jackpot_hits ?? payload.jackpotHits).map(normalizeLuckyGiftJackpotHit),
|
||||||
|
jackpotHitTotal: numberValue(payload.jackpot_hit_total ?? payload.jackpotHitTotal),
|
||||||
|
jackpotPage: numberValue(payload.jackpot_page ?? payload.jackpotPage) || jackpotPage,
|
||||||
|
jackpotPageSize: numberValue(payload.jackpot_page_size ?? payload.jackpotPageSize) || jackpotPageSize,
|
||||||
|
nextJackpotCursor: String(payload.next_jackpot_cursor ?? payload.nextJackpotCursor ?? "").trim(),
|
||||||
|
jackpotHasMore: Boolean(payload.jackpot_has_more ?? payload.jackpotHasMore),
|
||||||
|
multiplierDistribution: aggregateLuckyGiftMultiplierMetrics(
|
||||||
|
normalizeItems(
|
||||||
|
payload.multiplier_distribution ?? payload.multiplierDistribution ?? payload.multiplier_stats,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
profile: normalizeLuckyGiftUserProfile(
|
||||||
|
profileSource,
|
||||||
|
payload.snapshot_at_ms ??
|
||||||
|
payload.snapshotAtMs ??
|
||||||
|
profileSource.snapshot_at_ms ??
|
||||||
|
profileSource.snapshotAtMs,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveLuckyGiftConfig(config = {}) {
|
export async function saveLuckyGiftConfig(config = {}) {
|
||||||
const payload = luckyGiftConfigPayload(config);
|
const payload = luckyGiftConfigPayload(config);
|
||||||
return opsRequest("/lucky-gifts/config", {
|
return opsRequest("/lucky-gifts/config", {
|
||||||
@ -322,6 +415,349 @@ function normalizePoolAdjustmentResult(payload = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeLuckyGiftUserProfile(profile = {}, fallbackSnapshotAtMs = 0) {
|
||||||
|
const identity = profile.identity || {};
|
||||||
|
const internalUser = profile.internal_user || profile.internalUser || profile.user || identity.user || {};
|
||||||
|
const externalUserID = String(
|
||||||
|
profile.external_user_id ??
|
||||||
|
profile.externalUserId ??
|
||||||
|
identity.external_user_id ??
|
||||||
|
identity.externalUserId ??
|
||||||
|
"",
|
||||||
|
).trim();
|
||||||
|
const userID = String(
|
||||||
|
profile.internal_user_id ??
|
||||||
|
profile.internalUserId ??
|
||||||
|
profile.user_id ??
|
||||||
|
profile.userId ??
|
||||||
|
internalUser.user_id ??
|
||||||
|
internalUser.userId ??
|
||||||
|
identity.user_id ??
|
||||||
|
identity.userId ??
|
||||||
|
"",
|
||||||
|
).trim();
|
||||||
|
const identityType = externalUserID
|
||||||
|
? "external"
|
||||||
|
: normalizeIdentityType(
|
||||||
|
profile.identity_type ?? profile.identityType ?? profile.user_type ?? profile.userType ?? "internal",
|
||||||
|
);
|
||||||
|
const windows = profile.windows || profile.window_metrics || profile.windowMetrics || {};
|
||||||
|
const snapshotAtMs = numberValue(profile.snapshot_at_ms ?? profile.snapshotAtMs ?? fallbackSnapshotAtMs);
|
||||||
|
const profileUpdatedAtMs = numberValue(
|
||||||
|
profile.aggregated_at_ms ??
|
||||||
|
profile.aggregatedAtMs ??
|
||||||
|
profile.profile_updated_at_ms ??
|
||||||
|
profile.profileUpdatedAtMs ??
|
||||||
|
profile.updated_at_ms ??
|
||||||
|
profile.updatedAtMs,
|
||||||
|
);
|
||||||
|
const explicitLag = profile.data_lag_ms ?? profile.dataLagMs;
|
||||||
|
const jackpotCounts = profile.jackpot_counts || profile.jackpotCounts || {};
|
||||||
|
|
||||||
|
// 画像接口是独立读模型,字段名可能经过 protobuf JSON 或 admin envelope 转成 camelCase;
|
||||||
|
// 在边界一次性归一化,组件只消费稳定模型,且明确丢弃 owner 内部的 strategy_user_id / user_key 哈希。
|
||||||
|
return {
|
||||||
|
appCode: String(profile.app_code ?? profile.appCode ?? "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase(),
|
||||||
|
cumulativeSpendJackpotCount: numberValue(
|
||||||
|
profile.cumulative_spend_jackpot_count ??
|
||||||
|
profile.cumulativeSpendJackpotCount ??
|
||||||
|
jackpotCounts.cumulative_spend ??
|
||||||
|
jackpotCounts.cumulativeSpend,
|
||||||
|
),
|
||||||
|
dataLagMs:
|
||||||
|
explicitLag !== undefined
|
||||||
|
? Math.max(0, numberValue(explicitLag))
|
||||||
|
: snapshotAtMs && profileUpdatedAtMs
|
||||||
|
? Math.max(0, snapshotAtMs - profileUpdatedAtMs)
|
||||||
|
: 0,
|
||||||
|
downweightActive: Boolean(
|
||||||
|
profile.downweight_active ??
|
||||||
|
profile.downweightActive ??
|
||||||
|
profile.user_24h_rtp_downweight_active ??
|
||||||
|
profile.user24hRtpDownweightActive,
|
||||||
|
),
|
||||||
|
equivalentDraws: numberValue(profile.equivalent_draws ?? profile.equivalentDraws),
|
||||||
|
externalUserId: identityType === "external" ? externalUserID : "",
|
||||||
|
failedDrawCount: numberValue(profile.failed_draw_count ?? profile.failedDrawCount),
|
||||||
|
firstDrawAtMs: numberValue(profile.first_draw_at_ms ?? profile.firstDrawAtMs),
|
||||||
|
grantedDrawCount: numberValue(profile.granted_draw_count ?? profile.grantedDrawCount),
|
||||||
|
guaranteeDrawsRemaining: numberValue(
|
||||||
|
profile.guarantee_draws_remaining ?? profile.guaranteeDrawsRemaining ?? profile.loss_protection_remaining,
|
||||||
|
),
|
||||||
|
guaranteeThreshold: numberValue(
|
||||||
|
profile.guarantee_threshold ?? profile.guaranteeThreshold ?? profile.loss_streak_guarantee,
|
||||||
|
),
|
||||||
|
highMultiplierOrdinaryWinCount: numberValue(
|
||||||
|
profile.high_multiplier_ordinary_win_count ?? profile.highMultiplierOrdinaryWinCount,
|
||||||
|
),
|
||||||
|
identityType,
|
||||||
|
currentRtpPpm: numberValue(profile.current_rtp_ppm ?? profile.currentRtpPpm),
|
||||||
|
hasCurrentRtp: Boolean(profile.has_current_rtp ?? profile.hasCurrentRtp),
|
||||||
|
key: `${profile.app_code ?? profile.appCode ?? ""}:${profile.pool_id ?? profile.poolId ?? ""}:${identityType}:${
|
||||||
|
identityType === "external" ? externalUserID : userID
|
||||||
|
}`,
|
||||||
|
lastDrawAtMs: numberValue(profile.last_draw_at_ms ?? profile.lastDrawAtMs),
|
||||||
|
latestMultiplierPpm: numberValue(profile.latest_multiplier_ppm ?? profile.latestMultiplierPpm),
|
||||||
|
latestRuleVersion: numberValue(
|
||||||
|
profile.latest_rule_version ?? profile.latestRuleVersion ?? profile.rule_version,
|
||||||
|
),
|
||||||
|
latestStrategyVersion: String(
|
||||||
|
profile.latest_strategy_version ?? profile.latestStrategyVersion ?? profile.strategy_version ?? "",
|
||||||
|
).trim(),
|
||||||
|
latestWaterZone: String(
|
||||||
|
profile.latest_water_zone ?? profile.latestWaterZone ?? profile.water_zone ?? "",
|
||||||
|
).trim(),
|
||||||
|
lossStreak: numberValue(profile.loss_streak ?? profile.lossStreak),
|
||||||
|
maxMultiplierPpm: numberValue(profile.max_multiplier_ppm ?? profile.maxMultiplierPpm),
|
||||||
|
maxRewardCoins: numberValue(profile.max_reward_coins ?? profile.maxRewardCoins),
|
||||||
|
ordinaryWinCount: numberValue(profile.ordinary_win_count ?? profile.ordinaryWinCount),
|
||||||
|
pendingCumulativeSpendTokens: numberValue(
|
||||||
|
profile.pending_spend_jackpot_tokens ??
|
||||||
|
profile.pendingSpendJackpotTokens ??
|
||||||
|
profile.pending_cumulative_spend_tokens ??
|
||||||
|
profile.pendingCumulativeSpendTokens,
|
||||||
|
),
|
||||||
|
pendingRtpCompensationTokens: numberValue(
|
||||||
|
profile.pending_rtp_compensation_tokens ?? profile.pendingRtpCompensationTokens,
|
||||||
|
),
|
||||||
|
poolId: String(profile.pool_id ?? profile.poolId ?? "").trim(),
|
||||||
|
pendingDrawCount: numberValue(profile.pending_draw_count ?? profile.pendingDrawCount),
|
||||||
|
paidDraws: numberValue(profile.paid_draws ?? profile.paidDraws),
|
||||||
|
profileUpdatedAtMs,
|
||||||
|
rtpCompensationJackpotCount: numberValue(
|
||||||
|
profile.rtp_compensation_jackpot_count ??
|
||||||
|
profile.rtpCompensationJackpotCount ??
|
||||||
|
jackpotCounts.rtp_compensation ??
|
||||||
|
jackpotCounts.rtpCompensation,
|
||||||
|
),
|
||||||
|
snapshotAtMs,
|
||||||
|
stage: String(profile.stage ?? profile.current_stage ?? profile.currentStage ?? "").trim(),
|
||||||
|
baseRewardCoins: numberValue(profile.base_reward_coins ?? profile.baseRewardCoins),
|
||||||
|
guaranteeHitCount: numberValue(profile.guarantee_hit_count ?? profile.guaranteeHitCount),
|
||||||
|
user: {
|
||||||
|
avatar: String(internalUser.avatar ?? internalUser.avatar_url ?? internalUser.avatarUrl ?? "").trim(),
|
||||||
|
defaultDisplayUserId: String(
|
||||||
|
internalUser.default_display_user_id ?? internalUser.defaultDisplayUserId ?? "",
|
||||||
|
).trim(),
|
||||||
|
displayUserId: String(internalUser.display_user_id ?? internalUser.displayUserId ?? "").trim(),
|
||||||
|
prettyDisplayUserId: String(
|
||||||
|
internalUser.pretty_display_user_id ?? internalUser.prettyDisplayUserId ?? "",
|
||||||
|
).trim(),
|
||||||
|
prettyId: String(internalUser.pretty_id ?? internalUser.prettyId ?? "").trim(),
|
||||||
|
status: String(internalUser.status || "").trim(),
|
||||||
|
userId: identityType === "internal" ? userID : "",
|
||||||
|
username: String(internalUser.username ?? internalUser.name ?? "").trim(),
|
||||||
|
},
|
||||||
|
user24hOrdinaryWinFactorPpm: numberValue(
|
||||||
|
profile.downweight_factor_ppm ??
|
||||||
|
profile.downweightFactorPpm ??
|
||||||
|
profile.user_24h_ordinary_win_factor_ppm ??
|
||||||
|
profile.user24hOrdinaryWinFactorPpm,
|
||||||
|
),
|
||||||
|
user24hRtpThresholdPpm: numberValue(profile.user_24h_rtp_threshold_ppm ?? profile.user24hRtpThresholdPpm),
|
||||||
|
windows: {
|
||||||
|
fortyEightHours: normalizeLuckyGiftWindowMetric(
|
||||||
|
windows.forty_eight_hours ??
|
||||||
|
windows.fortyEightHours ??
|
||||||
|
windows.last_48h ??
|
||||||
|
windows.h48 ??
|
||||||
|
profile.forty_eight_hours ??
|
||||||
|
profile.fortyEightHours,
|
||||||
|
),
|
||||||
|
lifetime: normalizeLuckyGiftWindowMetric(
|
||||||
|
windows.lifetime ??
|
||||||
|
windows.all ??
|
||||||
|
windows.total ??
|
||||||
|
profile.lifetime ??
|
||||||
|
profile.all_time ??
|
||||||
|
profile.allTime,
|
||||||
|
),
|
||||||
|
oneHour: normalizeLuckyGiftWindowMetric(
|
||||||
|
windows.one_hour ??
|
||||||
|
windows.oneHour ??
|
||||||
|
windows.last_1h ??
|
||||||
|
windows.h1 ??
|
||||||
|
profile.one_hour ??
|
||||||
|
profile.oneHour,
|
||||||
|
),
|
||||||
|
twentyFourHours: normalizeLuckyGiftWindowMetric(
|
||||||
|
windows.twenty_four_hours ??
|
||||||
|
windows.twentyFourHours ??
|
||||||
|
windows.last_24h ??
|
||||||
|
windows.h24 ??
|
||||||
|
profile.twenty_four_hours ??
|
||||||
|
profile.twentyFourHours,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
zeroDrawCount: numberValue(profile.zero_draw_count ?? profile.zeroDrawCount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLuckyGiftWindowMetric(metric = {}) {
|
||||||
|
const wagerCoins = numberValue(
|
||||||
|
metric.wager_coins ?? metric.wagerCoins ?? metric.spent_coins ?? metric.spentCoins ?? metric.consumed_coins,
|
||||||
|
);
|
||||||
|
const payoutCoins = numberValue(
|
||||||
|
metric.payout_coins ?? metric.payoutCoins ?? metric.reward_coins ?? metric.rewardCoins ?? metric.returned_coins,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
drawCount: numberValue(metric.draw_count ?? metric.drawCount ?? metric.draws ?? metric.total_draws),
|
||||||
|
hasRtp: Boolean(metric.has_rtp ?? metric.hasRtp ?? wagerCoins > 0),
|
||||||
|
// 净输赢固定采用用户视角“返奖 - 消耗”,不直接接受含义可能相反的平台 profit 字段。
|
||||||
|
netCoins: payoutCoins - wagerCoins,
|
||||||
|
payoutCoins,
|
||||||
|
rtpPpm: numberValue(metric.rtp_ppm ?? metric.rtpPpm ?? metric.actual_rtp_ppm ?? metric.actualRtpPpm),
|
||||||
|
wagerCoins,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLuckyGiftMultiplierMetric(metric = {}) {
|
||||||
|
const hitType = String(metric.hit_type ?? metric.hitType ?? "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const drawCount = numberValue(metric.draw_count ?? metric.drawCount);
|
||||||
|
const ordinaryHitCount =
|
||||||
|
numberValue(
|
||||||
|
metric.ordinary_hit_count ?? metric.ordinaryHitCount ?? metric.ordinary_count ?? metric.ordinaryCount,
|
||||||
|
) || (hitType === "ordinary" ? drawCount : 0);
|
||||||
|
const rtpCompensationJackpotCount =
|
||||||
|
numberValue(metric.rtp_compensation_jackpot_count ?? metric.rtpCompensationJackpotCount) ||
|
||||||
|
(hitType === "rtp_compensation" ? drawCount : 0);
|
||||||
|
const cumulativeSpendJackpotCount =
|
||||||
|
numberValue(metric.cumulative_spend_jackpot_count ?? metric.cumulativeSpendJackpotCount) ||
|
||||||
|
(["cumulative_spend", "spend_milestone"].includes(hitType) ? drawCount : 0);
|
||||||
|
const zeroDrawCount =
|
||||||
|
numberValue(metric.zero_draw_count ?? metric.zeroDrawCount) || (hitType === "zero" ? drawCount : 0);
|
||||||
|
return {
|
||||||
|
cumulativeSpendJackpotCount,
|
||||||
|
hitCount:
|
||||||
|
numberValue(metric.hit_count ?? metric.hitCount) ||
|
||||||
|
ordinaryHitCount + rtpCompensationJackpotCount + cumulativeSpendJackpotCount + zeroDrawCount,
|
||||||
|
multiplierPpm: numberValue(metric.multiplier_ppm ?? metric.multiplierPpm),
|
||||||
|
ordinaryHitCount,
|
||||||
|
payoutCoins: numberValue(
|
||||||
|
metric.payout_coins ?? metric.payoutCoins ?? metric.reward_coins ?? metric.rewardCoins,
|
||||||
|
),
|
||||||
|
rtpCompensationJackpotCount,
|
||||||
|
wagerCoins: numberValue(metric.wager_coins ?? metric.wagerCoins),
|
||||||
|
zeroDrawCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateLuckyGiftMultiplierMetrics(metrics) {
|
||||||
|
const byMultiplier = new Map();
|
||||||
|
metrics.map(normalizeLuckyGiftMultiplierMetric).forEach((metric) => {
|
||||||
|
const current = byMultiplier.get(metric.multiplierPpm) || {
|
||||||
|
cumulativeSpendJackpotCount: 0,
|
||||||
|
hitCount: 0,
|
||||||
|
multiplierPpm: metric.multiplierPpm,
|
||||||
|
ordinaryHitCount: 0,
|
||||||
|
payoutCoins: 0,
|
||||||
|
rtpCompensationJackpotCount: 0,
|
||||||
|
wagerCoins: 0,
|
||||||
|
zeroDrawCount: 0,
|
||||||
|
};
|
||||||
|
current.cumulativeSpendJackpotCount += metric.cumulativeSpendJackpotCount;
|
||||||
|
current.hitCount += metric.hitCount;
|
||||||
|
current.ordinaryHitCount += metric.ordinaryHitCount;
|
||||||
|
current.payoutCoins += metric.payoutCoins;
|
||||||
|
current.rtpCompensationJackpotCount += metric.rtpCompensationJackpotCount;
|
||||||
|
current.wagerCoins += metric.wagerCoins;
|
||||||
|
current.zeroDrawCount += metric.zeroDrawCount;
|
||||||
|
byMultiplier.set(metric.multiplierPpm, current);
|
||||||
|
});
|
||||||
|
return [...byMultiplier.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLuckyGiftJackpotHit(hit = {}) {
|
||||||
|
const mechanism = String(
|
||||||
|
hit.mechanism ?? hit.jackpot_mechanism ?? hit.jackpotMechanism ?? hit.jackpot_type ?? hit.jackpotType ?? "",
|
||||||
|
).trim();
|
||||||
|
return {
|
||||||
|
conditions: normalizeLuckyGiftJackpotConditions(
|
||||||
|
hit.conditions ??
|
||||||
|
hit.conditions_json ??
|
||||||
|
hit.conditionsJson ??
|
||||||
|
hit.condition_snapshot_json ??
|
||||||
|
hit.conditionSnapshotJson,
|
||||||
|
),
|
||||||
|
drawId: String(hit.draw_id ?? hit.drawId ?? "").trim(),
|
||||||
|
eventKey: String(hit.event_key ?? hit.eventKey ?? "").trim(),
|
||||||
|
mechanism,
|
||||||
|
multiplierPpm: numberValue(hit.multiplier_ppm ?? hit.multiplierPpm),
|
||||||
|
occurredAtMs: numberValue(hit.occurred_at_ms ?? hit.occurredAtMs ?? hit.created_at_ms ?? hit.createdAtMs),
|
||||||
|
payoutCoins: numberValue(hit.payout_coins ?? hit.payoutCoins ?? hit.reward_coins ?? hit.rewardCoins),
|
||||||
|
reason: String(
|
||||||
|
hit.reason_summary ??
|
||||||
|
hit.reasonSummary ??
|
||||||
|
hit.reason ??
|
||||||
|
hit.final_reason ??
|
||||||
|
hit.finalReason ??
|
||||||
|
hit.decision_reason ??
|
||||||
|
hit.decisionReason ??
|
||||||
|
"",
|
||||||
|
).trim(),
|
||||||
|
reasonCode: String(hit.reason_code ?? hit.reasonCode ?? "").trim(),
|
||||||
|
requestId: String(hit.request_id ?? hit.requestId ?? "").trim(),
|
||||||
|
ruleVersion: numberValue(hit.rule_version ?? hit.ruleVersion),
|
||||||
|
stage: String(hit.stage ?? "").trim(),
|
||||||
|
tierId: String(hit.tier_id ?? hit.tierId ?? "").trim(),
|
||||||
|
wagerCoins: numberValue(hit.wager_coins ?? hit.wagerCoins),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLuckyGiftJackpotConditions(value) {
|
||||||
|
const parsed = parseJSONValue(value);
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
return parsed.map((condition) => ({
|
||||||
|
actual: String(condition.actual ?? condition.actual_value ?? condition.actualValue ?? "").trim(),
|
||||||
|
denominator: numberValue(condition.denominator),
|
||||||
|
factorPpm: numberValue(condition.factor_ppm ?? condition.factorPpm),
|
||||||
|
label: String(condition.label || "").trim(),
|
||||||
|
limitPpm: numberValue(condition.limit_ppm ?? condition.limitPpm),
|
||||||
|
name: String(condition.name || "").trim(),
|
||||||
|
numerator: numberValue(condition.numerator),
|
||||||
|
operator: String(condition.operator || "").trim(),
|
||||||
|
passed: Boolean(condition.passed),
|
||||||
|
ratioPpm: numberValue(condition.ratio_ppm ?? condition.ratioPpm),
|
||||||
|
reason: String(condition.reason || "").trim(),
|
||||||
|
threshold: String(condition.threshold ?? condition.threshold_value ?? condition.thresholdValue ?? "").trim(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIdentityType(value) {
|
||||||
|
const normalized = String(value || "all")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
return normalized === "internal" || normalized === "external" ? normalized : "all";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJSONValue(value) {
|
||||||
|
if (!value) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (typeof value === "object") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return { detail: String(value) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProfileItems(value) {
|
||||||
|
const items = normalizeItems(value);
|
||||||
|
if (items.length || !value || typeof value !== "object") {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
return Array.isArray(value.profiles) ? value.profiles : [];
|
||||||
|
}
|
||||||
|
|
||||||
function opsRelativePath(operationPath) {
|
function opsRelativePath(operationPath) {
|
||||||
const prefix = "/v1/admin/ops-center";
|
const prefix = "/v1/admin/ops-center";
|
||||||
if (!operationPath.startsWith(prefix)) {
|
if (!operationPath.startsWith(prefix)) {
|
||||||
|
|||||||
384
ops-center/src/components/UserProfileDetail.jsx
Normal file
384
ops-center/src/components/UserProfileDetail.jsx
Normal file
@ -0,0 +1,384 @@
|
|||||||
|
import Skeleton from "@mui/material/Skeleton";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
||||||
|
import { formatMultiplierFromPPM, formatNumber, formatPercentFromPPM } from "../format.js";
|
||||||
|
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
|
||||||
|
|
||||||
|
export function UserProfileDetail({ detailState, onLoadMore, onRetry, profile }) {
|
||||||
|
return (
|
||||||
|
<div className="admin-row ops-user-profile-detail-row">
|
||||||
|
<div className="ops-user-profile-detail-cell">
|
||||||
|
{!detailState || detailState.loading ? <DetailSkeleton /> : null}
|
||||||
|
{detailState?.error ? (
|
||||||
|
<div className="ops-user-profile-detail-error" role="alert">
|
||||||
|
<span>{detailState.error}</span>
|
||||||
|
<Button size="small" onClick={onRetry}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{detailState?.data && !detailState.loading ? (
|
||||||
|
<ProfileDetailContent
|
||||||
|
data={detailState.data}
|
||||||
|
fallbackProfile={profile}
|
||||||
|
loadingMore={detailState.loadingMore}
|
||||||
|
onLoadMore={onLoadMore}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileDetailContent({ data, fallbackProfile, loadingMore, onLoadMore }) {
|
||||||
|
const profile = data.profile?.key ? data.profile : fallbackProfile;
|
||||||
|
const distribution = [...(data.multiplierDistribution || [])].sort(
|
||||||
|
(left, right) => right.multiplierPpm - left.multiplierPpm,
|
||||||
|
);
|
||||||
|
const jackpotHits = data.jackpotHits || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ops-user-profile-detail">
|
||||||
|
<section className="ops-user-profile-detail__summary" aria-label="用户控制状态详情">
|
||||||
|
<DetailMetric label="最近开奖阶段" value={stageLabel(profile.stage)} />
|
||||||
|
<DetailMetric label="连续未中奖" value={`${formatNumber(profile.lossStreak)} 次`} />
|
||||||
|
<DetailMetric label="等价抽数" value={formatNumber(profile.equivalentDraws)} />
|
||||||
|
<DetailMetric
|
||||||
|
label="保底状态"
|
||||||
|
value={
|
||||||
|
profile.guaranteeDrawsRemaining > 0
|
||||||
|
? `还差 ${formatNumber(profile.guaranteeDrawsRemaining)} 抽`
|
||||||
|
: "保护剩余 0 抽"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DetailMetric
|
||||||
|
label="24 小时降权"
|
||||||
|
value={
|
||||||
|
profile.downweightActive
|
||||||
|
? `已触发,普通中奖保留 ${formatPercentFromPPM(profile.user24hOrdinaryWinFactorPpm)}`
|
||||||
|
: "未触发"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DetailMetric label="待兑消费大奖" value={`${formatNumber(profile.pendingCumulativeSpendTokens)} 次`} />
|
||||||
|
<DetailMetric label="累计有效抽数" value={`${formatNumber(profile.paidDraws)} 抽`} />
|
||||||
|
<DetailMetric label="基础返奖" value={`${formatNumber(profile.baseRewardCoins)} 金币`} />
|
||||||
|
<DetailMetric label="保底命中" value={`${formatNumber(profile.guaranteeHitCount)} 次`} />
|
||||||
|
<DetailMetric
|
||||||
|
label="历史最高单次"
|
||||||
|
value={`${formatMultiplierFromPPM(profile.maxMultiplierPpm)} / ${formatNumber(profile.maxRewardCoins)} 金币`}
|
||||||
|
/>
|
||||||
|
<DetailMetric label="首次抽奖" value={<TimeText value={profile.firstDrawAtMs} />} />
|
||||||
|
<DetailMetric
|
||||||
|
label="处理结果"
|
||||||
|
value={`已发 ${formatNumber(profile.grantedDrawCount)} / 待处理 ${formatNumber(
|
||||||
|
profile.pendingDrawCount,
|
||||||
|
)} / 失败 ${formatNumber(profile.failedDrawCount)}`}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="ops-user-profile-detail__section">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h3>倍率命中分布</h3>
|
||||||
|
<p>普通随机和两条大奖通道分列,倍率相同时也不会混为一次命中。</p>
|
||||||
|
</div>
|
||||||
|
<span>{distribution.length} 个倍率</span>
|
||||||
|
</header>
|
||||||
|
{distribution.length ? (
|
||||||
|
<div className="ops-profile-multiplier-table">
|
||||||
|
<div className="ops-profile-multiplier-row is-head">
|
||||||
|
<span>倍率</span>
|
||||||
|
<span>未中奖</span>
|
||||||
|
<span>普通命中</span>
|
||||||
|
<span>RTP 补偿大奖</span>
|
||||||
|
<span>累计消费大奖</span>
|
||||||
|
<span>合计命中</span>
|
||||||
|
<span>返奖金币</span>
|
||||||
|
</div>
|
||||||
|
{distribution.map((metric) => (
|
||||||
|
<div className="ops-profile-multiplier-row" key={metric.multiplierPpm}>
|
||||||
|
<strong>{formatMultiplierFromPPM(metric.multiplierPpm)}</strong>
|
||||||
|
<span>{formatNumber(metric.zeroDrawCount)}</span>
|
||||||
|
<span>{formatNumber(metric.ordinaryHitCount)}</span>
|
||||||
|
<span>{formatNumber(metric.rtpCompensationJackpotCount)}</span>
|
||||||
|
<span>{formatNumber(metric.cumulativeSpendJackpotCount)}</span>
|
||||||
|
<span>{formatNumber(metric.hitCount)}</span>
|
||||||
|
<span>{formatNumber(metric.payoutCoins)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="ops-user-profile-detail__empty">当前没有倍率命中数据</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="ops-user-profile-detail__section">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h3>大奖命中依据</h3>
|
||||||
|
<p>逐次展示走哪条大奖通道、命中时的关键门槛和实际判断结果。</p>
|
||||||
|
</div>
|
||||||
|
<span>
|
||||||
|
展示 {jackpotHits.length} / 共 {formatNumber(data.jackpotHitTotal || jackpotHits.length)} 次
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
{jackpotHits.length ? (
|
||||||
|
<>
|
||||||
|
<div className="ops-profile-jackpot-list">
|
||||||
|
{jackpotHits.map((hit, index) => (
|
||||||
|
<JackpotHit
|
||||||
|
hit={hit}
|
||||||
|
key={hit.eventKey || `${hit.drawId}:${hit.requestId}:${hit.occurredAtMs}:${index}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{data.jackpotHasMore && data.nextJackpotCursor ? (
|
||||||
|
<div className="ops-profile-jackpot-more">
|
||||||
|
<Button disabled={loadingMore} onClick={onLoadMore} size="small">
|
||||||
|
{loadingMore ? "加载中…" : "加载更早的大奖记录"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="ops-user-profile-detail__empty">该用户当前没有大奖命中记录</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailMetric({ label, value }) {
|
||||||
|
return (
|
||||||
|
<dl>
|
||||||
|
<dt>{label}</dt>
|
||||||
|
<dd>{value || "-"}</dd>
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function JackpotHit({ hit }) {
|
||||||
|
const conditions = flattenConditions(hit.conditions);
|
||||||
|
const mechanism = mechanismLabel(hit.mechanism);
|
||||||
|
return (
|
||||||
|
<article className="ops-profile-jackpot-hit">
|
||||||
|
<div className="ops-profile-jackpot-hit__head">
|
||||||
|
<div>
|
||||||
|
<OpsStatusBadge label={mechanism} tone={mechanismTone(hit.mechanism)} />
|
||||||
|
<strong>{formatMultiplierFromPPM(hit.multiplierPpm)}</strong>
|
||||||
|
<span>返奖 {formatNumber(hit.payoutCoins)} 金币</span>
|
||||||
|
</div>
|
||||||
|
<TimeText value={hit.occurredAtMs} />
|
||||||
|
</div>
|
||||||
|
<div className="ops-profile-jackpot-hit__reason">
|
||||||
|
<strong>为什么命中</strong>
|
||||||
|
<span>{reasonLabel(hit.reason, hit.reasonCode, hit.mechanism)}</span>
|
||||||
|
</div>
|
||||||
|
{conditions.length ? (
|
||||||
|
<dl className="ops-profile-jackpot-conditions">
|
||||||
|
{conditions.map((condition) => (
|
||||||
|
<div key={condition.key}>
|
||||||
|
<dt>{condition.value?.label || conditionLabel(condition.key)}</dt>
|
||||||
|
<dd>{conditionValue(condition.key, condition.value)}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
) : (
|
||||||
|
<div className="ops-profile-jackpot-hit__no-conditions">该历史记录没有保存更多判断快照</div>
|
||||||
|
)}
|
||||||
|
<footer>
|
||||||
|
<span>阶段:{stageLabel(hit.stage)}</span>
|
||||||
|
<span>规则:v{hit.ruleVersion || "-"}</span>
|
||||||
|
<span>消耗:{formatNumber(hit.wagerCoins)} 金币</span>
|
||||||
|
{hit.tierId ? <span>奖档:{hit.tierId}</span> : null}
|
||||||
|
{hit.drawId ? <span>抽奖单:{hit.drawId}</span> : null}
|
||||||
|
{hit.requestId ? <span>请求号:{hit.requestId}</span> : null}
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="ops-user-profile-detail-skeleton" aria-hidden="true">
|
||||||
|
<div>
|
||||||
|
{Array.from({ length: 6 }, (_, index) => (
|
||||||
|
<Skeleton animation="wave" height={42} key={index} variant="rounded" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Skeleton animation="wave" height={112} variant="rounded" />
|
||||||
|
<Skeleton animation="wave" height={156} variant="rounded" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenConditions(value, prefix = "") {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.filter((condition) => condition && !isInternalIdentityKey(condition.name))
|
||||||
|
.map((condition, index) => ({
|
||||||
|
key: condition.name || `condition_${index + 1}`,
|
||||||
|
value: condition,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Object.entries(value).flatMap(([key, child]) => {
|
||||||
|
const path = prefix ? `${prefix}.${key}` : key;
|
||||||
|
// stable hash、strategy user key 等仅用于 owner 内部关联,尤其外部用户不能从诊断快照泄露。
|
||||||
|
if (isInternalIdentityKey(path)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (child && typeof child === "object" && !Array.isArray(child)) {
|
||||||
|
return flattenConditions(child, path);
|
||||||
|
}
|
||||||
|
return [{ key: path, value: Array.isArray(child) ? child.join("、") : child }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInternalIdentityKey(key) {
|
||||||
|
const normalized = String(key).toLowerCase();
|
||||||
|
return ["strategy_user_id", "user_key", "stable_user", "hashed_user", "user_hash"].some((token) =>
|
||||||
|
normalized.includes(token),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mechanismLabel(mechanism) {
|
||||||
|
const normalized = String(mechanism || "").toLowerCase();
|
||||||
|
if (["rtp_compensation", "mechanism_1", "jackpot_mechanism_1", "compensation"].includes(normalized)) {
|
||||||
|
return "RTP 补偿大奖";
|
||||||
|
}
|
||||||
|
if (["cumulative_spend", "spend_milestone", "mechanism_2", "jackpot_mechanism_2", "spend"].includes(normalized)) {
|
||||||
|
return "累计消费大奖";
|
||||||
|
}
|
||||||
|
return mechanism || "大奖";
|
||||||
|
}
|
||||||
|
|
||||||
|
function mechanismTone(mechanism) {
|
||||||
|
return mechanismLabel(mechanism) === "累计消费大奖" ? "warning" : "succeeded";
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasonLabel(reason, reasonCode, mechanism) {
|
||||||
|
const normalized = String(reason || "").trim();
|
||||||
|
const known = {
|
||||||
|
cumulative_spend_token_redeemed: "累计消费达到门槛后生成大奖机会,本抽满足支付条件并完成兑付。",
|
||||||
|
jackpot_token_redeemed: "此前生成的大奖机会在本抽满足支付条件并完成兑付。",
|
||||||
|
milestone_token_jackpot: "用户累计消费已获得一次大奖机会,且奖池余额足够支付,因此触发累计消费大奖。",
|
||||||
|
rtp_compensation_eligible: "用户和全局 RTP 等补偿条件均满足,本抽进入 RTP 补偿大奖通道并命中。",
|
||||||
|
rtp_compensation_jackpot: "用户与奖池均满足补偿条件,且奖池余额足够支付,因此触发 RTP 补偿大奖。",
|
||||||
|
rtp_compensation_token_redeemed: "RTP 补偿机会已生成,本抽满足奖池支付条件并完成兑付。",
|
||||||
|
};
|
||||||
|
const normalizedCode = String(reasonCode || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (known[normalizedCode] || known[normalized.toLowerCase()] || normalized) {
|
||||||
|
return known[normalizedCode] || known[normalized.toLowerCase()] || normalized;
|
||||||
|
}
|
||||||
|
return mechanismLabel(mechanism) === "累计消费大奖"
|
||||||
|
? "累计消费达到规则门槛,生成的大奖机会在本抽完成兑付。"
|
||||||
|
: "用户和全局 RTP、奖池水位等门槛均允许,RTP 补偿机会在本抽完成兑付。";
|
||||||
|
}
|
||||||
|
|
||||||
|
function conditionLabel(key) {
|
||||||
|
const normalized = String(key).split(".").pop()?.toLowerCase() || key;
|
||||||
|
const labels = {
|
||||||
|
pool_balance_coins: "命中前奖池余额",
|
||||||
|
pool_payable: "奖池能否支付",
|
||||||
|
global_rtp_ppm: "命中前全局 RTP",
|
||||||
|
global_rtp_max_ppm: "全局 RTP 上限",
|
||||||
|
user_24h_rtp_ppm: "命中前用户 24 小时 RTP",
|
||||||
|
user_48h_rtp_ppm: "命中前用户 48 小时 RTP",
|
||||||
|
user_48h_rtp_max_ppm: "用户 48 小时 RTP 上限",
|
||||||
|
user_round_rtp_ppm: "命中前用户本轮 RTP",
|
||||||
|
user_round_rtp_max_ppm: "用户本轮 RTP 上限",
|
||||||
|
daily_hit_count: "当日已中大奖次数",
|
||||||
|
daily_hit_limit: "单日大奖次数上限",
|
||||||
|
cumulative_spend_coins: "累计消费金币",
|
||||||
|
cumulative_spend_threshold_coins: "累计消费门槛",
|
||||||
|
pending_token_count: "命中前待兑大奖机会",
|
||||||
|
multiplier_ppm: "命中倍率",
|
||||||
|
stage: "命中阶段",
|
||||||
|
eligible: "是否满足条件",
|
||||||
|
allowed: "是否允许发放",
|
||||||
|
};
|
||||||
|
return labels[normalized] || humanizeConditionKey(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function conditionValue(key, value) {
|
||||||
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
const parts = [];
|
||||||
|
const hasFriendlyValues = Boolean(value.actual || value.threshold);
|
||||||
|
if (hasFriendlyValues) {
|
||||||
|
parts.push(
|
||||||
|
[value.actual ? `实际 ${value.actual}` : "", operatorLabel(value.operator), value.threshold || ""]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" "),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasFriendlyValues && value.denominator > 0) {
|
||||||
|
parts.push(`${formatNumber(value.numerator)} / ${formatNumber(value.denominator)}`);
|
||||||
|
}
|
||||||
|
if (!hasFriendlyValues && (value.ratioPpm || value.limitPpm)) {
|
||||||
|
parts.push(`实际 ${formatPercentFromPPM(value.ratioPpm)} / 门槛 ${formatPercentFromPPM(value.limitPpm)}`);
|
||||||
|
}
|
||||||
|
if (value.factorPpm) {
|
||||||
|
parts.push(`权重 ${formatPercentFromPPM(value.factorPpm)}`);
|
||||||
|
}
|
||||||
|
parts.push(value.passed ? "通过" : "未通过");
|
||||||
|
if (value.reason) {
|
||||||
|
parts.push(value.reason);
|
||||||
|
}
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
const normalized = String(key).toLowerCase();
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return value ? "是" : "否";
|
||||||
|
}
|
||||||
|
if (value === null || value === undefined || value === "") {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
if (normalized.endsWith("_ppm") || normalized.includes("rtp_ppm")) {
|
||||||
|
return formatPercentFromPPM(value);
|
||||||
|
}
|
||||||
|
if (normalized.endsWith("_coins") || normalized.includes("count") || normalized.includes("limit")) {
|
||||||
|
return formatNumber(value);
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function operatorLabel(value) {
|
||||||
|
const labels = {
|
||||||
|
eq: "等于",
|
||||||
|
gt: "高于",
|
||||||
|
gte: "不低于",
|
||||||
|
lt: "低于",
|
||||||
|
lte: "不高于",
|
||||||
|
};
|
||||||
|
const normalized = String(value || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
return labels[normalized] || value || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanizeConditionKey(key) {
|
||||||
|
return String(key)
|
||||||
|
.replaceAll("_", " ")
|
||||||
|
.replace(/\bRtp\b/gi, "RTP")
|
||||||
|
.replace(/\bPpm\b/gi, "百分比")
|
||||||
|
.replace(/\bCoins\b/gi, "金币");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageLabel(stage) {
|
||||||
|
const normalized = String(stage || "").toLowerCase();
|
||||||
|
if (["novice", "newbie", "new"].includes(normalized)) {
|
||||||
|
return "新手阶段";
|
||||||
|
}
|
||||||
|
if (["normal", "ordinary"].includes(normalized)) {
|
||||||
|
return "普通阶段";
|
||||||
|
}
|
||||||
|
if (["advanced", "high", "high_value", "premium"].includes(normalized)) {
|
||||||
|
return "高价阶段";
|
||||||
|
}
|
||||||
|
return stage || "未记录";
|
||||||
|
}
|
||||||
268
ops-center/src/components/UserProfileTable.jsx
Normal file
268
ops-center/src/components/UserProfileTable.jsx
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
|
||||||
|
import KeyboardArrowRightOutlined from "@mui/icons-material/KeyboardArrowRightOutlined";
|
||||||
|
import PublicOutlined from "@mui/icons-material/PublicOutlined";
|
||||||
|
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||||
|
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
||||||
|
import { formatNumber, formatPercentFromPPM } from "../format.js";
|
||||||
|
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
|
||||||
|
|
||||||
|
export function buildUserProfileColumns(expandedKey) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
fixed: "left",
|
||||||
|
key: "identity",
|
||||||
|
label: "用户身份",
|
||||||
|
render: (profile) => <UserIdentityCell expanded={expandedKey === profile.key} profile={profile} />,
|
||||||
|
width: "270px",
|
||||||
|
},
|
||||||
|
windowColumn("one_hour", "近 1 小时", (profile) => profile.windows.oneHour),
|
||||||
|
windowColumn("twenty_four_hours", "近 24 小时", (profile) => profile.windows.twentyFourHours),
|
||||||
|
windowColumn("forty_eight_hours", "近 48 小时", (profile) => profile.windows.fortyEightHours),
|
||||||
|
windowColumn("lifetime", "累计", (profile) => profile.windows.lifetime),
|
||||||
|
{
|
||||||
|
key: "control_state",
|
||||||
|
label: "最近开奖阶段 / 连续未中",
|
||||||
|
render: (profile) => <ControlStateCell profile={profile} />,
|
||||||
|
width: "190px",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "downweight",
|
||||||
|
label: "24 小时 RTP 降权",
|
||||||
|
render: (profile) => <DownweightCell profile={profile} />,
|
||||||
|
width: "190px",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ordinary_wins",
|
||||||
|
label: "普通中奖",
|
||||||
|
render: (profile) => (
|
||||||
|
<div className="ops-profile-counts">
|
||||||
|
<strong>{formatNumber(profile.ordinaryWinCount)} 次</strong>
|
||||||
|
<span>其中高倍率 {formatNumber(profile.highMultiplierOrdinaryWinCount)} 次</span>
|
||||||
|
<span>未中奖 {formatNumber(profile.zeroDrawCount)} 次</span>
|
||||||
|
<small>普通奖与大奖通道独立统计</small>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
width: "170px",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "jackpots",
|
||||||
|
label: "两类大奖",
|
||||||
|
render: (profile) => (
|
||||||
|
<div className="ops-profile-counts">
|
||||||
|
<strong>
|
||||||
|
{formatNumber(profile.rtpCompensationJackpotCount + profile.cumulativeSpendJackpotCount)} 次
|
||||||
|
</strong>
|
||||||
|
<span>RTP 补偿 {formatNumber(profile.rtpCompensationJackpotCount)}</span>
|
||||||
|
<span>累计消费 {formatNumber(profile.cumulativeSpendJackpotCount)}</span>
|
||||||
|
<small>点击行查看每次命中依据</small>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
width: "180px",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "last_draw_at_ms",
|
||||||
|
label: "最近抽奖",
|
||||||
|
render: (profile) => (
|
||||||
|
<div className="ops-profile-time">
|
||||||
|
<TimeText value={profile.lastDrawAtMs} />
|
||||||
|
<small>
|
||||||
|
v{profile.latestRuleVersion || "-"} · {profile.latestStrategyVersion || "-"}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
width: "180px",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "data_lag_ms",
|
||||||
|
label: "画像时效",
|
||||||
|
render: (profile) => (
|
||||||
|
<div className="ops-profile-time">
|
||||||
|
<strong>{formatDuration(profile.dataLagMs)}</strong>
|
||||||
|
<small>
|
||||||
|
{profile.failedDrawCount > 0
|
||||||
|
? `失败 ${formatNumber(profile.failedDrawCount)} 抽`
|
||||||
|
: `已发 ${formatNumber(profile.grantedDrawCount)} · 待处理 ${formatNumber(profile.pendingDrawCount)}`}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
width: "150px",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function windowColumn(key, label, selectMetric) {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
render: (profile) => <WindowMetricCell metric={selectMetric(profile)} />,
|
||||||
|
width: "205px",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function WindowMetricCell({ metric }) {
|
||||||
|
return (
|
||||||
|
<div className="ops-profile-window">
|
||||||
|
<div className="ops-profile-window__head">
|
||||||
|
<strong>{metric.hasRtp ? formatObservedRTP(metric.rtpPpm) : "-"}</strong>
|
||||||
|
<small>{formatNumber(metric.drawCount)} 抽</small>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>消耗</dt>
|
||||||
|
<dd>{formatNumber(metric.wagerCoins)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>返奖</dt>
|
||||||
|
<dd>{formatNumber(metric.payoutCoins)}</dd>
|
||||||
|
</div>
|
||||||
|
<div className={metric.netCoins > 0 ? "is-profit" : metric.netCoins < 0 ? "is-loss" : ""}>
|
||||||
|
<dt>用户净输赢</dt>
|
||||||
|
<dd>{formatSignedNumber(metric.netCoins)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserIdentityCell({ expanded, profile }) {
|
||||||
|
return (
|
||||||
|
<div className="ops-profile-identity">
|
||||||
|
<span className="ops-profile-expand" aria-hidden="true">
|
||||||
|
{expanded ? (
|
||||||
|
<KeyboardArrowDownOutlined fontSize="small" />
|
||||||
|
) : (
|
||||||
|
<KeyboardArrowRightOutlined fontSize="small" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{profile.identityType === "external" ? (
|
||||||
|
<div className="ops-profile-external">
|
||||||
|
<span className="ops-profile-external__icon">
|
||||||
|
<PublicOutlined fontSize="small" />
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong>{profile.externalUserId || "-"}</strong>
|
||||||
|
<small>external_user_id</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="ops-profile-internal">
|
||||||
|
<AdminUserIdentity rows={internalIdentityRows(profile.user)} user={profile.user} />
|
||||||
|
<small>内部用户{profile.user.status ? ` · ${userStatusLabel(profile.user.status)}` : ""}</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ControlStateCell({ profile }) {
|
||||||
|
const remaining = profile.guaranteeDrawsRemaining;
|
||||||
|
return (
|
||||||
|
<div className="ops-profile-control">
|
||||||
|
<div>
|
||||||
|
<OpsStatusBadge label={stageLabel(profile.stage)} tone={stageTone(profile.stage)} />
|
||||||
|
<span>等价抽数 {formatNumber(profile.equivalentDraws)}</span>
|
||||||
|
</div>
|
||||||
|
<strong>连续未中 {formatNumber(profile.lossStreak)} 次</strong>
|
||||||
|
<small>{remaining > 0 ? `距连续未中保护 ${formatNumber(remaining)} 抽` : "连续未中保护剩余 0 抽"}</small>
|
||||||
|
{profile.pendingCumulativeSpendTokens > 0 ? (
|
||||||
|
<small className="is-warning">
|
||||||
|
待兑累计消费大奖 {formatNumber(profile.pendingCumulativeSpendTokens)} 次
|
||||||
|
</small>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DownweightCell({ profile }) {
|
||||||
|
const threshold = profile.user24hRtpThresholdPpm;
|
||||||
|
const factor = profile.user24hOrdinaryWinFactorPpm;
|
||||||
|
return (
|
||||||
|
<div className="ops-profile-downweight">
|
||||||
|
{profile.downweightActive ? (
|
||||||
|
<OpsStatusBadge label={`已降权 · 保留 ${formatPercentFromPPM(factor)}`} tone="warning" />
|
||||||
|
) : (
|
||||||
|
<OpsStatusBadge label="未触发" tone="succeeded" />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
当前{" "}
|
||||||
|
{profile.hasCurrentRtp || profile.windows.twentyFourHours.hasRtp
|
||||||
|
? formatObservedRTP(
|
||||||
|
profile.hasCurrentRtp ? profile.currentRtpPpm : profile.windows.twentyFourHours.rtpPpm,
|
||||||
|
)
|
||||||
|
: "暂无消耗"}
|
||||||
|
</span>
|
||||||
|
<small>
|
||||||
|
{threshold > 0
|
||||||
|
? `严格高于 ${formatPercentFromPPM(threshold)} 时保留 ${formatPercentFromPPM(factor)}`
|
||||||
|
: profile.downweightActive
|
||||||
|
? `普通中奖机会当前按 ${formatPercentFromPPM(factor)} 发放`
|
||||||
|
: "当前普通中奖机会未被降权"}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function internalIdentityRows(user) {
|
||||||
|
const shortID = user.defaultDisplayUserId || user.displayUserId;
|
||||||
|
return [shortID ? `短号 ${shortID}` : "", user.userId ? `长号 ${user.userId}` : ""].filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function userStatusLabel(status) {
|
||||||
|
const normalized = String(status || "").toLowerCase();
|
||||||
|
const labels = {
|
||||||
|
active: "正常",
|
||||||
|
banned: "已封禁",
|
||||||
|
disabled: "已停用",
|
||||||
|
inactive: "未激活",
|
||||||
|
};
|
||||||
|
return labels[normalized] || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageLabel(stage) {
|
||||||
|
const normalized = String(stage || "").toLowerCase();
|
||||||
|
if (normalized === "novice" || normalized === "newbie" || normalized === "new") {
|
||||||
|
return "新手阶段";
|
||||||
|
}
|
||||||
|
if (normalized === "normal" || normalized === "ordinary") {
|
||||||
|
return "普通阶段";
|
||||||
|
}
|
||||||
|
if (["advanced", "high", "high_value", "premium"].includes(normalized)) {
|
||||||
|
return "高价阶段";
|
||||||
|
}
|
||||||
|
return stage || "阶段未记录";
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageTone(stage) {
|
||||||
|
return stage ? "succeeded" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSignedNumber(value) {
|
||||||
|
const number = Number(value);
|
||||||
|
if (!Number.isFinite(number)) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return `${number > 0 ? "+" : ""}${formatNumber(number)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已有消耗时 0 ppm 是真实的 0% 返奖率,不是“暂无数据”;配置字段仍沿用通用格式化器的空值语义。
|
||||||
|
function formatObservedRTP(value) {
|
||||||
|
const number = Number(value);
|
||||||
|
if (!Number.isFinite(number) || number < 0) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return `${(number / 10_000).toFixed(2)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(value) {
|
||||||
|
const milliseconds = Math.max(0, Number(value) || 0);
|
||||||
|
if (milliseconds < 1_000) {
|
||||||
|
return "实时";
|
||||||
|
}
|
||||||
|
if (milliseconds < 60_000) {
|
||||||
|
return `${Math.floor(milliseconds / 1_000)} 秒`;
|
||||||
|
}
|
||||||
|
if (milliseconds < 3_600_000) {
|
||||||
|
return `${Math.floor(milliseconds / 60_000)} 分钟`;
|
||||||
|
}
|
||||||
|
return `${(milliseconds / 3_600_000).toFixed(1)} 小时`;
|
||||||
|
}
|
||||||
456
ops-center/src/components/UserProfilesView.jsx
Normal file
456
ops-center/src/components/UserProfilesView.jsx
Normal file
@ -0,0 +1,456 @@
|
|||||||
|
import SearchOutlined from "@mui/icons-material/SearchOutlined";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
AdminFilterResetButton,
|
||||||
|
AdminFilterSelect,
|
||||||
|
AdminListToolbar,
|
||||||
|
AdminSearchBox,
|
||||||
|
} from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
|
import { fetchLuckyGiftUserProfile, fetchLuckyGiftUserProfiles } from "../api.js";
|
||||||
|
import { UserProfileDetail } from "./UserProfileDetail.jsx";
|
||||||
|
import { buildUserProfileColumns } from "./UserProfileTable.jsx";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
const IDENTITY_OPTIONS = [
|
||||||
|
["all", "全部用户"],
|
||||||
|
["internal", "内部用户"],
|
||||||
|
["external", "外部接入用户"],
|
||||||
|
];
|
||||||
|
const STAGE_OPTIONS = [
|
||||||
|
["", "全部最近开奖阶段"],
|
||||||
|
["novice", "最近为新手阶段"],
|
||||||
|
["normal", "最近为普通阶段"],
|
||||||
|
["advanced", "最近为高阶阶段"],
|
||||||
|
];
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
["", "全部状态"],
|
||||||
|
["jackpot", "中出过大奖"],
|
||||||
|
["loss_streak", "连续未中奖"],
|
||||||
|
["pending_token", "有待兑消费大奖"],
|
||||||
|
];
|
||||||
|
const SORT_OPTIONS = [
|
||||||
|
["last_draw_at", "最近抽奖"],
|
||||||
|
["lifetime_rtp", "累计 RTP"],
|
||||||
|
["lifetime_wager", "累计消耗"],
|
||||||
|
["lifetime_payout", "累计返奖"],
|
||||||
|
["jackpot_count", "大奖次数"],
|
||||||
|
["loss_streak", "连续未中奖"],
|
||||||
|
];
|
||||||
|
const SORT_DIRECTION_OPTIONS = [
|
||||||
|
["desc", "从高到低 / 从新到旧"],
|
||||||
|
["asc", "从低到高 / 从旧到新"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function UserProfilesView({ apps, defaultAppCode, poolIDsByApp }) {
|
||||||
|
const [appCode, setAppCode] = useState(defaultAppCode);
|
||||||
|
const [poolId, setPoolId] = useState("");
|
||||||
|
const [identityType, setIdentityType] = useState("all");
|
||||||
|
const [stage, setStage] = useState("");
|
||||||
|
const [status, setStatus] = useState("");
|
||||||
|
const [sortBy, setSortBy] = useState("last_draw_at");
|
||||||
|
const [sortDirection, setSortDirection] = useState("desc");
|
||||||
|
const [keywordDraft, setKeywordDraft] = useState("");
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
const [expandedKey, setExpandedKey] = useState("");
|
||||||
|
const [details, setDetails] = useState({});
|
||||||
|
// 页码只负责兼容通用 DataTable 的滚动触发;真正翻页使用 owner 返回的不可解释 cursor,
|
||||||
|
// 因此并发开奖插入新画像时不会因为 OFFSET 位移而把同一用户追加两次。
|
||||||
|
const cursorsByPage = useRef({ 1: "" });
|
||||||
|
const [state, setState] = useState({
|
||||||
|
error: "",
|
||||||
|
hasMore: false,
|
||||||
|
items: [],
|
||||||
|
loading: false,
|
||||||
|
snapshotAtMs: 0,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const appOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
apps.map((item) => [
|
||||||
|
item.app_code ?? item.appCode,
|
||||||
|
`${item.app_name ?? item.appName ?? ""} (${item.app_code ?? item.appCode})`,
|
||||||
|
]),
|
||||||
|
[apps],
|
||||||
|
);
|
||||||
|
const poolIDs = useMemo(() => poolIDsByApp.get(appCode) || [], [appCode, poolIDsByApp]);
|
||||||
|
const poolOptions = useMemo(() => poolIDs.map((value) => [value, value]), [poolIDs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAppCode((current) => current || defaultAppCode);
|
||||||
|
}, [defaultAppCode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 奖池是画像聚合主键的一部分。应用切换或配置刚发布后若当前奖池不再存在,自动落到该应用第一口池,
|
||||||
|
// 不带空 pool_id 请求,避免后端退化成跨池扫描并把两个独立奖池的用户数据混在一起。
|
||||||
|
setPoolId((current) => (current && poolIDs.includes(current) ? current : poolIDs[0] || ""));
|
||||||
|
}, [poolIDs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!appCode || !poolId) {
|
||||||
|
setState({
|
||||||
|
error: "",
|
||||||
|
hasMore: false,
|
||||||
|
items: [],
|
||||||
|
loading: false,
|
||||||
|
snapshotAtMs: 0,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setState((current) => ({ ...current, error: "", loading: true }));
|
||||||
|
fetchLuckyGiftUserProfiles({
|
||||||
|
appCode,
|
||||||
|
cursor: page > 1 ? cursorsByPage.current[page] || "" : "",
|
||||||
|
identityType,
|
||||||
|
keyword,
|
||||||
|
page,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
poolId,
|
||||||
|
sortBy,
|
||||||
|
sortDirection,
|
||||||
|
stage,
|
||||||
|
status,
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.hasMore && result.nextCursor) {
|
||||||
|
cursorsByPage.current[page + 1] = result.nextCursor;
|
||||||
|
} else {
|
||||||
|
delete cursorsByPage.current[page + 1];
|
||||||
|
}
|
||||||
|
setState((current) => ({
|
||||||
|
error: "",
|
||||||
|
hasMore: result.hasMore && Boolean(result.nextCursor),
|
||||||
|
// cursor 保证顺序稳定,前端仍按身份键防御性去重,避免故障代理重复响应污染 React row key。
|
||||||
|
items: page > 1 ? mergeProfiles(current.items, result.items) : result.items,
|
||||||
|
loading: false,
|
||||||
|
snapshotAtMs: result.snapshotAtMs,
|
||||||
|
total: result.total,
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState((current) => ({
|
||||||
|
...current,
|
||||||
|
error: error.message || "获取用户画像失败",
|
||||||
|
loading: false,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [appCode, identityType, keyword, page, poolId, reloadKey, sortBy, sortDirection, stage, status]);
|
||||||
|
|
||||||
|
const resetQueryState = useCallback(() => {
|
||||||
|
cursorsByPage.current = { 1: "" };
|
||||||
|
setPage(1);
|
||||||
|
setExpandedKey("");
|
||||||
|
setDetails({});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reloadProfiles = useCallback(() => {
|
||||||
|
cursorsByPage.current = { 1: "" };
|
||||||
|
setPage(1);
|
||||||
|
setExpandedKey("");
|
||||||
|
setDetails({});
|
||||||
|
setReloadKey((current) => current + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const changeAppCode = useCallback(
|
||||||
|
(nextAppCode) => {
|
||||||
|
setAppCode(nextAppCode);
|
||||||
|
setPoolId((poolIDsByApp.get(nextAppCode) || [])[0] || "");
|
||||||
|
resetQueryState();
|
||||||
|
},
|
||||||
|
[poolIDsByApp, resetQueryState],
|
||||||
|
);
|
||||||
|
|
||||||
|
const changePoolId = useCallback(
|
||||||
|
(nextPoolId) => {
|
||||||
|
setPoolId(nextPoolId);
|
||||||
|
resetQueryState();
|
||||||
|
},
|
||||||
|
[resetQueryState],
|
||||||
|
);
|
||||||
|
|
||||||
|
const changeIdentityType = useCallback(
|
||||||
|
(nextIdentityType) => {
|
||||||
|
setIdentityType(nextIdentityType);
|
||||||
|
setKeywordDraft("");
|
||||||
|
setKeyword("");
|
||||||
|
resetQueryState();
|
||||||
|
},
|
||||||
|
[resetQueryState],
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitSearch = useCallback(
|
||||||
|
(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setKeyword(keywordDraft.trim());
|
||||||
|
resetQueryState();
|
||||||
|
},
|
||||||
|
[keywordDraft, resetQueryState],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetFilters = useCallback(() => {
|
||||||
|
setIdentityType("all");
|
||||||
|
setStage("");
|
||||||
|
setStatus("");
|
||||||
|
setSortBy("last_draw_at");
|
||||||
|
setSortDirection("desc");
|
||||||
|
setKeywordDraft("");
|
||||||
|
setKeyword("");
|
||||||
|
resetQueryState();
|
||||||
|
}, [resetQueryState]);
|
||||||
|
|
||||||
|
const loadDetail = useCallback(async (profile, jackpotCursor = "", append = false) => {
|
||||||
|
const key = profile.key;
|
||||||
|
setDetails((current) => ({
|
||||||
|
...current,
|
||||||
|
[key]: {
|
||||||
|
data: current[key]?.data,
|
||||||
|
error: "",
|
||||||
|
loading: !append,
|
||||||
|
loadingMore: append,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
const data = await fetchLuckyGiftUserProfile({
|
||||||
|
appCode: profile.appCode,
|
||||||
|
externalUserId: profile.externalUserId,
|
||||||
|
identityType: profile.identityType,
|
||||||
|
jackpotCursor,
|
||||||
|
poolId: profile.poolId,
|
||||||
|
userId: profile.user.userId,
|
||||||
|
});
|
||||||
|
setDetails((current) => {
|
||||||
|
const previous = current[key]?.data;
|
||||||
|
const nextData =
|
||||||
|
append && previous
|
||||||
|
? {
|
||||||
|
...data,
|
||||||
|
// owner cursor 已固定在最后一条大奖事实之后;前端再按事实键去重,防止代理重放响应,
|
||||||
|
// 同时不吞掉 request_id 为空的历史记录。
|
||||||
|
jackpotHits: mergeJackpotHits(previous.jackpotHits, data.jackpotHits),
|
||||||
|
multiplierDistribution: previous.multiplierDistribution,
|
||||||
|
profile: previous.profile,
|
||||||
|
}
|
||||||
|
: data;
|
||||||
|
return { ...current, [key]: { data: nextData, error: "", loading: false, loadingMore: false } };
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setDetails((current) => ({
|
||||||
|
...current,
|
||||||
|
[key]: {
|
||||||
|
data: current[key]?.data,
|
||||||
|
error: error.message || "获取画像明细失败",
|
||||||
|
loading: false,
|
||||||
|
loadingMore: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadMoreDetail = useCallback(
|
||||||
|
(profile) => {
|
||||||
|
const detail = details[profile.key]?.data;
|
||||||
|
if (!detail?.jackpotHasMore || !detail.nextJackpotCursor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadDetail(profile, detail.nextJackpotCursor, true);
|
||||||
|
},
|
||||||
|
[details, loadDetail],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleProfile = useCallback(
|
||||||
|
(profile) => {
|
||||||
|
if (expandedKey === profile.key) {
|
||||||
|
setExpandedKey("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExpandedKey(profile.key);
|
||||||
|
if (!details[profile.key]?.data && !details[profile.key]?.loading) {
|
||||||
|
loadDetail(profile);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[details, expandedKey, loadDetail],
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = useMemo(() => buildUserProfileColumns(expandedKey), [expandedKey]);
|
||||||
|
const loadMoreProfiles = useCallback(() => {
|
||||||
|
if (!state.loading && state.hasMore) {
|
||||||
|
setPage((current) => current + 1);
|
||||||
|
}
|
||||||
|
}, [state.hasMore, state.loading]);
|
||||||
|
const hasActiveFilters =
|
||||||
|
identityType !== "all" ||
|
||||||
|
Boolean(stage || status || keywordDraft || keyword) ||
|
||||||
|
sortBy !== "last_draw_at" ||
|
||||||
|
sortDirection !== "desc";
|
||||||
|
const searchPlaceholder =
|
||||||
|
identityType === "external"
|
||||||
|
? "只支持 external_user_id"
|
||||||
|
: identityType === "internal"
|
||||||
|
? "短号 / 长号 / 靓号"
|
||||||
|
: "短号 / 长号 / 靓号 / external_user_id";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ops-view ops-user-profiles">
|
||||||
|
<AdminListToolbar
|
||||||
|
filters={
|
||||||
|
<>
|
||||||
|
<AdminFilterSelect label="应用" options={appOptions} value={appCode} onChange={changeAppCode} />
|
||||||
|
<AdminFilterSelect label="奖池" options={poolOptions} value={poolId} onChange={changePoolId} />
|
||||||
|
<AdminFilterSelect
|
||||||
|
label="用户来源"
|
||||||
|
options={IDENTITY_OPTIONS}
|
||||||
|
value={identityType}
|
||||||
|
onChange={changeIdentityType}
|
||||||
|
/>
|
||||||
|
<AdminFilterSelect
|
||||||
|
label="最近开奖阶段"
|
||||||
|
options={STAGE_OPTIONS}
|
||||||
|
value={stage}
|
||||||
|
onChange={(value) => {
|
||||||
|
setStage(value);
|
||||||
|
resetQueryState();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<AdminFilterSelect
|
||||||
|
label="问题定位"
|
||||||
|
options={STATUS_OPTIONS}
|
||||||
|
value={status}
|
||||||
|
onChange={(value) => {
|
||||||
|
setStatus(value);
|
||||||
|
resetQueryState();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<AdminFilterSelect
|
||||||
|
label="排序指标"
|
||||||
|
options={SORT_OPTIONS}
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(value) => {
|
||||||
|
setSortBy(value);
|
||||||
|
resetQueryState();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<AdminFilterSelect
|
||||||
|
label="排序方向"
|
||||||
|
options={SORT_DIRECTION_OPTIONS}
|
||||||
|
value={sortDirection}
|
||||||
|
onChange={(value) => {
|
||||||
|
setSortDirection(value);
|
||||||
|
resetQueryState();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<form className="ops-user-profile-search" onSubmit={submitSearch}>
|
||||||
|
<AdminSearchBox
|
||||||
|
label="用户"
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
value={keywordDraft}
|
||||||
|
onChange={setKeywordDraft}
|
||||||
|
/>
|
||||||
|
<Button startIcon={<SearchOutlined fontSize="small" />} type="submit" variant="primary">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<AdminFilterResetButton disabled={!hasActiveFilters} onClick={resetFilters} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{state.error && state.items.length ? (
|
||||||
|
<div className="ops-alert" role="alert">
|
||||||
|
<span>{state.error}</span>
|
||||||
|
<Button size="small" onClick={reloadProfiles}>
|
||||||
|
重新加载
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<DataState
|
||||||
|
error={state.items.length ? "" : state.error}
|
||||||
|
loading={state.loading && !state.items.length}
|
||||||
|
onRetry={reloadProfiles}
|
||||||
|
>
|
||||||
|
<DataTable
|
||||||
|
className="ops-user-profile-table"
|
||||||
|
columns={columns}
|
||||||
|
emptyLabel="当前无数据"
|
||||||
|
getRowProps={(profile) => ({
|
||||||
|
"aria-expanded": expandedKey === profile.key,
|
||||||
|
className:
|
||||||
|
expandedKey === profile.key ? "ops-user-profile-row is-expanded" : "ops-user-profile-row",
|
||||||
|
role: "button",
|
||||||
|
tabIndex: 0,
|
||||||
|
onClick: () => toggleProfile(profile),
|
||||||
|
onKeyDown: (event) => {
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
toggleProfile(profile);
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
items={state.items}
|
||||||
|
infiniteScroll={{
|
||||||
|
doneLabel: state.items.length ? "已加载全部用户" : "",
|
||||||
|
hasMore: !state.error && state.hasMore,
|
||||||
|
loading: state.loading && state.items.length > 0,
|
||||||
|
onLoadMore: loadMoreProfiles,
|
||||||
|
}}
|
||||||
|
minWidth="1830px"
|
||||||
|
renderRowDetail={(profile) =>
|
||||||
|
expandedKey === profile.key ? (
|
||||||
|
<UserProfileDetail
|
||||||
|
detailState={details[profile.key]}
|
||||||
|
key={`${profile.key}:detail`}
|
||||||
|
onLoadMore={() => loadMoreDetail(profile)}
|
||||||
|
profile={profile}
|
||||||
|
onRetry={() => loadDetail(profile)}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
rowKey={(profile) => profile.key}
|
||||||
|
tableClassName="ops-user-profile-grid"
|
||||||
|
title={`${appCode || "-"} / ${poolId || "-"} 用户画像${state.loading ? "(更新中)" : ""}`}
|
||||||
|
total={state.total}
|
||||||
|
/>
|
||||||
|
</DataState>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeProfiles(previous = [], next = []) {
|
||||||
|
const byKey = new Map(previous.map((profile) => [profile.key, profile]));
|
||||||
|
next.forEach((profile) => byKey.set(profile.key, profile));
|
||||||
|
return [...byKey.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeJackpotHits(previous = [], next = []) {
|
||||||
|
const seen = new Set(previous.map(jackpotEventKey));
|
||||||
|
return [
|
||||||
|
...previous,
|
||||||
|
...next.filter((hit) => {
|
||||||
|
const key = jackpotEventKey(hit);
|
||||||
|
if (seen.has(key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function jackpotEventKey(hit) {
|
||||||
|
return hit.eventKey || `${hit.drawId}:${hit.requestId}:${hit.occurredAtMs}:${hit.mechanism}`;
|
||||||
|
}
|
||||||
@ -808,6 +808,479 @@ body {
|
|||||||
padding: var(--space-2) var(--space-6);
|
padding: var(--space-2) var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- 用户画像:四个统计窗口在单元格内纵向收敛,横向只保留控制链路需要对比的维度 ---- */
|
||||||
|
|
||||||
|
.ops-user-profile-search {
|
||||||
|
display: flex;
|
||||||
|
min-width: 360px;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-user-profile-search .search-box {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-user-profile-table {
|
||||||
|
min-height: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-user-profile-grid {
|
||||||
|
--table-row-height: 112px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-user-profile-row {
|
||||||
|
transition:
|
||||||
|
background var(--motion-fast) var(--ease-standard),
|
||||||
|
box-shadow var(--motion-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-user-profile-row:hover,
|
||||||
|
.ops-user-profile-row.is-expanded {
|
||||||
|
background: var(--primary-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-user-profile-row:focus-visible {
|
||||||
|
z-index: 2;
|
||||||
|
outline: 2px solid var(--primary);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-identity {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-expand {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 22px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-internal,
|
||||||
|
.ops-profile-external {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-internal {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-internal > small {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding-left: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-external__icon {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
flex: 0 0 34px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--info-surface);
|
||||||
|
color: var(--info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-external > span:last-child {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-external strong,
|
||||||
|
.ops-profile-external small {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-external strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-external small {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-window {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-window__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-window__head strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 16px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-window__head small {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-window dl {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-window dl > div {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-window dt,
|
||||||
|
.ops-profile-window dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-window dd {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 700;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-window .is-profit dd {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-window .is-loss dd {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-control,
|
||||||
|
.ops-profile-downweight,
|
||||||
|
.ops-profile-counts,
|
||||||
|
.ops-profile-time {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
align-content: center;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-control > div {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-control > div > span:last-child,
|
||||||
|
.ops-profile-control small,
|
||||||
|
.ops-profile-downweight small,
|
||||||
|
.ops-profile-counts small,
|
||||||
|
.ops-profile-time small {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-control strong,
|
||||||
|
.ops-profile-counts strong,
|
||||||
|
.ops-profile-time strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-control .is-warning {
|
||||||
|
color: var(--warning);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-downweight > span,
|
||||||
|
.ops-profile-counts > span,
|
||||||
|
.ops-profile-time > span {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-user-profile-detail-row {
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--fill-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-user-profile-detail-cell {
|
||||||
|
min-width: 0;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
padding: 0 var(--space-4) var(--space-4) 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-user-profile-detail {
|
||||||
|
display: grid;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-user-profile-detail__summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(140px, 1fr));
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-user-profile-detail__summary dl {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-user-profile-detail__summary dl:last-child {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-user-profile-detail__summary dt {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-user-profile-detail__summary dd {
|
||||||
|
margin: var(--space-1) 0 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-user-profile-detail__section {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: var(--space-4) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-user-profile-detail__section:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-user-profile-detail__section > header {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-user-profile-detail__section h3,
|
||||||
|
.ops-user-profile-detail__section p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-user-profile-detail__section h3 {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-user-profile-detail__section p,
|
||||||
|
.ops-user-profile-detail__section > header > span {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-user-profile-detail__section p {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-multiplier-table {
|
||||||
|
display: grid;
|
||||||
|
min-width: 820px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-top: 1px solid var(--row-border);
|
||||||
|
border-bottom: 1px solid var(--row-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-multiplier-row {
|
||||||
|
display: grid;
|
||||||
|
min-height: 34px;
|
||||||
|
align-items: center;
|
||||||
|
grid-template-columns: repeat(7, minmax(112px, 1fr));
|
||||||
|
border-bottom: 1px solid var(--row-border);
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-multiplier-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-multiplier-row.is-head {
|
||||||
|
background: var(--bg-card-strong);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-multiplier-row strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-jackpot-list {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-jackpot-more {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
border-top: 1px solid var(--row-border);
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-jackpot-hit {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: var(--space-3);
|
||||||
|
border-top: 1px solid var(--row-border);
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-jackpot-hit__head,
|
||||||
|
.ops-profile-jackpot-hit__head > div,
|
||||||
|
.ops-profile-jackpot-hit footer {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-jackpot-hit__head {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-jackpot-hit__head strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-jackpot-hit__head > span,
|
||||||
|
.ops-profile-jackpot-hit__head div > span,
|
||||||
|
.ops-profile-jackpot-hit footer {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-jackpot-hit__reason {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 92px minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: var(--space-3);
|
||||||
|
border-left: 3px solid var(--primary);
|
||||||
|
background: var(--primary-surface);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-jackpot-hit__reason strong {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-jackpot-conditions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(160px, 1fr));
|
||||||
|
gap: 1px;
|
||||||
|
margin: 0;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-jackpot-conditions > div {
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--bg-card);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-jackpot-conditions dt {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 10px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-jackpot-conditions dd {
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 2px 0 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-jackpot-hit footer {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-profile-jackpot-hit__no-conditions,
|
||||||
|
.ops-user-profile-detail__empty {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 11px;
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-user-profile-detail-skeleton {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-user-profile-detail-skeleton > div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(120px, 1fr));
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-user-profile-detail-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
border-top: 1px solid var(--danger-border);
|
||||||
|
background: var(--danger-surface);
|
||||||
|
color: var(--danger);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- 响应式 ---- */
|
/* ---- 响应式 ---- */
|
||||||
|
|
||||||
@media (max-width: 1279px) {
|
@media (max-width: 1279px) {
|
||||||
|
|||||||
@ -148,6 +148,7 @@ export const API_OPERATIONS = {
|
|||||||
getJob: "getJob",
|
getJob: "getJob",
|
||||||
getLuckyGiftConfig: "getLuckyGiftConfig",
|
getLuckyGiftConfig: "getLuckyGiftConfig",
|
||||||
getLuckyGiftDrawSummary: "getLuckyGiftDrawSummary",
|
getLuckyGiftDrawSummary: "getLuckyGiftDrawSummary",
|
||||||
|
getLuckyGiftUserProfile: "getLuckyGiftUserProfile",
|
||||||
getRechargeBillOverview: "getRechargeBillOverview",
|
getRechargeBillOverview: "getRechargeBillOverview",
|
||||||
getRechargeBillSummary: "getRechargeBillSummary",
|
getRechargeBillSummary: "getRechargeBillSummary",
|
||||||
getRedPacket: "getRedPacket",
|
getRedPacket: "getRedPacket",
|
||||||
@ -236,6 +237,7 @@ export const API_OPERATIONS = {
|
|||||||
listLuckyGiftConfigs: "listLuckyGiftConfigs",
|
listLuckyGiftConfigs: "listLuckyGiftConfigs",
|
||||||
listLuckyGiftDraws: "listLuckyGiftDraws",
|
listLuckyGiftDraws: "listLuckyGiftDraws",
|
||||||
listLuckyGiftPoolBalances: "listLuckyGiftPoolBalances",
|
listLuckyGiftPoolBalances: "listLuckyGiftPoolBalances",
|
||||||
|
listLuckyGiftUserProfiles: "listLuckyGiftUserProfiles",
|
||||||
listManagers: "listManagers",
|
listManagers: "listManagers",
|
||||||
listOperationLogs: "listOperationLogs",
|
listOperationLogs: "listOperationLogs",
|
||||||
listPermissions: "listPermissions",
|
listPermissions: "listPermissions",
|
||||||
@ -1358,6 +1360,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "lucky-gift:view",
|
permission: "lucky-gift:view",
|
||||||
permissions: ["lucky-gift:view"]
|
permissions: ["lucky-gift:view"]
|
||||||
},
|
},
|
||||||
|
getLuckyGiftUserProfile: {
|
||||||
|
method: "GET",
|
||||||
|
operationId: API_OPERATIONS.getLuckyGiftUserProfile,
|
||||||
|
path: "/v1/admin/ops-center/lucky-gifts/user-profile",
|
||||||
|
permission: "lucky-gift:view",
|
||||||
|
permissions: ["lucky-gift:view"]
|
||||||
|
},
|
||||||
getRechargeBillOverview: {
|
getRechargeBillOverview: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.getRechargeBillOverview,
|
operationId: API_OPERATIONS.getRechargeBillOverview,
|
||||||
@ -1972,6 +1981,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "lucky-gift:view",
|
permission: "lucky-gift:view",
|
||||||
permissions: ["lucky-gift:view"]
|
permissions: ["lucky-gift:view"]
|
||||||
},
|
},
|
||||||
|
listLuckyGiftUserProfiles: {
|
||||||
|
method: "GET",
|
||||||
|
operationId: API_OPERATIONS.listLuckyGiftUserProfiles,
|
||||||
|
path: "/v1/admin/ops-center/lucky-gifts/user-profiles",
|
||||||
|
permission: "lucky-gift:view",
|
||||||
|
permissions: ["lucky-gift:view"]
|
||||||
|
},
|
||||||
listManagers: {
|
listManagers: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.listManagers,
|
operationId: API_OPERATIONS.listManagers,
|
||||||
|
|||||||
56
src/shared/api/generated/schema.d.ts
vendored
56
src/shared/api/generated/schema.d.ts
vendored
@ -292,6 +292,38 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/ops-center/lucky-gifts/user-profiles": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["listLuckyGiftUserProfiles"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/admin/ops-center/lucky-gifts/user-profile": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getLuckyGiftUserProfile"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/admin/ops-center/lucky-gifts/summary": {
|
"/admin/ops-center/lucky-gifts/summary": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -8329,6 +8361,30 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
listLuckyGiftUserProfiles: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
getLuckyGiftUserProfile: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
getLuckyGiftDrawSummary: {
|
getLuckyGiftDrawSummary: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user