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

660 lines
27 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 BlockOutlined from "@mui/icons-material/BlockOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import FileDownloadOutlined from "@mui/icons-material/FileDownloadOutlined";
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
import KeyboardArrowUpOutlined from "@mui/icons-material/KeyboardArrowUpOutlined";
import LockOpenOutlined from "@mui/icons-material/LockOpenOutlined";
import PasswordOutlined from "@mui/icons-material/PasswordOutlined";
import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined";
import UnfoldMoreOutlined from "@mui/icons-material/UnfoldMoreOutlined";
import Autocomplete from "@mui/material/Autocomplete";
import Checkbox from "@mui/material/Checkbox";
import FormControlLabel from "@mui/material/FormControlLabel";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import { AdminFormDialog, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx";
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
import { createOptionsColumnFilter, createUserIdentityColumnFilter } from "@/shared/ui/tableFilters.js";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { UploadField } from "@/shared/ui/UploadField.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import { appUserStatusFilters, appUserStatusLabels, genderOptions } from "@/features/app-users/constants.js";
import {
BanCountdown,
GenderValue,
LevelValue,
OperatorValue,
RolesValue,
} from "@/features/app-users/components/AppUserValues.jsx";
import { useAppUsersPage } from "@/features/app-users/hooks/useAppUsersPage.js";
import { CoinLedgerTable } from "@/features/operations/components/CoinLedgerTable.jsx";
import styles from "@/features/app-users/app-users.module.css";
export function AppUserListPage() {
const page = useAppUsersPage();
const items = page.data.items || [];
const total = page.data.total || 0;
const selectableItems = items.filter((user) => !isBannedUser(user));
const allChecked =
selectableItems.length > 0 && selectableItems.every((user) => page.selectedUserIds.includes(user.userId));
const selectedOnPageCount = selectableItems.filter((user) => page.selectedUserIds.includes(user.userId)).length;
const someChecked = selectedOnPageCount > 0 && !allChecked;
const toggleAll = (checked) => {
if (!checked) {
page.setSelectedUserIds((current) =>
current.filter((userId) => !selectableItems.some((user) => user.userId === userId)),
);
return;
}
page.setSelectedUserIds((current) =>
Array.from(new Set([...current, ...selectableItems.map((user) => user.userId)])),
);
};
const tableColumns = [
{
key: "select",
className: styles.selectColumn,
header: (
<Checkbox
checked={allChecked}
disabled={!page.abilities.canStatus || selectableItems.length === 0}
indeterminate={someChecked}
size="small"
onChange={(event) => toggleAll(event.target.checked)}
/>
),
resizable: false,
width: "64px",
render: (user) => {
const banned = isBannedUser(user);
return (
<Checkbox
checked={page.selectedUserIds.includes(user.userId)}
disabled={!page.abilities.canStatus || banned || page.loadingAction === "batch-ban"}
size="small"
onChange={(event) => {
page.setSelectedUserIds((current) =>
event.target.checked
? Array.from(new Set([...current, user.userId]))
: current.filter((userId) => userId !== user.userId),
);
}}
/>
);
},
},
{
key: "identity",
label: "用户",
width: "minmax(240px, 1.5fr)",
filter: createUserIdentityColumnFilter({
value: page.userFilter,
onChange: page.changeUserFilter,
}),
render: (user) => <AdminUserIdentity openInAppUserDetail user={user} />,
},
{
key: "gender",
label: "性别",
width: "minmax(72px, 0.4fr)",
render: (user) => <GenderValue gender={user.gender} />,
},
{
key: "age",
label: "年龄",
width: "minmax(72px, 0.4fr)",
render: (user) => (user.age === undefined || user.age === null ? "-" : `${user.age}`),
},
{
key: "wealthLevel",
label: "富豪等级",
width: "minmax(118px, 0.7fr)",
render: (user) => <LevelValue level={user.levels?.wealth} />,
},
{
key: "charmLevel",
label: "魅力等级",
width: "minmax(118px, 0.7fr)",
render: (user) => <LevelValue level={user.levels?.charm} />,
},
{
key: "gameLevel",
label: "游戏等级",
width: "minmax(96px, 0.55fr)",
render: (user) => <LevelValue level={user.levels?.game} />,
},
{
key: "roles",
label: "身份",
width: "minmax(150px, 0.9fr)",
render: (user) => <RolesValue roles={user.roles} />,
},
{
key: "vip",
label: "VIP",
width: "minmax(80px, 0.45fr)",
render: (user) => <VipValue vip={user.vip} />,
},
{
key: "location",
label: "国家 / 区域",
width: "minmax(170px, 1fr)",
filter: createOptionsColumnFilter({
emptyLabel: "全部国家 / 区域",
loading: page.loadingCountries || page.loadingRegions,
options: page.locationFilterOptions,
placeholder: "搜索国家或区域",
value: page.locationFilterValue,
onChange: page.changeLocationFilter,
}),
render: (user) => <UserLocation page={page} user={user} />,
},
{
key: "coin",
header: (
<SortHeader
field="coin"
label="金币"
sortBy={page.sortBy}
sortDirection={page.sortDirection}
onClick={page.changeSort}
/>
),
label: "金币",
width: "minmax(90px, 0.6fr)",
render: (user) => <CoinValue page={page} user={user} />,
},
{
key: "status",
label: "状态",
width: "minmax(92px, 0.6fr)",
filter: createOptionsColumnFilter({
options: appUserStatusFilters,
placeholder: "搜索状态",
value: page.status,
onChange: page.changeStatus,
}),
render: (user) => (
<div className={styles.stack}>
<UserStatus status={user.status} />
<BanCountdown ban={user.ban} status={user.status} />
</div>
),
},
{
key: "lastLoginAtMs",
label: "最新成功登录",
width: "minmax(160px, 0.9fr)",
render: (user) => formatMillis(user.lastLoginAtMs),
},
{
key: "lastOperator",
label: "最新操作人",
width: "minmax(142px, 0.8fr)",
render: (user) => <OperatorValue operator={user.lastOperator} />,
},
{
key: "lastOperatedAtMs",
label: "最新操作时间",
width: "minmax(160px, 0.9fr)",
render: (user) => formatMillis(user.lastOperatedAtMs),
},
{
key: "time",
header: (
<SortHeader
field="created_at"
label="创建 / 活跃"
sortBy={page.sortBy}
sortDirection={page.sortDirection}
onClick={page.changeSort}
/>
),
label: "创建 / 活跃",
width: "minmax(180px, 1fr)",
render: (user) => (
<div className={styles.stack}>
<span>{formatMillis(user.createdAtMs)}</span>
<span className={styles.meta}>{formatMillis(user.lastActiveAtMs)}</span>
</div>
),
},
{
key: "actions",
label: "操作",
width: "minmax(148px, 0.8fr)",
render: (user) => <UserActions page={page} user={user} />,
},
];
return (
<section className={styles.root}>
<div className={styles.contentPanel}>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<div className={styles.listBlock}>
<div className={styles.toolbar}>
<div className={styles.toolbarLeft}>
{page.abilities.canStatus ? (
<span className={styles.selectedMeta}>已选 {page.selectedUserIds.length} </span>
) : null}
<TimeRangeFilter
className={styles.timeFilter}
label="创建时间"
value={page.timeRange}
onChange={page.changeTimeRange}
/>
</div>
<div className={styles.toolbarActions}>
<Button startIcon={<RestartAltOutlined fontSize="small" />} onClick={page.resetFilters}>
重置筛选
</Button>
{page.abilities.canExport ? (
<Button
disabled={page.loadingAction === "export"}
startIcon={<FileDownloadOutlined fontSize="small" />}
onClick={page.exportUsers}
>
{page.loadingAction === "export" ? "导出中" : "导出 CSV"}
</Button>
) : null}
{page.abilities.canStatus ? (
<Button
disabled={!page.selectedUserIds.length || page.loadingAction === "batch-ban"}
startIcon={<BlockOutlined fontSize="small" />}
variant="danger"
onClick={page.batchBan}
>
批量封禁
</Button>
) : null}
</div>
</div>
<DataTable
columns={tableColumns}
items={items}
minWidth="2380px"
pagination={
total > 0
? {
page: page.page,
pageSize: page.data.pageSize || 50,
total,
onPageChange: page.setPage,
}
: undefined
}
rowKey={(user) => user.userId}
/>
</div>
</DataState>
</div>
<AdminFormDialog
loading={page.loadingAction === "edit"}
open={page.activeAction === "edit"}
size="standard"
submitDisabled={!page.abilities.canUpdate || page.loadingAction === "edit"}
submitLabel="保存资料"
title="编辑用户"
onClose={page.closeAction}
onSubmit={page.abilities.canUpdate ? page.submitEdit : (event) => event.preventDefault()}
>
<AdminFormSection title="用户资料">
<TextField
disabled={!page.abilities.canUpdate}
label="用户名称"
value={page.editForm.username}
onChange={(event) => page.setEditForm({ ...page.editForm, username: event.target.value })}
/>
<UploadField
disabled={!page.abilities.canUpdate || !page.abilities.canUpload}
label="头像"
value={page.editForm.avatar}
onChange={(avatar) => page.setEditForm({ ...page.editForm, avatar })}
/>
<TextField
disabled={!page.abilities.canUpdate}
label="性别"
select
value={page.editForm.gender}
onChange={(event) => page.setEditForm({ ...page.editForm, gender: event.target.value })}
>
{genderOptions.map(([value, label]) => (
<MenuItem key={value || "empty"} value={value}>
{label}
</MenuItem>
))}
</TextField>
</AdminFormSection>
<AdminFormSection
actions={
<Button
disabled={!page.abilities.canLevel || page.loadingAction === "levels"}
variant="primary"
onClick={page.submitLevels}
>
{page.loadingAction === "levels" ? "保存中" : "保存等级"}
</Button>
}
title="等级调整"
>
<LevelAdjustmentFields page={page} />
</AdminFormSection>
</AdminFormDialog>
<ActionModal
disabled={!page.abilities.canStatus}
loading={page.loadingAction === `status-${page.activeUser?.userId}`}
open={page.activeAction === "ban"}
sectionTitle="封禁信息"
title="封禁用户"
onClose={page.closeAction}
onSubmit={page.submitBan}
>
<TextField
disabled={!page.abilities.canStatus}
label="封禁期限"
select
value={page.banForm.mode}
onChange={(event) => page.setBanForm({ ...page.banForm, mode: event.target.value })}
>
<MenuItem value="permanent">永久</MenuItem>
<MenuItem value="timed">指定截止时间</MenuItem>
</TextField>
{page.banForm.mode === "timed" ? (
<TextField
disabled={!page.abilities.canStatus}
label="封禁截止时间"
slotProps={{ inputLabel: { shrink: true } }}
type="datetime-local"
value={page.banForm.expiresAtLocal}
onChange={(event) => page.setBanForm({ ...page.banForm, expiresAtLocal: event.target.value })}
/>
) : null}
<TextField
disabled={!page.abilities.canStatus}
label="封禁原因"
multiline
minRows={2}
value={page.banForm.reason}
onChange={(event) => page.setBanForm({ ...page.banForm, reason: event.target.value })}
/>
</ActionModal>
<ActionModal
disabled={!page.abilities.canUpdate}
loading={page.loadingAction === "country"}
open={page.activeAction === "country"}
sectionTitle="国家信息"
title="修改国家"
onClose={page.closeAction}
onSubmit={page.submitCountry}
>
<Autocomplete
disabled={!page.abilities.canUpdate || page.loadingCountries}
getOptionLabel={(option) => option.label || ""}
isOptionEqualToValue={(option, value) => option.value === value.value}
loading={page.loadingCountries}
options={page.countryOptions}
renderInput={(params) => <TextField {...params} label="国家" required />}
size="small"
value={page.countryOptions.find((option) => option.value === page.countryForm.country) || null}
onChange={(_, option) => page.setCountryForm({ country: option?.value || "" })}
/>
</ActionModal>
<ActionModal
disabled={!page.abilities.canPassword}
loading={page.loadingAction === "password"}
open={page.activeAction === "password"}
sectionTitle="密码信息"
title="设置密码"
onClose={page.closeAction}
onSubmit={page.submitPassword}
>
<TextField
disabled={!page.abilities.canPassword}
label="新密码"
type="password"
value={page.passwordForm.password}
onChange={(event) => page.setPasswordForm({ ...page.passwordForm, password: event.target.value })}
/>
</ActionModal>
<SideDrawer
className={styles.coinLedgerDrawer}
contentClassName={styles.coinLedgerDrawerBody}
open={page.activeAction === "coin-ledger"}
title={`金币流水 - ${userLedgerTitle(page.activeUser)}`}
width="wide"
onClose={page.closeAction}
>
{page.activeUser ? (
<CoinLedgerTable lockedUser={page.activeUser} userId={page.activeUser.userId} />
) : null}
</SideDrawer>
</section>
);
}
function CoinValue({ page, user }) {
const value = formatNumber(user.coin);
if (!page.abilities.canCoinLedger) {
return value;
}
return (
<button
aria-label={`查看 ${userLedgerTitle(user)} 的金币流水`}
className={styles.coinButton}
type="button"
onClick={() => page.openCoinLedger(user)}
>
{value}
</button>
);
}
function LevelAdjustmentFields({ page }) {
const tracks = [
["wealth", "富豪等级"],
["charm", "魅力等级"],
];
const updateTrack = (track, patch) => {
page.setLevelForm((current) => ({
...current,
[track]: { ...current[track], ...patch },
}));
};
return (
<div className={styles.levelForm}>
{tracks.map(([track, label]) => {
const field = page.levelForm[track];
const current = page.activeUser?.levels?.[track];
return (
<div className={styles.levelTrackForm} key={track}>
<FormControlLabel
control={
<Checkbox
checked={field.enabled}
disabled={!page.abilities.canLevel}
onChange={(event) => updateTrack(track, { enabled: event.target.checked })}
/>
}
label={`${label}(真实 Lv${Number(current?.realLevel || 0)},展示 Lv${Number(current?.displayLevel || 0)}`}
/>
<div className={styles.formGridTwo}>
<TextField
disabled={!page.abilities.canLevel || !field.enabled}
label="目标等级"
slotProps={{ htmlInput: { max: 50, min: 1, step: 1 } }}
type="number"
value={field.level}
onChange={(event) => updateTrack(track, { level: event.target.value })}
/>
<TextField
disabled={!page.abilities.canLevel || !field.enabled}
label="有效天数"
slotProps={{ htmlInput: { max: 36500, min: 1, step: 1 } }}
type="number"
value={field.durationDays}
onChange={(event) => updateTrack(track, { durationDays: event.target.value })}
/>
</div>
</div>
);
})}
<TextField
disabled
label="游戏等级(只读)"
value={`Lv${Number(page.activeUser?.levels?.game?.displayLevel || 0)}`}
/>
<TextField
disabled={!page.abilities.canLevel}
label="调整原因"
value={page.levelForm.reason}
onChange={(event) => page.setLevelForm((current) => ({ ...current, reason: event.target.value }))}
/>
</div>
);
}
function VipValue({ vip }) {
const level = Number(vip?.level || 0);
if (level <= 0 || !vip?.active) {
return <span className={styles.vipBadgeMuted}>VIP 0</span>;
}
return <span className={styles.vipBadge}>VIP {level}</span>;
}
function UserLocation({ page, user }) {
const countryLabel = formatCountry(user);
const regionLabel = user.regionName || (user.regionId ? `区域 ${user.regionId}` : "-");
return (
<div className={styles.stack}>
{page.abilities.canUpdate ? (
<button className={styles.countryButton} type="button" onClick={() => page.openCountry(user)}>
{countryLabel}
</button>
) : (
<span className={styles.primaryText}>{countryLabel}</span>
)}
<span className={styles.meta}>{regionLabel}</span>
</div>
);
}
function UserActions({ page, user }) {
const banned = user.status === "banned" || user.status === "disabled";
return (
<div className={styles.rowActions}>
{page.abilities.canUpdate || page.abilities.canLevel ? (
<Tooltip arrow title={page.abilities.canUpdate ? "编辑" : "等级调整"}>
<span>
<IconButton
label={page.abilities.canUpdate ? "编辑" : "等级调整"}
onClick={() => page.openEdit(user)}
>
<EditOutlined fontSize="small" />
</IconButton>
</span>
</Tooltip>
) : null}
{page.abilities.canStatus ? (
<Tooltip arrow title={banned ? "解封" : "封禁"}>
<span>
<IconButton
disabled={page.loadingAction === `status-${user.userId}`}
label={banned ? "解封" : "封禁"}
tone={banned ? "success" : "danger"}
onClick={() => page.toggleBan(user)}
>
{banned ? <LockOpenOutlined fontSize="small" /> : <BlockOutlined fontSize="small" />}
</IconButton>
</span>
</Tooltip>
) : null}
{page.abilities.canPassword ? (
<Tooltip arrow title="设置密码">
<span>
<IconButton label="设置密码" onClick={() => page.openPassword(user)}>
<PasswordOutlined fontSize="small" />
</IconButton>
</span>
</Tooltip>
) : null}
</div>
);
}
function UserStatus({ status }) {
const tone = status === "active" ? "running" : "danger";
return (
<span className={`status-badge status-badge--${tone}`}>
<span className="status-point" />
{appUserStatusLabels[status] || status || "-"}
</span>
);
}
function SortHeader({ field, label, onClick, sortBy, sortDirection }) {
const active = sortBy === field;
const nextDirection = active && sortDirection === "desc" ? "asc" : "desc";
const Icon = !active
? UnfoldMoreOutlined
: sortDirection === "asc"
? KeyboardArrowUpOutlined
: KeyboardArrowDownOutlined;
return (
<button
aria-label={`${label}${nextDirection === "asc" ? "正序" : "倒序"}排序`}
className={[styles.sortHeader, active ? styles.sortHeaderActive : ""].filter(Boolean).join(" ")}
type="button"
onClick={() => onClick(field)}
>
<span>{label}</span>
<Icon className={styles.sortIcon} fontSize="inherit" />
</button>
);
}
function ActionModal({ children, disabled, loading, onClose, onSubmit, open, sectionTitle = "基本信息", title }) {
return (
<AdminFormDialog
loading={loading}
open={open}
size="compact"
submitDisabled={disabled || loading}
title={title}
onClose={onClose}
onSubmit={onSubmit}
>
<AdminFormSection title={sectionTitle}>{children}</AdminFormSection>
</AdminFormDialog>
);
}
function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
}
function userLedgerTitle(user) {
if (!user) {
return "用户";
}
return user.username || user.displayUserId || user.prettyDisplayUserId || user.userId || "用户";
}
function formatCountry(user) {
return user.countryDisplayName || user.countryName || user.country || "-";
}
function isBannedUser(user) {
return user.status === "banned" || user.status === "disabled";
}