2026-07-11 02:26:23 +08:00

342 lines
14 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 { 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 "";
}