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 = { 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 ( {meta.label} ); } function SummaryMetric({ label, value, icon, accent, }: { label: string; value: number; icon: ReactNode; accent: string; }) { return (
{label}
{icon} {integerFormatter.format(value)}
); } export default function PlayersPage() { const { isDark, isUltra, antdThemeConfig } = useTheme(); const [messageApi, messageContextHolder] = message.useMessage(); useEffect(() => { setMessageInstance(messageApi); }, [messageApi]); const [filters, setFilters] = useState({ page: 1, pageSize: 20, keyword: '', status: 'all', serverId: 'all', }); const [keywordInput, setKeywordInput] = useState(''); const [selectedPlayer, setSelectedPlayer] = useState(null); const [resourceTarget, setResourceTarget] = useState(null); const [banTarget, setBanTarget] = useState(null); const [recoverTarget, setRecoverTarget] = useState(null); const tableViewportRef = useRef(null); const [tableScrollHeight, setTableScrollHeight] = useState(DEFAULT_TABLE_SCROLL_HEIGHT); const [resourceForm] = Form.useForm(); const [banForm] = Form.useForm(); 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('.ant-table-header'); const pagination = viewport.querySelector('.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('.ant-table-header'); const pagination = viewport.querySelector('.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) => { 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['columns']>( () => [ { title: '角色', key: 'player', width: 180, className: 'players-cell-start', render: (_, player) => (
{player.name} ID {player.id}
), }, { title: '所属账号', key: 'account', width: 176, className: 'players-cell-start', render: (_, player) => (
{player.accountName} ID {player.accountId}
), }, { title: '所在区服', key: 'server', width: 152, className: 'players-cell-start', render: (_, player) => (
{player.serverName} {player.serverId}
), }, { title: '状态', dataIndex: 'status', key: 'status', width: 92, className: 'players-cell-start', render: (status: GamePlayerStatus) => , }, { title: '等级 / VIP', key: 'level', width: 104, className: 'players-cell-start', render: (_, player) => (
Lv. {integerFormatter.format(player.level)} VIP {integerFormatter.format(player.vipLevel)}
), }, { title: '战力', dataIndex: 'combatPower', key: 'combatPower', width: 116, className: 'players-cell-start', render: (value: number) => ( {integerFormatter.format(value)} ), }, { title: '金币 / 钻石', key: 'resources', width: 164, className: 'players-cell-start', render: (_, player) => (
金币 {integerFormatter.format(player.gold)} 钻石 {integerFormatter.format(player.diamonds)}
), }, { title: '累计充值', dataIndex: 'totalRecharge', key: 'totalRecharge', width: 126, className: 'players-cell-start', render: (value: number) => ( {currencyFormatter.format(value)} ), }, { title: '最后登录', key: 'lastLogin', width: 190, className: 'players-cell-start', render: (_, player) => (
{formatDateTime(player.lastLoginTime)} {player.lastLoginIp}
), }, { title: '操作', key: 'actions', width: 112, fixed: 'right', className: 'players-cell-start players-actions-cell', render: (_, player) => ( } /> ) : (
} accent="#1677ff" /> } accent="#52c41a" /> } accent="#13a8a8" /> } accent="#722ed1" /> } accent="#ff4d4f" />
{ const value = event.target.value; setKeywordInput(value); if (value === '' && filters.keyword !== '') { updateFilters({ keyword: '' }); } }} onPressEnter={() => updateFilters({ keyword: keywordInput.trim() })} />
updateFilters({ serverId })} />
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 })), }} />
)} setSelectedPlayer(null)} > {selectedPlayer && (
{selectedPlayer.name} ID {selectedPlayer.id}
{selectedPlayer.accountName} / {selectedPlayer.accountId} {selectedPlayer.serverName} {integerFormatter.format(selectedPlayer.level)} {integerFormatter.format(selectedPlayer.vipLevel)} {integerFormatter.format(selectedPlayer.combatPower)} {integerFormatter.format(selectedPlayer.gold)} {integerFormatter.format(selectedPlayer.diamonds)} {currencyFormatter.format(selectedPlayer.totalRecharge)} {formatDateTime(selectedPlayer.createdAt)} {formatDateTime(selectedPlayer.lastLoginTime)} {selectedPlayer.lastLoginIp} {selectedPlayer.status === 'banned' && ( <> {selectedPlayer.banReason || '—'} {formatDateTime(selectedPlayer.banUntil)} )}
)}
resourceForm.submit()} onCancel={() => { setResourceTarget(null); resourceForm.resetFields(); }} > form={resourceForm} layout="vertical" requiredMark={false} onFinish={submitResources} >
banForm.submit()} onCancel={() => { setBanTarget(null); banForm.resetFields(); }} > form={banForm} layout="vertical" requiredMark={false} initialValues={{ durationDays: 7 }} onFinish={submitBan} >