初始化游戏运营后台
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user