342 lines
14 KiB
JavaScript
342 lines
14 KiB
JavaScript
import { useCallback, useMemo, useRef, useState } from "react";
|
||
import { AdminPrettyDisplayID, AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||
import { formatMillis } from "@/shared/utils/time.js";
|
||
import { getAppUser } from "@/features/app-users/api";
|
||
import { appUserStatusLabels } from "@/features/app-users/constants.js";
|
||
import { AppUserDetailContext } from "@/features/app-users/components/AppUserDetailContext.jsx";
|
||
import {
|
||
BanCountdown,
|
||
GenderValue,
|
||
LevelValue,
|
||
OperatorValue,
|
||
RolesValue,
|
||
formatUsdMinor,
|
||
} from "@/features/app-users/components/AppUserValues.jsx";
|
||
import styles from "@/features/app-users/app-users.module.css";
|
||
|
||
export function AppUserDetailProvider({ children }) {
|
||
const { showToast } = useToast();
|
||
const requestSeqRef = useRef(0);
|
||
const [detailUser, setDetailUser] = useState(null);
|
||
const [loadingUserId, setLoadingUserId] = useState("");
|
||
|
||
const closeAppUserDetail = useCallback(() => {
|
||
requestSeqRef.current += 1;
|
||
setDetailUser(null);
|
||
setLoadingUserId("");
|
||
}, []);
|
||
|
||
const openAppUserDetail = useCallback(
|
||
async (source) => {
|
||
const seedUser = normalizeDetailSeed(source);
|
||
if (!seedUser.userId) {
|
||
showToast("缺少用户 ID,无法打开用户详情", "error");
|
||
return;
|
||
}
|
||
|
||
const requestSeq = requestSeqRef.current + 1;
|
||
requestSeqRef.current = requestSeq;
|
||
setDetailUser(seedUser);
|
||
setLoadingUserId(seedUser.userId);
|
||
|
||
try {
|
||
const latestUser = await getAppUser(seedUser.userId);
|
||
if (requestSeqRef.current === requestSeq) {
|
||
setDetailUser({ ...seedUser, ...latestUser });
|
||
}
|
||
} catch (err) {
|
||
if (requestSeqRef.current === requestSeq) {
|
||
showToast(err.message || "加载用户详情失败", "error");
|
||
}
|
||
} finally {
|
||
if (requestSeqRef.current === requestSeq) {
|
||
setLoadingUserId("");
|
||
}
|
||
}
|
||
},
|
||
[showToast],
|
||
);
|
||
|
||
const value = useMemo(() => ({ closeAppUserDetail, openAppUserDetail }), [closeAppUserDetail, openAppUserDetail]);
|
||
|
||
return (
|
||
<AppUserDetailContext.Provider value={value}>
|
||
{children}
|
||
<AppUserDetailDrawer
|
||
loading={Boolean(detailUser?.userId) && loadingUserId === detailUser.userId}
|
||
open={Boolean(detailUser)}
|
||
user={detailUser}
|
||
onClose={closeAppUserDetail}
|
||
/>
|
||
</AppUserDetailContext.Provider>
|
||
);
|
||
}
|
||
|
||
function AppUserDetailDrawer({ loading, onClose, open, user }) {
|
||
if (!open || !user) {
|
||
return null;
|
||
}
|
||
const balances = user.balances || [];
|
||
const equippedResources = user.equippedResources || [];
|
||
const resources = user.resources || [];
|
||
|
||
return (
|
||
<SideDrawer open={open} title="用户详情" width="wide" onClose={onClose}>
|
||
<div className={styles.detailHero}>
|
||
<AdminUserIdentity user={user} size="large" />
|
||
<AppUserStatus status={user.status} />
|
||
</div>
|
||
{loading ? <div className={styles.detailLoading}>正在刷新用户详情...</div> : null}
|
||
<section className={styles.detailSection}>
|
||
<h3 className={styles.detailSectionTitle}>身份信息</h3>
|
||
<div className={styles.detailGrid}>
|
||
<DetailItem label="用户 ID" value={user.userId} />
|
||
<DetailItem label="短 ID" value={user.defaultDisplayUserId || user.displayUserId || user.userId} />
|
||
<DetailItem label="靓号" value={<PrettyValue user={user} />} />
|
||
<DetailItem label="靓号记录 ID" value={user.prettyId} />
|
||
<DetailItem label="昵称" value={user.username} />
|
||
<DetailItem label="性别" value={<GenderValue gender={user.gender} />} />
|
||
<DetailItem label="生日" value={user.birth} />
|
||
<DetailItem label="年龄" value={formatAge(user.age)} />
|
||
<DetailItem label="注册设备" value={user.registerDevice} />
|
||
<DetailItem label="身份" value={<RolesValue roles={user.roles} />} />
|
||
</div>
|
||
</section>
|
||
<section className={styles.detailSection}>
|
||
<h3 className={styles.detailSectionTitle}>等级信息</h3>
|
||
<div className={styles.detailGrid}>
|
||
<DetailItem label="富豪等级" value={<LevelValue level={user.levels?.wealth} showReal />} />
|
||
<DetailItem label="魅力等级" value={<LevelValue level={user.levels?.charm} showReal />} />
|
||
<DetailItem label="游戏等级" value={<LevelValue level={user.levels?.game} showReal />} />
|
||
<DetailItem label="VIP 等级" value={<VipDetailValue vip={user.vip} />} />
|
||
<DetailItem label="VIP 到期时间" value={formatVipExpire(user.vip)} />
|
||
</div>
|
||
</section>
|
||
<section className={styles.detailSection}>
|
||
<h3 className={styles.detailSectionTitle}>账号状态</h3>
|
||
<div className={styles.detailGrid}>
|
||
<DetailItem label="状态" value={appUserStatusLabels[user.status] || user.status || "-"} />
|
||
<DetailItem label="封禁剩余" value={<BanCountdown ban={user.ban} status={user.status} />} />
|
||
<DetailItem label="封禁原因" value={user.ban?.reason} />
|
||
<DetailItem label="金币" value={formatNumber(user.coin)} />
|
||
<DetailItem label="钻石" value={formatNumber(user.diamond)} />
|
||
<DetailItem label="累计充值" value={formatUsdMinor(user.cumulativeRechargeUsdMinor)} />
|
||
<DetailItem label="国家" value={formatCountry(user)} />
|
||
<DetailItem
|
||
label="区域"
|
||
value={user.regionName || (user.regionId ? `区域 ${user.regionId}` : "-")}
|
||
/>
|
||
<DetailItem label="最新成功登录" value={formatMillis(user.lastLoginAtMs)} />
|
||
<DetailItem label="最近活跃" value={formatMillis(user.lastActiveAtMs)} />
|
||
<DetailItem
|
||
label="最新操作"
|
||
value={<OperatorValue operatedAtMs={user.lastOperatedAtMs} operator={user.lastOperator} />}
|
||
/>
|
||
<DetailItem label="创建时间" value={formatMillis(user.createdAtMs)} />
|
||
<DetailItem label="更新时间" value={formatMillis(user.updatedAtMs)} />
|
||
</div>
|
||
</section>
|
||
<section className={styles.detailSection}>
|
||
<h3 className={styles.detailSectionTitle}>资产余额</h3>
|
||
<AssetBalanceList balances={balances} />
|
||
</section>
|
||
<section className={styles.detailSection}>
|
||
<h3 className={styles.detailSectionTitle}>当前佩戴</h3>
|
||
<ResourceList resources={equippedResources} />
|
||
</section>
|
||
<section className={styles.detailSection}>
|
||
<h3 className={styles.detailSectionTitle}>资源资产</h3>
|
||
<ResourceList resources={resources} />
|
||
</section>
|
||
</SideDrawer>
|
||
);
|
||
}
|
||
|
||
function DetailItem({ label, value }) {
|
||
const isElement = value && typeof value === "object";
|
||
return (
|
||
<div className={styles.detailItem}>
|
||
<span className={styles.detailLabel}>{label}</span>
|
||
<span className={styles.detailValue}>
|
||
{isElement ? value : value === undefined || value === null || value === "" ? "-" : value}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PrettyValue({ user }) {
|
||
if (!user.prettyId || !user.prettyDisplayUserId) {
|
||
return "-";
|
||
}
|
||
return <AdminPrettyDisplayID prettyDisplayUserId={user.prettyDisplayUserId} prettyId={user.prettyId} />;
|
||
}
|
||
|
||
function VipDetailValue({ vip }) {
|
||
const level = Number(vip?.level || 0);
|
||
if (level <= 0 || !vip?.active) {
|
||
return "VIP 0";
|
||
}
|
||
return vip.name ? `VIP ${level} · ${vip.name}` : `VIP ${level}`;
|
||
}
|
||
|
||
function AssetBalanceList({ balances }) {
|
||
if (!balances.length) {
|
||
return <div className={styles.emptyInline}>当前无数据</div>;
|
||
}
|
||
return (
|
||
<div className={styles.assetList}>
|
||
{balances.map((balance) => (
|
||
<div className={styles.assetRow} key={balance.assetType}>
|
||
<div>
|
||
<div className={styles.assetName}>{formatAssetType(balance.assetType)}</div>
|
||
<div className={styles.assetMeta}>冻结 {formatNumber(balance.frozenAmount)}</div>
|
||
</div>
|
||
<div className={styles.assetAmount}>{formatNumber(balance.availableAmount)}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ResourceList({ resources }) {
|
||
if (!resources.length) {
|
||
return <div className={styles.emptyInline}>当前无数据</div>;
|
||
}
|
||
return (
|
||
<ul className={styles.resourceList}>
|
||
{resources.map((resource) => (
|
||
<li className={styles.resourceItem} key={resource.entitlementId || resource.resourceId}>
|
||
<ResourceThumb resource={resource} />
|
||
<div className={styles.resourceBody}>
|
||
<div className={styles.resourceTitle}>
|
||
<span>{resource.name || resource.resourceCode || `资源 ${resource.resourceId}`}</span>
|
||
{resource.equipped ? <span className={styles.resourceTag}>佩戴中</span> : null}
|
||
</div>
|
||
<div className={styles.resourceMeta}>
|
||
{formatResourceType(resource.resourceType)} · 剩余{" "}
|
||
{formatNumber(resource.remainingQuantity)} / {formatNumber(resource.quantity)}
|
||
</div>
|
||
<div className={styles.resourceMeta}>到期 {formatExpireTime(resource.expiresAtMs)}</div>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
);
|
||
}
|
||
|
||
function ResourceThumb({ resource }) {
|
||
const imageUrl = resource.previewUrl || resource.assetUrl || resource.animationUrl;
|
||
if (!imageUrl) {
|
||
return <span className={styles.resourceThumbFallback}>{resource.name?.slice(0, 1) || "资"}</span>;
|
||
}
|
||
return <img alt="" className={styles.resourceThumb} src={imageUrl} />;
|
||
}
|
||
|
||
function AppUserStatus({ status }) {
|
||
const tone = status === "active" ? "running" : "danger";
|
||
return (
|
||
<span className={`status-badge status-badge--${tone}`}>
|
||
<span className="status-point" />
|
||
{appUserStatusLabels[status] || status || "-"}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function normalizeDetailSeed(source) {
|
||
if (source === undefined || source === null) {
|
||
return {};
|
||
}
|
||
if (typeof source === "string" || typeof source === "number") {
|
||
return { userId: String(source).trim() };
|
||
}
|
||
|
||
const userId = firstNonEmpty(
|
||
source.userId,
|
||
source.user_id,
|
||
source.assignedUserId,
|
||
source.assigned_user_id,
|
||
source.targetUserId,
|
||
source.target_user_id,
|
||
);
|
||
|
||
return {
|
||
...source,
|
||
avatar: firstNonEmpty(source.avatar),
|
||
defaultDisplayUserId: firstNonEmpty(source.defaultDisplayUserId, source.default_display_user_id),
|
||
displayUserId: firstNonEmpty(source.displayUserId, source.display_user_id),
|
||
prettyDisplayUserId: firstNonEmpty(source.prettyDisplayUserId, source.pretty_display_user_id),
|
||
prettyId: firstNonEmpty(source.prettyId, source.pretty_id),
|
||
userId,
|
||
username: firstNonEmpty(source.username, source.name, source.account),
|
||
};
|
||
}
|
||
|
||
function formatCountry(user) {
|
||
return user.countryDisplayName || user.countryName || user.country || "-";
|
||
}
|
||
|
||
function formatNumber(value) {
|
||
const number = Number(value || 0);
|
||
return Number.isFinite(number) ? number.toLocaleString() : "-";
|
||
}
|
||
|
||
function formatAge(value) {
|
||
if (value === undefined || value === null || value === "") {
|
||
return "-";
|
||
}
|
||
const age = Number(value);
|
||
return Number.isFinite(age) && age >= 0 ? `${Math.trunc(age)} 岁` : "-";
|
||
}
|
||
|
||
function formatVipExpire(vip) {
|
||
if (Number(vip?.level || 0) <= 0) {
|
||
return "-";
|
||
}
|
||
return formatExpireTime(vip?.expiresAtMs);
|
||
}
|
||
|
||
function formatAssetType(value) {
|
||
const normalized = String(value || "").toUpperCase();
|
||
const labels = {
|
||
COIN: "金币",
|
||
DIAMOND: "钻石",
|
||
};
|
||
return labels[normalized] || normalized || "-";
|
||
}
|
||
|
||
function formatResourceType(value) {
|
||
const labels = {
|
||
avatar_frame: "头像框",
|
||
badge: "徽章",
|
||
chat_bubble: "聊天气泡",
|
||
emoji_pack: "表情包",
|
||
floating_screen: "飘屏",
|
||
gift: "礼物",
|
||
mic_seat_animation: "麦位动画",
|
||
mic_seat_icon: "麦位图标",
|
||
profile_card: "资料卡",
|
||
vehicle: "座驾",
|
||
};
|
||
return labels[value] || value || "-";
|
||
}
|
||
|
||
function formatExpireTime(value) {
|
||
const expiresAtMs = Number(value || 0);
|
||
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= 0) {
|
||
return "永久";
|
||
}
|
||
return formatMillis(expiresAtMs);
|
||
}
|
||
|
||
function firstNonEmpty(...values) {
|
||
for (const value of values) {
|
||
const normalized = String(value ?? "").trim();
|
||
if (normalized) {
|
||
return normalized;
|
||
}
|
||
}
|
||
return "";
|
||
}
|