初始化游戏运营后台

This commit is contained in:
qq143838
2026-08-28 05:24:30 +08:00
commit aafc6bcf03
124 changed files with 27876 additions and 0 deletions
+774
View File
@@ -0,0 +1,774 @@
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>
);
}