Files
admin-ui/src/pages/players/PlayersPage.tsx
T
2026-08-28 05:24:30 +08:00

775 lines
27 KiB
TypeScript
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, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { CSSProperties, ReactNode } from 'react';
import dayjs from 'dayjs';
import {
Button,
Card,
ConfigProvider,
Descriptions,
Drawer,
Form,
Input,
InputNumber,
Layout,
message,
Modal,
Result,
Select,
Space,
Spin,
Table,
Tag,
Tooltip,
Typography,
} from 'antd';
import type { TableProps } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import {
CheckCircleOutlined,
EditOutlined,
EyeOutlined,
PlayCircleOutlined,
ReloadOutlined,
SearchOutlined,
StopOutlined,
TeamOutlined,
ThunderboltOutlined,
UserAddOutlined,
UserOutlined,
} from '@ant-design/icons';
import {
useGamePlayerResourcesMutation,
useGamePlayersQuery,
useGamePlayerStatusMutation,
} from '@/api/queries/useGamePlayersQuery';
import type { GamePlayerFilters } from '@/api/queries/useGamePlayersQuery';
import { useTheme } from '@/hooks/useTheme';
import AppSidebar from '@/layouts/AppSidebar';
import WorkspaceTabs from '@/layouts/WorkspaceTabs';
import type { GamePlayer, GamePlayerStatus } from '@/schemas/gamePlayer';
import { setMessageInstance } from '@/utils/messageBus';
import './PlayersPage.css';
const integerFormatter = new Intl.NumberFormat('zh-CN', {
useGrouping: false,
maximumFractionDigits: 0,
});
const currencyFormatter = new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
useGrouping: false,
maximumFractionDigits: 0,
});
const DEFAULT_TABLE_SCROLL_HEIGHT = 240;
const MIN_TABLE_SCROLL_HEIGHT = 96;
const statusMeta: Record<GamePlayerStatus, { color: string; label: string }> = {
online: { color: 'success', label: '在线' },
offline: { color: 'default', label: '离线' },
banned: { color: 'error', label: '已封禁' },
};
const statusOptions = [
{ value: 'all', label: '全部状态' },
{ value: 'online', label: '在线' },
{ value: 'offline', label: '离线' },
{ value: 'banned', label: '已封禁' },
] satisfies Array<{ value: GamePlayerStatus | 'all'; label: string }>;
const banDurationOptions = [
{ value: 1, label: '1 天' },
{ value: 3, label: '3 天' },
{ value: 7, label: '7 天' },
{ value: 30, label: '30 天' },
{ value: 0, label: '永久' },
];
interface ResourceFormValues {
level: number;
vipLevel: number;
combatPower: number;
gold: number;
diamonds: number;
}
interface BanFormValues {
reason: string;
durationDays: number;
}
function formatDateTime(value: string | null) {
return value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '—';
}
function StatusTag({ status }: { status: GamePlayerStatus }) {
const meta = statusMeta[status];
return (
<Tag color={meta.color} variant="filled">
{meta.label}
</Tag>
);
}
function SummaryMetric({
label,
value,
icon,
accent,
}: {
label: string;
value: number;
icon: ReactNode;
accent: string;
}) {
return (
<div className="players-metric" style={{ '--metric-accent': accent } as CSSProperties}>
<div className="players-metric-heading">{label}</div>
<div className="players-metric-value-row">
<span className="players-metric-icon">{icon}</span>
<span className="players-metric-value">{integerFormatter.format(value)}</span>
</div>
</div>
);
}
export default function PlayersPage() {
const { isDark, isUltra, antdThemeConfig } = useTheme();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => {
setMessageInstance(messageApi);
}, [messageApi]);
const [filters, setFilters] = useState<GamePlayerFilters>({
page: 1,
pageSize: 20,
keyword: '',
status: 'all',
serverId: 'all',
});
const [keywordInput, setKeywordInput] = useState('');
const [selectedPlayer, setSelectedPlayer] = useState<GamePlayer | null>(null);
const [resourceTarget, setResourceTarget] = useState<GamePlayer | null>(null);
const [banTarget, setBanTarget] = useState<GamePlayer | null>(null);
const [recoverTarget, setRecoverTarget] = useState<GamePlayer | null>(null);
const tableViewportRef = useRef<HTMLDivElement>(null);
const [tableScrollHeight, setTableScrollHeight] = useState(DEFAULT_TABLE_SCROLL_HEIGHT);
const [resourceForm] = Form.useForm<ResourceFormValues>();
const [banForm] = Form.useForm<BanFormValues>();
const resourceMutation = useGamePlayerResourcesMutation();
const statusMutation = useGamePlayerStatusMutation();
const { players, total, summary, serverOptions, loading, fetched, fetchError, refresh } =
useGamePlayersQuery(filters);
useLayoutEffect(() => {
const viewport = tableViewportRef.current;
if (!viewport || !fetched || fetchError) return;
const measure = () => {
const header = viewport.querySelector<HTMLElement>('.ant-table-header');
const pagination = viewport.querySelector<HTMLElement>('.ant-pagination');
const paginationStyle = pagination ? window.getComputedStyle(pagination) : null;
const paginationOuterHeight = pagination
? pagination.getBoundingClientRect().height +
Number.parseFloat(paginationStyle?.marginTop || '0') +
Number.parseFloat(paginationStyle?.marginBottom || '0')
: 0;
const fixedHeight = (header?.getBoundingClientRect().height ?? 0) + paginationOuterHeight;
const nextHeight = Math.max(
MIN_TABLE_SCROLL_HEIGHT,
Math.floor(viewport.clientHeight - fixedHeight),
);
if (viewport.clientHeight > 0) {
setTableScrollHeight((current) => (current === nextHeight ? current : nextHeight));
}
};
const resizeObserver = new ResizeObserver(measure);
resizeObserver.observe(viewport);
const header = viewport.querySelector<HTMLElement>('.ant-table-header');
const pagination = viewport.querySelector<HTMLElement>('.ant-pagination');
if (header) resizeObserver.observe(header);
if (pagination) resizeObserver.observe(pagination);
measure();
return () => resizeObserver.disconnect();
}, [fetchError, fetched, total]);
const pageClass = useMemo(() => {
const classes = ['game-players-page'];
if (isDark) classes.push('is-dark');
if (isUltra) classes.push('is-ultra');
return classes.join(' ');
}, [isDark, isUltra]);
const updateFilters = useCallback((patch: Partial<GamePlayerFilters>) => {
setFilters((current) => ({ ...current, ...patch, page: patch.page ?? 1 }));
}, []);
const openResourceEditor = useCallback(
(player: GamePlayer) => {
setResourceTarget(player);
resourceForm.setFieldsValue({
level: player.level,
vipLevel: player.vipLevel,
combatPower: player.combatPower,
gold: player.gold,
diamonds: player.diamonds,
});
},
[resourceForm],
);
const submitResources = async (values: ResourceFormValues) => {
if (!resourceTarget) return;
try {
const updated = await resourceMutation.mutateAsync({
playerId: resourceTarget.id,
...values,
});
setSelectedPlayer((current) => (current?.id === updated.id ? updated : current));
messageApi.success('玩家数值已更新');
setResourceTarget(null);
resourceForm.resetFields();
} catch {
// The HTTP layer reports the server error and keeps the editor open for correction.
}
};
const openBanModal = useCallback(
(player: GamePlayer) => {
setBanTarget(player);
banForm.setFieldsValue({ reason: '', durationDays: 7 });
},
[banForm],
);
const submitBan = async (values: BanFormValues) => {
if (!banTarget) return;
try {
const updated = await statusMutation.mutateAsync({
playerId: banTarget.id,
status: 'banned',
reason: values.reason,
durationDays: values.durationDays,
});
setSelectedPlayer((current) => (current?.id === updated.id ? updated : current));
messageApi.success('玩家已封禁');
setBanTarget(null);
banForm.resetFields();
} catch {
// The HTTP layer reports the server error and keeps the modal open for correction.
}
};
const submitRecover = async () => {
if (!recoverTarget) return;
try {
const updated = await statusMutation.mutateAsync({
playerId: recoverTarget.id,
status: 'offline',
});
setSelectedPlayer((current) => (current?.id === updated.id ? updated : current));
messageApi.success('玩家已解封');
setRecoverTarget(null);
} catch {
// The HTTP layer reports the server error and keeps the confirmation open.
}
};
const columns = useMemo<TableProps<GamePlayer>['columns']>(
() => [
{
title: '角色',
key: 'player',
width: 180,
className: 'players-cell-start',
render: (_, player) => (
<div className="players-identity">
<Typography.Text strong>{player.name}</Typography.Text>
<Typography.Text type="secondary" className="players-mono">
ID {player.id}
</Typography.Text>
</div>
),
},
{
title: '所属账号',
key: 'account',
width: 176,
className: 'players-cell-start',
render: (_, player) => (
<div className="players-stack">
<span>{player.accountName}</span>
<Typography.Text type="secondary" className="players-mono">
ID {player.accountId}
</Typography.Text>
</div>
),
},
{
title: '所在区服',
key: 'server',
width: 152,
className: 'players-cell-start',
render: (_, player) => (
<div className="players-stack">
<span>{player.serverName}</span>
<Typography.Text type="secondary" className="players-mono">
{player.serverId}
</Typography.Text>
</div>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 92,
className: 'players-cell-start',
render: (status: GamePlayerStatus) => <StatusTag status={status} />,
},
{
title: '等级 / VIP',
key: 'level',
width: 104,
className: 'players-cell-start',
render: (_, player) => (
<div className="players-stack players-numeric">
<span>Lv. {integerFormatter.format(player.level)}</span>
<Typography.Text type="secondary">
VIP {integerFormatter.format(player.vipLevel)}
</Typography.Text>
</div>
),
},
{
title: '战力',
dataIndex: 'combatPower',
key: 'combatPower',
width: 116,
className: 'players-cell-start',
render: (value: number) => (
<span className="players-numeric">{integerFormatter.format(value)}</span>
),
},
{
title: '金币 / 钻石',
key: 'resources',
width: 164,
className: 'players-cell-start',
render: (_, player) => (
<div className="players-stack players-numeric">
<span>金币 {integerFormatter.format(player.gold)}</span>
<Typography.Text type="secondary">
钻石 {integerFormatter.format(player.diamonds)}
</Typography.Text>
</div>
),
},
{
title: '累计充值',
dataIndex: 'totalRecharge',
key: 'totalRecharge',
width: 126,
className: 'players-cell-start',
render: (value: number) => (
<span className="players-numeric">{currencyFormatter.format(value)}</span>
),
},
{
title: '最后登录',
key: 'lastLogin',
width: 190,
className: 'players-cell-start',
render: (_, player) => (
<div className="players-stack">
<span className="players-time">{formatDateTime(player.lastLoginTime)}</span>
<Typography.Text type="secondary" className="players-mono">
{player.lastLoginIp}
</Typography.Text>
</div>
),
},
{
title: '操作',
key: 'actions',
width: 112,
fixed: 'right',
className: 'players-cell-start players-actions-cell',
render: (_, player) => (
<Space size={2} className="players-row-actions">
<Tooltip title="查看详情">
<Button
type="text"
aria-label={`查看玩家 ${player.name}`}
icon={<EyeOutlined />}
onClick={() => setSelectedPlayer(player)}
/>
</Tooltip>
<Tooltip title="调整数值">
<Button
type="text"
aria-label={`调整玩家 ${player.name}`}
icon={<EditOutlined />}
onClick={() => openResourceEditor(player)}
/>
</Tooltip>
{player.status === 'banned' ? (
<Tooltip title="解除封禁">
<Button
type="text"
aria-label={`解封玩家 ${player.name}`}
icon={<CheckCircleOutlined />}
disabled={statusMutation.isPending}
onClick={() => setRecoverTarget(player)}
/>
</Tooltip>
) : (
<Tooltip title="封禁玩家">
<Button
type="text"
danger
aria-label={`封禁玩家 ${player.name}`}
icon={<StopOutlined />}
disabled={statusMutation.isPending}
onClick={() => openBanModal(player)}
/>
</Tooltip>
)}
</Space>
),
},
],
[openBanModal, openResourceEditor, statusMutation.isPending],
);
return (
<ConfigProvider locale={zhCN} theme={antdThemeConfig}>
{messageContextHolder}
<Layout className={pageClass}>
<AppSidebar />
<Layout className="players-content-shell">
<WorkspaceTabs />
<Layout.Content className="players-content">
<Spin spinning={!fetched} delay={150} size="large" description="正在加载">
{!fetched ? (
<div className="players-loading-spacer" />
) : fetchError ? (
<Result
status="error"
title="玩家数据加载失败"
subTitle={fetchError}
extra={
<Button type="primary" loading={loading} onClick={() => refresh()}>
重新加载
</Button>
}
/>
) : (
<div className="players-shell">
<Card size="small" className="players-summary-card">
<div className="players-metric-grid">
<SummaryMetric
label="总玩家"
value={summary.total}
icon={<TeamOutlined />}
accent="#1677ff"
/>
<SummaryMetric
label="当前在线"
value={summary.online}
icon={<PlayCircleOutlined />}
accent="#52c41a"
/>
<SummaryMetric
label="今日活跃"
value={summary.activeToday}
icon={<ThunderboltOutlined />}
accent="#13a8a8"
/>
<SummaryMetric
label="今日新增"
value={summary.newToday}
icon={<UserAddOutlined />}
accent="#722ed1"
/>
<SummaryMetric
label="已封禁"
value={summary.banned}
icon={<StopOutlined />}
accent="#ff4d4f"
/>
</div>
</Card>
<Card size="small" className="players-filter-card">
<div className="players-filter-bar">
<div className="players-filter-actions">
<div className="players-search">
<Input
allowClear
aria-label="搜索玩家"
placeholder="角色 ID、角色名、账号"
value={keywordInput}
onChange={(event) => {
const value = event.target.value;
setKeywordInput(value);
if (value === '' && filters.keyword !== '') {
updateFilters({ keyword: '' });
}
}}
onPressEnter={() => updateFilters({ keyword: keywordInput.trim() })}
/>
<Button
type="primary"
aria-label="搜索玩家数据"
icon={<SearchOutlined />}
onClick={() => updateFilters({ keyword: keywordInput.trim() })}
>
搜索
</Button>
</div>
<Select
className="players-filter-select"
aria-label="玩家状态筛选"
value={filters.status}
options={statusOptions}
onChange={(status) => updateFilters({ status })}
/>
<Select
showSearch
optionFilterProp="label"
className="players-server-select"
aria-label="玩家区服筛选"
value={filters.serverId}
options={[{ value: 'all', label: '全部区服' }, ...serverOptions]}
onChange={(serverId) => updateFilters({ serverId })}
/>
<Button
className="players-filter-refresh"
aria-label="刷新玩家数据"
title="刷新玩家数据"
icon={<ReloadOutlined />}
loading={loading}
onClick={() => refresh()}
/>
</div>
</div>
</Card>
<Card size="small" className="players-table-card">
<div ref={tableViewportRef} className="players-table-viewport">
<Table<GamePlayer>
rowKey="id"
size="middle"
columns={columns}
dataSource={players}
loading={loading}
scroll={{ x: 1420, y: tableScrollHeight }}
locale={{ emptyText: '暂无符合条件的玩家' }}
pagination={{
current: filters.page,
pageSize: filters.pageSize,
total,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50],
locale: { items_per_page: '/ 页' },
onChange: (page, pageSize) =>
setFilters((current) => ({ ...current, page, pageSize })),
}}
/>
</div>
</Card>
</div>
)}
</Spin>
</Layout.Content>
</Layout>
<Drawer
title="玩家详情"
placement="right"
size="min(92vw, 560px)"
open={selectedPlayer !== null}
rootClassName={`players-detail-drawer ${isDark ? 'dark' : 'light'}`}
onClose={() => setSelectedPlayer(null)}
>
{selectedPlayer && (
<div className="players-detail">
<div className="players-detail-heading">
<span className="players-detail-icon">
<UserOutlined />
</span>
<div>
<Typography.Title level={4}>{selectedPlayer.name}</Typography.Title>
<Typography.Text type="secondary" className="players-mono">
ID {selectedPlayer.id}
</Typography.Text>
</div>
<StatusTag status={selectedPlayer.status} />
</div>
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="所属账号">
{selectedPlayer.accountName} / {selectedPlayer.accountId}
</Descriptions.Item>
<Descriptions.Item label="所在区服">{selectedPlayer.serverName}</Descriptions.Item>
<Descriptions.Item label="等级">
{integerFormatter.format(selectedPlayer.level)}
</Descriptions.Item>
<Descriptions.Item label="VIP 等级">
{integerFormatter.format(selectedPlayer.vipLevel)}
</Descriptions.Item>
<Descriptions.Item label="战力">
{integerFormatter.format(selectedPlayer.combatPower)}
</Descriptions.Item>
<Descriptions.Item label="金币">
{integerFormatter.format(selectedPlayer.gold)}
</Descriptions.Item>
<Descriptions.Item label="钻石">
{integerFormatter.format(selectedPlayer.diamonds)}
</Descriptions.Item>
<Descriptions.Item label="累计充值">
{currencyFormatter.format(selectedPlayer.totalRecharge)}
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{formatDateTime(selectedPlayer.createdAt)}
</Descriptions.Item>
<Descriptions.Item label="最后登录">
{formatDateTime(selectedPlayer.lastLoginTime)}
</Descriptions.Item>
<Descriptions.Item label="最后登录 IP">
<span className="players-mono">{selectedPlayer.lastLoginIp}</span>
</Descriptions.Item>
{selectedPlayer.status === 'banned' && (
<>
<Descriptions.Item label="封禁原因">
{selectedPlayer.banReason || '—'}
</Descriptions.Item>
<Descriptions.Item label="封禁截止">
{formatDateTime(selectedPlayer.banUntil)}
</Descriptions.Item>
</>
)}
</Descriptions>
</div>
)}
</Drawer>
<Modal
title={`调整数值${resourceTarget ? `:${resourceTarget.name}` : ''}`}
open={resourceTarget !== null}
width={680}
okText="保存修改"
cancelText="取消"
okButtonProps={{ loading: resourceMutation.isPending }}
rootClassName="players-resource-modal"
destroyOnHidden
onOk={() => resourceForm.submit()}
onCancel={() => {
setResourceTarget(null);
resourceForm.resetFields();
}}
>
<Form<ResourceFormValues>
form={resourceForm}
layout="vertical"
requiredMark={false}
onFinish={submitResources}
>
<div className="players-form-grid">
<Form.Item
name="level"
label="角色等级"
rules={[{ required: true, message: '请输入角色等级' }]}
>
<InputNumber min={1} max={999} precision={0} controls />
</Form.Item>
<Form.Item
name="vipLevel"
label="VIP 等级"
rules={[{ required: true, message: '请输入 VIP 等级' }]}
>
<InputNumber min={0} max={99} precision={0} controls />
</Form.Item>
<Form.Item
name="combatPower"
label="战力"
rules={[{ required: true, message: '请输入战力' }]}
>
<InputNumber min={0} precision={0} controls />
</Form.Item>
<Form.Item
name="gold"
label="金币"
rules={[{ required: true, message: '请输入金币数量' }]}
>
<InputNumber min={0} precision={0} controls />
</Form.Item>
<Form.Item
name="diamonds"
label="钻石"
rules={[{ required: true, message: '请输入钻石数量' }]}
>
<InputNumber min={0} precision={0} controls />
</Form.Item>
</div>
</Form>
</Modal>
<Modal
title={`封禁玩家${banTarget ? `:${banTarget.name}` : ''}`}
open={banTarget !== null}
okText="确认封禁"
cancelText="取消"
okButtonProps={{ danger: true, loading: statusMutation.isPending }}
destroyOnHidden
onOk={() => banForm.submit()}
onCancel={() => {
setBanTarget(null);
banForm.resetFields();
}}
>
<Form<BanFormValues>
form={banForm}
layout="vertical"
requiredMark={false}
initialValues={{ durationDays: 7 }}
onFinish={submitBan}
>
<Form.Item
name="reason"
label="封禁原因"
rules={[
{ required: true, whitespace: true, message: '请输入封禁原因' },
{ max: 100, message: '封禁原因不能超过 100 个字符' },
]}
>
<Input.TextArea rows={3} maxLength={100} showCount placeholder="填写处理依据" />
</Form.Item>
<Form.Item name="durationDays" label="封禁期限" rules={[{ required: true }]}>
<Select options={banDurationOptions} />
</Form.Item>
</Form>
</Modal>
<Modal
title="解除封禁"
open={recoverTarget !== null}
okText="确认解封"
cancelText="取消"
okButtonProps={{ loading: statusMutation.isPending }}
onOk={submitRecover}
onCancel={() => setRecoverTarget(null)}
>
<Typography.Paragraph className="players-confirm-copy">
{recoverTarget ? `玩家 ${recoverTarget.name} 将恢复为离线状态。` : ''}
</Typography.Paragraph>
</Modal>
</Layout>
</ConfigProvider>
);
}