385 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 || "未记录";
}