775 lines
27 KiB
TypeScript
775 lines
27 KiB
TypeScript
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>
|
||
);
|
||
}
|