初始化游戏运营后台

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
+1
View File
@@ -0,0 +1 @@
VITE_MOCK_API=true
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
.vite/
*.log
*.zip
*.tsbuildinfo
coverage/
storybook-static/
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"ignorePatterns": [
"node_modules",
"src/generated",
"public",
"tools/oxlint/__fixtures__"
]
}
+49
View File
@@ -0,0 +1,49 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"ignorePatterns": [
"node_modules/**"
],
"plugins": [
"typescript",
"react",
"jsx-a11y"
],
"categories": {
"correctness": "error"
},
"env": {
"browser": true,
"es2022": true
},
"rules": {
"typescript/no-explicit-any": "error",
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_"
}
],
"typescript/ban-ts-comment": "error",
"typescript/no-empty-object-type": "error",
"typescript/no-namespace": "error",
"typescript/no-require-imports": "error",
"typescript/no-this-alias": "error",
"typescript/no-unsafe-function-type": "error",
"typescript/no-unused-expressions": "warn",
"typescript/no-wrapper-object-types": "error",
"typescript/prefer-as-const": "error",
"typescript/triple-slash-reference": "error",
"no-empty": [
"error",
{
"allowEmptyCatch": true
}
],
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error",
"jsx-a11y/no-autofocus": "off",
"jsx-a11y/prefer-tag-over-role": "off"
}
}
+25
View File
@@ -0,0 +1,25 @@
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
framework: {
name: '@storybook/react-vite',
options: {},
},
stories: ['../src/**/*.stories.@(ts|tsx)'],
addons: ['@storybook/addon-docs', '@storybook/addon-a11y', '@storybook/addon-vitest'],
viteFinal: (viteConfig) => {
if (viteConfig.build) {
viteConfig.build.outDir = undefined;
viteConfig.build.emptyOutDir = false;
if (viteConfig.build.rollupOptions) {
viteConfig.build.rollupOptions.input = undefined;
}
}
if (viteConfig.experimental) {
viteConfig.experimental.renderBuiltUrl = undefined;
}
return viteConfig;
},
};
export default config;
+6
View File
@@ -0,0 +1,6 @@
<script>
if (localStorage.getItem('dark-mode') === null) localStorage.setItem('dark-mode', 'false');
if (localStorage.getItem('isUltraDarkThemeEnabled') === null) {
localStorage.setItem('isUltraDarkThemeEnabled', 'false');
}
</script>
+68
View File
@@ -0,0 +1,68 @@
import { useLayoutEffect } from 'react';
import type { Decorator, Preview } from '@storybook/react-vite';
import { ConfigProvider } from 'antd';
import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
import { buildAntdThemeConfig } from '@/hooks/useTheme';
import enUS from '../src/i18n/locales/en-US.json';
if (!i18next.isInitialized) {
void i18next.use(initReactI18next).init({
lng: 'en-US',
fallbackLng: 'en-US',
resources: { 'en-US': { translation: enUS } },
interpolation: { escapeValue: false, prefix: '{', suffix: '}' },
returnNull: false,
});
}
export const withTheme: Decorator = function ThemeDecorator(Story, context) {
const dark = context.globals.theme === 'dark';
useLayoutEffect(() => {
document.body.classList.remove('dark', 'light');
document.body.classList.add(dark ? 'dark' : 'light');
document.documentElement.removeAttribute('data-theme');
}, [dark]);
return (
<ConfigProvider theme={buildAntdThemeConfig(dark, false)}>
<div style={{ padding: 24, minWidth: 320 }}>
<Story />
</div>
</ConfigProvider>
);
};
const preview: Preview = {
decorators: [withTheme],
globalTypes: {
theme: {
description: 'Ant Design theme',
defaultValue: 'light',
toolbar: {
title: 'Theme',
icon: 'circlehollow',
items: [
{ value: 'light', title: 'Light' },
{ value: 'dark', title: 'Dark' },
],
dynamicTitle: true,
},
},
},
parameters: {
controls: {
expanded: true,
sort: 'requiredFirst',
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
a11y: {
test: 'error',
},
},
};
export default preview;
+18
View File
@@ -0,0 +1,18 @@
# Admin UI Agent Instructions
## Collaboration Rules
1. 禁止启用任何子代理。所有分析、开发、测试和验证工作必须由当前主代理直接完成。
## Git 提交规则
1. 所有 Git 提交信息必须使用中文,禁止使用英文提交信息。
2. 推送前必须检查本次提交信息,确认其准确描述修改内容且为中文。
## Display Formatting Rules
1. 所有用户界面中的可见数字禁止使用千位分隔符。玩家数量、订单数量、金额、统计指标、图表坐标、图表 Tooltip 和表格数据都必须显示为连续数字,例如使用 `10213`,禁止显示为 `10,213`。
## Layout Rules
1. 桌面端运营页面必须占满侧边栏右侧的可用宽度,只保留正常页面内边距。禁止使用任意固定 `max-width` 将主内容区居中收窄。
+91
View File
@@ -0,0 +1,91 @@
# 游戏运营后台
可复用的游戏服务端运营后台前端模板,技术栈为 React、TypeScript、Ant Design、TanStack Query 和 Vite。
当前包含:
- 登录页
- 总览
- 账号管理
- 区服管理
- 玩家管理
- CDK管理
- 公告管理
- 订单管理
## 开发
```sh
npm install
npm run dev
```
开发服务器监听 `0.0.0.0:19999`:
- 登录页:`http://localhost:19999/`
- 后台:`http://localhost:19999/panel/`
`.env.development` 默认启用 MSW Mock API,因此不依赖后端即可完成页面开发和验收。
## 配置
| 变量 | 用途 |
|---|---|
| `VITE_MOCK_API` | 开发环境是否启用 Mock API |
| `VITE_ADMIN_BASE_PATH` | 后台部署路径前缀,默认 `/` |
| `VITE_BACKEND_TARGET` | 可选的开发代理后端地址 |
| `VITE_SOURCEMAP` | 生产构建是否输出 sourcemap |
生产环境可由服务端在 HTML 中提前注入 `window.ADMIN_UI_BASE_PATH`。路由固定挂载在该前缀下的 `/panel`,适合由 Go 的 `embed.FS` 或其他静态资源方案直接嵌入。
## API 约定
现有页面通过以下前缀访问业务接口:
```text
/panel/api/game/overview
/panel/api/game/accounts
/panel/api/game/servers
/panel/api/game/players
/panel/api/game/cdks
/panel/api/game/announcements
/panel/api/game/orders
```
接口响应使用统一结构:
```json
{
"success": true,
"msg": "",
"obj": {}
}
```
开发阶段的对应实现位于 `src/mocks/handlers.ts`,接入真实服务端时可保持页面和 Query 层不变。
## 命令
```sh
npm run typecheck
npm run lint
npm run format:check
npm run test
npm run build
```
## 目录
```text
src/
api/ 请求封装和游戏业务 Query
components/ 登录表单与图表组件
hooks/ 主题和页面标题
i18n/ 登录页多语言
layouts/ 左侧菜单和顶部标签栏
mocks/ 开发环境 Mock API
pages/ 当前运营后台页面
schemas/ 游戏业务 Zod Schema
styles/ 全局布局样式
test/ 当前功能回归测试
```
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex,nofollow" />
<title>游戏运营后台</title>
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex,nofollow" />
<title>游戏运营后台</title>
</head>
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/login.tsx"></script>
</body>
</html>
+8508
View File
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
{
"name": "admin-ui",
"version": "1.0.0",
"private": true,
"description": "Reusable game operations administration UI.",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0 --port 19999",
"lint": "oxlint src .storybook vite.config.js vitest.config.ts",
"lint:fix": "oxlint --fix src .storybook vite.config.js vitest.config.ts",
"lint:deprecated": "oxlint --type-aware -A all -D typescript/no-deprecated src",
"format": "oxfmt src .storybook vite.config.js vitest.config.ts",
"format:check": "oxfmt --check src .storybook vite.config.js vitest.config.ts",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"dependencies": {
"@ant-design/icons": "^6.3.2",
"@hookform/resolvers": "^5.9.1",
"@tanstack/react-query": "^5.102.2",
"antd": "^6.6.1",
"dayjs": "^1.11.23",
"i18next": "^26.4.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-hook-form": "^7.86.0",
"react-i18next": "^17.0.12",
"react-router": "^8.3.0",
"uplot": "^1.6.32",
"zod": "^4.4.3"
},
"devDependencies": {
"@storybook/addon-a11y": "^10.5.10",
"@storybook/addon-docs": "^10.5.10",
"@storybook/addon-vitest": "^10.5.10",
"@storybook/react-vite": "^10.5.10",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^6.1.0",
"@vitest/browser-playwright": "4.1.11",
"@vitest/coverage-v8": "^4.1.11",
"jsdom": "^30.0.1",
"lint-staged": "^17.3.0",
"msw": "^2.15.0",
"oxfmt": "0.64.0",
"oxlint": "1.79.0",
"oxlint-tsgolint": "^7.0.2001",
"playwright": "^1.62.1",
"storybook": "^10.5.10",
"typescript": "7.0.2",
"vite": "8.2.2",
"vitest": "^4.1.11"
},
"lint-staged": {
"src/**/*.{ts,tsx}": [
"oxfmt",
"oxlint --fix"
]
},
"engines": {
"node": ">=24.0.0",
"npm": ">=10.0.0"
},
"msw": {
"workerDirectory": [
"public"
]
}
}
+361
View File
@@ -0,0 +1,361 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.15.0'
const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
addEventListener('install', function () {
self.skipWaiting()
})
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
})
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents)
// Omit the body of server-sent event stream responses.
// Cloning such responses would prevent client-side stream cancelations
// from reaching the original stream (a teed stream only cancels its
// source once both of its branches cancel) and would buffer the
// entire stream into the unconsumed clone indefinitely.
const isEventStreamResponse = response.headers
.get('content-type')
?.toLowerCase()
.startsWith('text/event-stream')
// Clone the response so both the client and the library could consume it.
const responseClone = isEventStreamResponse ? null : response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: response.type,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body: responseClone ? responseClone.body : null,
},
},
},
responseClone && responseClone.body
? [serializedRequest.body, responseClone.body]
: [],
)
}
return response
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client
}
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[serializedRequest.body],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
}
}
+8
View File
@@ -0,0 +1,8 @@
import type { ReactNode } from 'react';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from '@/queryClient';
export function QueryProvider({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
+211
View File
@@ -0,0 +1,211 @@
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
const CSRF_TOKEN_PATH = '/csrf-token';
let csrfToken: string | null = null;
let csrfFetchPromise: Promise<string | null> | null = null;
let sessionExpired = false;
let basePathPrefix = '';
export interface HttpResponse {
ok: boolean;
status: number;
statusText: string;
data: unknown;
}
export class HttpError extends Error {
status: number;
response: { status: number; statusText: string; data: unknown };
constructor(status: number, statusText: string, data: unknown) {
super(`Request failed with status ${status}`);
this.name = 'HttpError';
this.status = status;
this.response = { status, statusText, data };
}
}
export interface HttpRequestOptions {
headers?: Record<string, string> | Headers;
params?: unknown;
timeout?: number;
signal?: AbortSignal;
}
function readMetaToken(): string | null {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null;
}
async function fetchCsrfToken(): Promise<string | null> {
try {
const res = await fetch(basePathPrefix + CSRF_TOKEN_PATH, {
method: 'GET',
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
if (!res.ok) return null;
const json = (await res.json()) as { success?: boolean; obj?: unknown } | null;
return json?.success && typeof json.obj === 'string' ? json.obj : null;
} catch {
return null;
}
}
async function ensureCsrfToken(): Promise<string | null> {
if (csrfToken) return csrfToken;
const meta = readMetaToken();
if (meta) {
csrfToken = meta;
return csrfToken;
}
if (!csrfFetchPromise) csrfFetchPromise = fetchCsrfToken();
const fetched = await csrfFetchPromise;
csrfFetchPromise = null;
if (fetched) csrfToken = fetched;
return csrfToken;
}
function encodeForm(data: unknown): string {
if (data == null || typeof data !== 'object') return '';
const parts: string[] = [];
const append = (key: string, value: unknown): void => {
if (value === undefined) return;
if (value === null) {
parts.push(`${encodeURIComponent(key)}=`);
return;
}
if (Array.isArray(value)) {
value.forEach((item) => append(key, item));
return;
}
if (typeof value === 'object') {
Object.entries(value as Record<string, unknown>).forEach(([k, v]) =>
append(`${key}[${k}]`, v),
);
return;
}
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
};
Object.entries(data as Record<string, unknown>).forEach(([k, v]) => append(k, v));
return parts.join('&');
}
function appendQuery(url: string, query: string): string {
if (query === '') return url;
const hashIndex = url.indexOf('#');
const path = hashIndex === -1 ? url : url.slice(0, hashIndex);
const hash = hashIndex === -1 ? '' : url.slice(hashIndex);
const hasQuery = path.includes('?');
const separator = !hasQuery ? '?' : path.endsWith('?') || path.endsWith('&') ? '' : '&';
return `${path}${separator}${query}${hash}`;
}
function requestSignal(options: HttpRequestOptions): AbortSignal | undefined {
if (!options.timeout) return options.signal;
const timeout = AbortSignal.timeout(options.timeout);
if (!options.signal) return timeout;
if (typeof AbortSignal.any === 'function') return AbortSignal.any([options.signal, timeout]);
const controller = new AbortController();
const abort = () => controller.abort();
options.signal.addEventListener('abort', abort, { once: true });
timeout.addEventListener('abort', abort, { once: true });
return controller.signal;
}
async function performFetch(
method: string,
url: string,
data: unknown,
options: HttpRequestOptions,
csrfOverride?: string,
): Promise<Response> {
const upper = method.toUpperCase();
const headers = new Headers(options.headers);
headers.set('X-Requested-With', 'XMLHttpRequest');
let body: BodyInit | undefined;
if (data instanceof FormData) {
body = data;
headers.delete('Content-Type');
} else if (!SAFE_METHODS.has(upper)) {
const declaredType = (headers.get('Content-Type') || '').toLowerCase();
if (declaredType.startsWith('application/json')) {
if (data !== undefined) {
body = typeof data === 'string' ? data : JSON.stringify(data);
}
} else {
headers.set('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
body = encodeForm(data);
}
}
if (!SAFE_METHODS.has(upper)) {
const token = csrfOverride ?? (await ensureCsrfToken());
if (token) headers.set('X-CSRF-Token', token);
}
const query = encodeForm(options.params);
const fullUrl = basePathPrefix + appendQuery(url, query);
const signal = requestSignal(options);
return fetch(fullUrl, { method: upper, headers, body, credentials: 'same-origin', signal });
}
async function parseBody(res: Response): Promise<unknown> {
if (res.status === 204 || res.status === 205) return '';
const text = await res.text();
if (text === '') return '';
const contentType = (res.headers.get('content-type') || '').toLowerCase();
if (contentType.includes('application/json') || text[0] === '{' || text[0] === '[') {
try {
return JSON.parse(text);
} catch {
return text;
}
}
return text;
}
export async function httpRequest(
method: string,
url: string,
data?: unknown,
options: HttpRequestOptions = {},
): Promise<HttpResponse> {
let res = await performFetch(method, url, data, options);
if (res.status === 403 && !SAFE_METHODS.has(method.toUpperCase())) {
csrfToken = null;
const fresh = await fetchCsrfToken();
if (fresh) {
csrfToken = fresh;
res = await performFetch(method, url, data, options, fresh);
}
}
if (res.status === 401) {
if (!sessionExpired) {
sessionExpired = true;
window.location.replace(window.ADMIN_UI_BASE_PATH || basePathPrefix || '/');
}
return new Promise<HttpResponse>(() => {});
}
const parsed = await parseBody(res);
if (!res.ok) throw new HttpError(res.status, res.statusText, parsed);
return { ok: true, status: res.status, statusText: res.statusText, data: parsed };
}
export function setupHttp(): void {
let basePath: string | null | undefined = window.ADMIN_UI_BASE_PATH;
if (!basePath) {
const metaTag = document.querySelector('meta[name="base-path"]');
basePath = metaTag ? metaTag.getAttribute('content') : null;
}
basePathPrefix =
typeof basePath === 'string' && basePath !== '' && basePath !== '/'
? basePath.replace(/\/$/, '')
: '';
csrfToken = readMetaToken();
}
+80
View File
@@ -0,0 +1,80 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { keys } from '@/api/queryKeys';
import { GameAccountListSchema, GameAccountSchema } from '@/schemas/gameAccount';
import type {
GameAccountList,
GameAccountPlatform,
GameAccountStatus,
} from '@/schemas/gameAccount';
import { HttpUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
export interface GameAccountFilters {
page: number;
pageSize: number;
keyword: string;
status: GameAccountStatus | 'all';
platform: GameAccountPlatform | 'all';
}
export interface UpdateGameAccountStatusInput {
accountId: string;
status: 'active' | 'banned';
reason?: string;
durationDays?: number;
}
async function fetchGameAccounts(filters: GameAccountFilters): Promise<GameAccountList> {
const msg = await HttpUtil.get('/panel/api/game/accounts', filters, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch game accounts');
const validated = parseMsg(msg, GameAccountListSchema, 'game/accounts', { strict: true });
if (!validated.obj) throw new Error('Game account list response is empty');
return validated.obj;
}
export async function updateGameAccountStatus(input: UpdateGameAccountStatusInput) {
const { accountId, ...payload } = input;
const msg = await HttpUtil.post(`/panel/api/game/accounts/${accountId}/status`, payload, {
headers: { 'Content-Type': 'application/json' },
silentSuccess: true,
});
if (!msg?.success) throw new Error(msg?.msg || 'Failed to update account status');
const validated = parseMsg(msg, GameAccountSchema, 'game/accounts/status', { strict: true });
if (!validated.obj) throw new Error('Updated account response is empty');
return validated.obj;
}
export function useGameAccountsQuery(filters: GameAccountFilters) {
const query = useQuery({
queryKey: keys.game.accounts.list(filters),
queryFn: () => fetchGameAccounts(filters),
staleTime: 10_000,
placeholderData: (previous) => previous,
});
return {
accounts: query.data?.items ?? [],
total: query.data?.total ?? 0,
summary: query.data?.summary ?? {
total: 0,
newToday: 0,
active: 0,
banned: 0,
locked: 0,
},
platformOptions: query.data?.platformOptions ?? [],
loading: query.isFetching,
fetched: query.data !== undefined || query.isError,
fetchError: query.error ? (query.error as Error).message : '',
refresh: query.refetch,
};
}
export function useGameAccountStatusMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateGameAccountStatus,
onSuccess: () => queryClient.invalidateQueries({ queryKey: keys.game.accounts.root() }),
});
}
@@ -0,0 +1,123 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { keys } from '@/api/queryKeys';
import {
GameAnnouncementListSchema,
GameAnnouncementSchema,
GameAnnouncementStatusInputSchema,
GameAnnouncementUpsertInputSchema,
} from '@/schemas/gameAnnouncement';
import type {
GameAnnouncementChannel,
GameAnnouncementList,
GameAnnouncementStatus,
GameAnnouncementStatusInput,
GameAnnouncementUpsertInput,
} from '@/schemas/gameAnnouncement';
import { HttpUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
export interface GameAnnouncementFilters {
page: number;
pageSize: number;
keyword: string;
status: GameAnnouncementStatus | 'all';
channel: GameAnnouncementChannel | 'all';
}
export interface SaveGameAnnouncementInput {
announcementId?: string;
data: GameAnnouncementUpsertInput;
}
export interface UpdateGameAnnouncementStatusInput extends GameAnnouncementStatusInput {
announcementId: string;
}
const JSON_OPTIONS = {
headers: { 'Content-Type': 'application/json' },
silentSuccess: true,
} as const;
export async function fetchGameAnnouncements(
filters: GameAnnouncementFilters,
): Promise<GameAnnouncementList> {
const msg = await HttpUtil.get('/panel/api/game/announcements', filters, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch game announcements');
const validated = parseMsg(msg, GameAnnouncementListSchema, 'game/announcements', {
strict: true,
});
if (!validated.obj) throw new Error('Game announcement list response is empty');
return validated.obj;
}
export async function saveGameAnnouncement(input: SaveGameAnnouncementInput) {
const payload = GameAnnouncementUpsertInputSchema.parse(input.data);
const endpoint = input.announcementId
? `/panel/api/game/announcements/${input.announcementId}`
: '/panel/api/game/announcements';
const msg = await HttpUtil.post(endpoint, payload, JSON_OPTIONS);
if (!msg?.success) throw new Error(msg?.msg || 'Failed to save game announcement');
const validated = parseMsg(msg, GameAnnouncementSchema, 'game/announcements/save', {
strict: true,
});
if (!validated.obj) throw new Error('Saved announcement response is empty');
return validated.obj;
}
export async function updateGameAnnouncementStatus(input: UpdateGameAnnouncementStatusInput) {
const { announcementId, ...rawPayload } = input;
const payload = GameAnnouncementStatusInputSchema.parse(rawPayload);
const msg = await HttpUtil.post(
`/panel/api/game/announcements/${announcementId}/status`,
payload,
JSON_OPTIONS,
);
if (!msg?.success) throw new Error(msg?.msg || 'Failed to update announcement status');
const validated = parseMsg(msg, GameAnnouncementSchema, 'game/announcements/status', {
strict: true,
});
if (!validated.obj) throw new Error('Updated announcement response is empty');
return validated.obj;
}
export function useGameAnnouncementsQuery(filters: GameAnnouncementFilters) {
const query = useQuery({
queryKey: keys.game.announcements.list(filters),
queryFn: () => fetchGameAnnouncements(filters),
staleTime: 10_000,
placeholderData: (previous) => previous,
});
return {
announcements: query.data?.items ?? [],
total: query.data?.total ?? 0,
summary: query.data?.summary ?? {
total: 0,
active: 0,
scheduled: 0,
draft: 0,
inactive: 0,
},
serverOptions: query.data?.serverOptions ?? [],
loading: query.isFetching,
fetched: query.data !== undefined || query.isError,
fetchError: query.error ? (query.error as Error).message : '',
refresh: query.refetch,
};
}
function useInvalidateGameAnnouncements() {
const queryClient = useQueryClient();
return () => queryClient.invalidateQueries({ queryKey: keys.game.announcements.root() });
}
export function useSaveGameAnnouncementMutation() {
const invalidate = useInvalidateGameAnnouncements();
return useMutation({ mutationFn: saveGameAnnouncement, onSuccess: invalidate });
}
export function useGameAnnouncementStatusMutation() {
const invalidate = useInvalidateGameAnnouncements();
return useMutation({ mutationFn: updateGameAnnouncementStatus, onSuccess: invalidate });
}
+109
View File
@@ -0,0 +1,109 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { keys } from '@/api/queryKeys';
import {
GameCdkBatchCreateInputSchema,
GameCdkBatchCreateResultSchema,
GameCdkListSchema,
GameCdkSchema,
GameCdkStatusInputSchema,
} from '@/schemas/gameCdk';
import type {
GameCdkBatchCreateInput,
GameCdkBatchCreateResult,
GameCdkList,
GameCdkStatus,
GameCdkStatusInput,
} from '@/schemas/gameCdk';
import { HttpUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
export interface GameCdkFilters {
page: number;
pageSize: number;
keyword: string;
status: GameCdkStatus | 'all';
batchId: string | 'all';
}
export interface UpdateGameCdkStatusInput extends GameCdkStatusInput {
cdkId: string;
}
const JSON_OPTIONS = {
headers: { 'Content-Type': 'application/json' },
silentSuccess: true,
} as const;
export async function fetchGameCdks(filters: GameCdkFilters): Promise<GameCdkList> {
const msg = await HttpUtil.get('/panel/api/game/cdks', filters, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch game CDKs');
const validated = parseMsg(msg, GameCdkListSchema, 'game/cdks', { strict: true });
if (!validated.obj) throw new Error('Game CDK list response is empty');
return validated.obj;
}
export async function createGameCdkBatch(
input: GameCdkBatchCreateInput,
): Promise<GameCdkBatchCreateResult> {
const payload = GameCdkBatchCreateInputSchema.parse(input);
const msg = await HttpUtil.post('/panel/api/game/cdks/batches', payload, JSON_OPTIONS);
if (!msg?.success) throw new Error(msg?.msg || 'Failed to create game CDK batch');
const validated = parseMsg(msg, GameCdkBatchCreateResultSchema, 'game/cdks/batches', {
strict: true,
});
if (!validated.obj) throw new Error('Created CDK batch response is empty');
return validated.obj;
}
export async function updateGameCdkStatus(input: UpdateGameCdkStatusInput) {
const { cdkId, ...rawPayload } = input;
const payload = GameCdkStatusInputSchema.parse(rawPayload);
const msg = await HttpUtil.post(`/panel/api/game/cdks/${cdkId}/status`, payload, JSON_OPTIONS);
if (!msg?.success) throw new Error(msg?.msg || 'Failed to update game CDK status');
const validated = parseMsg(msg, GameCdkSchema, 'game/cdks/status', { strict: true });
if (!validated.obj) throw new Error('Updated CDK response is empty');
return validated.obj;
}
export function useGameCdksQuery(filters: GameCdkFilters) {
const query = useQuery({
queryKey: keys.game.cdks.list(filters),
queryFn: () => fetchGameCdks(filters),
staleTime: 10_000,
placeholderData: (previous) => previous,
});
return {
cdks: query.data?.items ?? [],
total: query.data?.total ?? 0,
summary: query.data?.summary ?? {
total: 0,
unused: 0,
used: 0,
expired: 0,
disabled: 0,
},
batchOptions: query.data?.batchOptions ?? [],
serverOptions: query.data?.serverOptions ?? [],
loading: query.isFetching,
fetched: query.data !== undefined || query.isError,
fetchError: query.error ? (query.error as Error).message : '',
refresh: query.refetch,
};
}
function useInvalidateGameCdks() {
const queryClient = useQueryClient();
return () => queryClient.invalidateQueries({ queryKey: keys.game.cdks.root() });
}
export function useCreateGameCdkBatchMutation() {
const invalidate = useInvalidateGameCdks();
return useMutation({ mutationFn: createGameCdkBatch, onSuccess: invalidate });
}
export function useGameCdkStatusMutation() {
const invalidate = useInvalidateGameCdks();
return useMutation({ mutationFn: updateGameCdkStatus, onSuccess: invalidate });
}
+117
View File
@@ -0,0 +1,117 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { keys } from '@/api/queryKeys';
import {
GameOrderDeliveryInputSchema,
GameOrderListSchema,
GameOrderRefundInputSchema,
GameOrderSchema,
} from '@/schemas/gameOrder';
import type {
GameOrderChannel,
GameOrderDeliveryInput,
GameOrderList,
GameOrderRefundInput,
GameOrderStatus,
} from '@/schemas/gameOrder';
import { HttpUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
export interface GameOrderFilters {
page: number;
pageSize: number;
keyword: string;
status: GameOrderStatus | 'all';
channel: GameOrderChannel | 'all';
serverId: string | 'all';
}
export interface DeliverGameOrderInput extends GameOrderDeliveryInput {
orderId: string;
}
export interface RefundGameOrderInput extends GameOrderRefundInput {
orderId: string;
}
const JSON_OPTIONS = {
headers: { 'Content-Type': 'application/json' },
silentSuccess: true,
} as const;
export async function fetchGameOrders(filters: GameOrderFilters): Promise<GameOrderList> {
const msg = await HttpUtil.get('/panel/api/game/orders', filters, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch game orders');
const validated = parseMsg(msg, GameOrderListSchema, 'game/orders', { strict: true });
if (!validated.obj) throw new Error('Game order list response is empty');
return validated.obj;
}
export async function deliverGameOrder(input: DeliverGameOrderInput) {
const { orderId, ...rawPayload } = input;
const payload = GameOrderDeliveryInputSchema.parse(rawPayload);
const msg = await HttpUtil.post(
`/panel/api/game/orders/${orderId}/deliver`,
payload,
JSON_OPTIONS,
);
if (!msg?.success) throw new Error(msg?.msg || 'Failed to deliver game order');
const validated = parseMsg(msg, GameOrderSchema, 'game/orders/deliver', { strict: true });
if (!validated.obj) throw new Error('Delivered order response is empty');
return validated.obj;
}
export async function refundGameOrder(input: RefundGameOrderInput) {
const { orderId, ...rawPayload } = input;
const payload = GameOrderRefundInputSchema.parse(rawPayload);
const msg = await HttpUtil.post(
`/panel/api/game/orders/${orderId}/refund`,
payload,
JSON_OPTIONS,
);
if (!msg?.success) throw new Error(msg?.msg || 'Failed to refund game order');
const validated = parseMsg(msg, GameOrderSchema, 'game/orders/refund', { strict: true });
if (!validated.obj) throw new Error('Refunded order response is empty');
return validated.obj;
}
export function useGameOrdersQuery(filters: GameOrderFilters) {
const query = useQuery({
queryKey: keys.game.orders.list(filters),
queryFn: () => fetchGameOrders(filters),
staleTime: 10_000,
placeholderData: (previous) => previous,
});
return {
orders: query.data?.items ?? [],
total: query.data?.total ?? 0,
summary: query.data?.summary ?? {
total: 0,
paidAmount: 0,
pending: 0,
awaitingDelivery: 0,
refunding: 0,
},
serverOptions: query.data?.serverOptions ?? [],
loading: query.isFetching,
fetched: query.data !== undefined || query.isError,
fetchError: query.error ? (query.error as Error).message : '',
refresh: query.refetch,
};
}
function useInvalidateGameOrders() {
const queryClient = useQueryClient();
return () => queryClient.invalidateQueries({ queryKey: keys.game.orders.root() });
}
export function useDeliverGameOrderMutation() {
const invalidate = useInvalidateGameOrders();
return useMutation({ mutationFn: deliverGameOrder, onSuccess: invalidate });
}
export function useRefundGameOrderMutation() {
const invalidate = useInvalidateGameOrders();
return useMutation({ mutationFn: refundGameOrder, onSuccess: invalidate });
}
+39
View File
@@ -0,0 +1,39 @@
import { useQuery } from '@tanstack/react-query';
import { keys } from '@/api/queryKeys';
import { GameOverviewSchema } from '@/schemas/gameOverview';
import type { GameOverview, OverviewRange } from '@/schemas/gameOverview';
import { HttpUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
export interface GameOverviewFilters {
range: OverviewRange;
serverId: string;
}
async function fetchGameOverview(filters: GameOverviewFilters): Promise<GameOverview> {
const msg = await HttpUtil.get('/panel/api/game/overview', filters, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch game overview');
const validated = parseMsg(msg, GameOverviewSchema, 'game/overview', { strict: true });
if (!validated.obj) throw new Error('Game overview response is empty');
return validated.obj;
}
export function useGameOverviewQuery(filters: GameOverviewFilters) {
const query = useQuery({
queryKey: keys.game.overview(filters.range, filters.serverId),
queryFn: () => fetchGameOverview(filters),
refetchInterval: 30_000,
refetchIntervalInBackground: false,
staleTime: 10_000,
placeholderData: (previous) => previous,
});
return {
overview: query.data,
loading: query.isFetching,
fetched: query.data !== undefined || query.isError,
fetchError: query.error ? (query.error as Error).message : '',
refresh: query.refetch,
};
}
+115
View File
@@ -0,0 +1,115 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { keys } from '@/api/queryKeys';
import {
GamePlayerListSchema,
GamePlayerResourceInputSchema,
GamePlayerSchema,
GamePlayerStatusInputSchema,
} from '@/schemas/gamePlayer';
import type {
GamePlayerList,
GamePlayerResourceInput,
GamePlayerStatus,
GamePlayerStatusInput,
} from '@/schemas/gamePlayer';
import { HttpUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
export interface GamePlayerFilters {
page: number;
pageSize: number;
keyword: string;
status: GamePlayerStatus | 'all';
serverId: string | 'all';
}
export interface UpdateGamePlayerResourcesInput extends GamePlayerResourceInput {
playerId: string;
}
export interface UpdateGamePlayerStatusInput extends GamePlayerStatusInput {
playerId: string;
}
const JSON_OPTIONS = {
headers: { 'Content-Type': 'application/json' },
silentSuccess: true,
} as const;
export async function fetchGamePlayers(filters: GamePlayerFilters): Promise<GamePlayerList> {
const msg = await HttpUtil.get('/panel/api/game/players', filters, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch game players');
const validated = parseMsg(msg, GamePlayerListSchema, 'game/players', { strict: true });
if (!validated.obj) throw new Error('Game player list response is empty');
return validated.obj;
}
export async function updateGamePlayerResources(input: UpdateGamePlayerResourcesInput) {
const { playerId, ...rawPayload } = input;
const payload = GamePlayerResourceInputSchema.parse(rawPayload);
const msg = await HttpUtil.post(
`/panel/api/game/players/${playerId}/resources`,
payload,
JSON_OPTIONS,
);
if (!msg?.success) throw new Error(msg?.msg || 'Failed to update player resources');
const validated = parseMsg(msg, GamePlayerSchema, 'game/players/resources', { strict: true });
if (!validated.obj) throw new Error('Updated player response is empty');
return validated.obj;
}
export async function updateGamePlayerStatus(input: UpdateGamePlayerStatusInput) {
const { playerId, ...rawPayload } = input;
const payload = GamePlayerStatusInputSchema.parse(rawPayload);
const msg = await HttpUtil.post(
`/panel/api/game/players/${playerId}/status`,
payload,
JSON_OPTIONS,
);
if (!msg?.success) throw new Error(msg?.msg || 'Failed to update player status');
const validated = parseMsg(msg, GamePlayerSchema, 'game/players/status', { strict: true });
if (!validated.obj) throw new Error('Updated player response is empty');
return validated.obj;
}
export function useGamePlayersQuery(filters: GamePlayerFilters) {
const query = useQuery({
queryKey: keys.game.players.list(filters),
queryFn: () => fetchGamePlayers(filters),
staleTime: 10_000,
placeholderData: (previous) => previous,
});
return {
players: query.data?.items ?? [],
total: query.data?.total ?? 0,
summary: query.data?.summary ?? {
total: 0,
online: 0,
activeToday: 0,
newToday: 0,
banned: 0,
},
serverOptions: query.data?.serverOptions ?? [],
loading: query.isFetching,
fetched: query.data !== undefined || query.isError,
fetchError: query.error ? (query.error as Error).message : '',
refresh: query.refetch,
};
}
function useInvalidateGamePlayers() {
const queryClient = useQueryClient();
return () => queryClient.invalidateQueries({ queryKey: keys.game.players.root() });
}
export function useGamePlayerResourcesMutation() {
const invalidate = useInvalidateGamePlayers();
return useMutation({ mutationFn: updateGamePlayerResources, onSuccess: invalidate });
}
export function useGamePlayerStatusMutation() {
const invalidate = useInvalidateGamePlayers();
return useMutation({ mutationFn: updateGamePlayerStatus, onSuccess: invalidate });
}
+125
View File
@@ -0,0 +1,125 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { keys } from '@/api/queryKeys';
import {
GameServerListSchema,
GameServerSchema,
GameServerStatusInputSchema,
GameServerUpsertInputSchema,
} from '@/schemas/gameServer';
import type {
GameServerList,
GameServerStatus,
GameServerStatusInput,
GameServerUpsertInput,
} from '@/schemas/gameServer';
import { HttpUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
export interface GameServerFilters {
page: number;
pageSize: number;
keyword: string;
status: GameServerStatus | 'all';
region: string | 'all';
}
export interface UpdateGameServerInput extends GameServerUpsertInput {
serverId: string;
}
export interface UpdateGameServerStatusInput extends GameServerStatusInput {
serverId: string;
}
const JSON_OPTIONS = {
headers: { 'Content-Type': 'application/json' },
silentSuccess: true,
} as const;
export async function fetchGameServers(filters: GameServerFilters): Promise<GameServerList> {
const msg = await HttpUtil.get('/panel/api/game/servers', filters, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch game servers');
const validated = parseMsg(msg, GameServerListSchema, 'game/servers', { strict: true });
if (!validated.obj) throw new Error('Game server list response is empty');
return validated.obj;
}
export async function createGameServer(input: GameServerUpsertInput) {
const payload = GameServerUpsertInputSchema.parse(input);
const msg = await HttpUtil.post('/panel/api/game/servers', payload, JSON_OPTIONS);
if (!msg?.success) throw new Error(msg?.msg || 'Failed to create game server');
const validated = parseMsg(msg, GameServerSchema, 'game/servers/create', { strict: true });
if (!validated.obj) throw new Error('Created game server response is empty');
return validated.obj;
}
export async function updateGameServer(input: UpdateGameServerInput) {
const { serverId, ...rawPayload } = input;
const payload = GameServerUpsertInputSchema.parse(rawPayload);
const msg = await HttpUtil.post(`/panel/api/game/servers/${serverId}`, payload, JSON_OPTIONS);
if (!msg?.success) throw new Error(msg?.msg || 'Failed to update game server');
const validated = parseMsg(msg, GameServerSchema, 'game/servers/update', { strict: true });
if (!validated.obj) throw new Error('Updated game server response is empty');
return validated.obj;
}
export async function updateGameServerStatus(input: UpdateGameServerStatusInput) {
const { serverId, ...rawPayload } = input;
const payload = GameServerStatusInputSchema.parse(rawPayload);
const msg = await HttpUtil.post(
`/panel/api/game/servers/${serverId}/status`,
payload,
JSON_OPTIONS,
);
if (!msg?.success) throw new Error(msg?.msg || 'Failed to update game server status');
const validated = parseMsg(msg, GameServerSchema, 'game/servers/status', { strict: true });
if (!validated.obj) throw new Error('Updated game server response is empty');
return validated.obj;
}
export function useGameServersQuery(filters: GameServerFilters) {
const query = useQuery({
queryKey: keys.game.servers.list(filters),
queryFn: () => fetchGameServers(filters),
staleTime: 10_000,
placeholderData: (previous) => previous,
});
return {
servers: query.data?.items ?? [],
total: query.data?.total ?? 0,
summary: query.data?.summary ?? {
total: 0,
online: 0,
maintenance: 0,
offline: 0,
onlinePlayers: 0,
},
regionOptions: query.data?.regionOptions ?? [],
loading: query.isFetching,
fetched: query.data !== undefined || query.isError,
fetchError: query.error ? (query.error as Error).message : '',
refresh: query.refetch,
};
}
function useInvalidateGameServers() {
const queryClient = useQueryClient();
return () => queryClient.invalidateQueries({ queryKey: keys.game.servers.root() });
}
export function useCreateGameServerMutation() {
const invalidate = useInvalidateGameServers();
return useMutation({ mutationFn: createGameServer, onSuccess: invalidate });
}
export function useUpdateGameServerMutation() {
const invalidate = useInvalidateGameServers();
return useMutation({ mutationFn: updateGameServer, onSuccess: invalidate });
}
export function useGameServerStatusMutation() {
const invalidate = useInvalidateGameServers();
return useMutation({ mutationFn: updateGameServerStatus, onSuccess: invalidate });
}
+29
View File
@@ -0,0 +1,29 @@
export const keys = {
game: {
overview: (range: string, serverId: string) => ['game', 'overview', range, serverId] as const,
accounts: {
root: () => ['game', 'accounts'] as const,
list: (filters: unknown) => ['game', 'accounts', 'list', filters] as const,
},
servers: {
root: () => ['game', 'servers'] as const,
list: (filters: unknown) => ['game', 'servers', 'list', filters] as const,
},
players: {
root: () => ['game', 'players'] as const,
list: (filters: unknown) => ['game', 'players', 'list', filters] as const,
},
cdks: {
root: () => ['game', 'cdks'] as const,
list: (filters: unknown) => ['game', 'cdks', 'list', filters] as const,
},
announcements: {
root: () => ['game', 'announcements'] as const,
list: (filters: unknown) => ['game', 'announcements', 'list', filters] as const,
},
orders: {
root: () => ['game', 'orders'] as const,
list: (filters: unknown) => ['game', 'orders', 'list', filters] as const,
},
},
} as const;
@@ -0,0 +1,171 @@
import { useEffect } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, waitFor } from 'storybook/test';
import { Button, Form, Input, InputNumber, Select, Switch, Typography } from 'antd';
import { FormProvider } from 'react-hook-form';
import { z } from 'zod';
import { FormField } from './FormField';
import { useZodForm } from './useZodForm';
const meta = {
title: 'Form/RHF/FormField',
component: FormField,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Connects an Ant Design control to react-hook-form, normalizes its change payload, and renders translated validation feedback.',
},
},
},
argTypes: {
name: { description: 'Field path as a dotted string or an array of path segments.' },
control: {
description: 'Optional react-hook-form control; defaults to the surrounding FormProvider.',
},
label: { description: 'Form.Item label.' },
tooltip: { description: 'Form.Item tooltip shown beside the label.' },
extra: { description: 'Helper text rendered below the input.' },
valueProp: {
description: 'Value prop passed to the child, such as value or checked.',
},
transform: {
description: 'Optional input and output mappers between form state and the displayed value.',
},
onAfterChange: { description: 'Called with the stored value after every change.' },
rules: { description: 'Controller validation rules.' },
required: { description: 'Marks the field as required.' },
noStyle: { description: 'Renders without Form.Item layout chrome.' },
children: { description: 'The Ant Design control to connect.' },
},
} satisfies Meta<typeof FormField>;
export default meta;
type Story = StoryObj<typeof meta>;
const AccountSchema = z.object({
username: z.string(),
platform: z.string(),
enabled: z.boolean(),
});
function AccountDemo() {
const methods = useZodForm(AccountSchema, {
defaultValues: { username: 'player_1001', platform: 'official', enabled: true },
});
return (
<FormProvider {...methods}>
<Form layout="vertical" style={{ maxWidth: 360 }}>
<FormField name="username" label="Account" required>
<Input placeholder="player_1001" />
</FormField>
<FormField name="platform" label="Platform">
<Select
options={[
{ label: 'Official', value: 'official' },
{ label: 'Steam', value: 'steam' },
]}
/>
</FormField>
<FormField name="enabled" label="Enabled" valueProp="checked">
<Switch />
</FormField>
</Form>
</FormProvider>
);
}
const ResourceSchema = z.object({
resourceAmount: z.number(),
});
function ResourceDemo() {
const methods = useZodForm(ResourceSchema, { defaultValues: { resourceAmount: 5000 } });
const resourceAmount = methods.watch('resourceAmount');
return (
<FormProvider {...methods}>
<Form layout="vertical" style={{ maxWidth: 360 }}>
<FormField
name="resourceAmount"
label="Resource packs"
extra="Each displayed pack represents 100 resource points"
transform={{
input: (value) => (typeof value === 'number' ? value / 100 : value),
output: (value) => (typeof value === 'number' ? value * 100 : 0),
}}
>
<InputNumber min={0} style={{ width: '100%' }} />
</FormField>
<Typography.Text type="secondary">Form state: {resourceAmount} points</Typography.Text>
</Form>
</FormProvider>
);
}
const ServerSchema = z.object({
name: z.string().min(1, 'Server name is required'),
port: z
.number()
.min(1, 'Port must be between 1 and 65535')
.max(65535, 'Port must be between 1 and 65535'),
});
function ValidationDemo() {
const methods = useZodForm(ServerSchema, { defaultValues: { name: '', port: 0 } });
useEffect(() => {
void methods.trigger();
}, [methods]);
return (
<FormProvider {...methods}>
<Form layout="vertical" style={{ maxWidth: 360 }}>
<FormField name="name" label="Server name" required>
<Input placeholder="Aurora One" />
</FormField>
<FormField name="port" label="Port" required>
<InputNumber style={{ width: '100%' }} />
</FormField>
<Button onClick={() => void methods.trigger()}>Validate</Button>
</Form>
</FormProvider>
);
}
const placeholderArgs = {
name: 'username',
children: <Input />,
};
export const AccountFields: Story = {
args: placeholderArgs,
render: () => <AccountDemo />,
};
export const NumericTransform: Story = {
args: placeholderArgs,
render: () => <ResourceDemo />,
play: async ({ canvas, userEvent }) => {
const input = canvas.getByRole('spinbutton');
await userEvent.clear(input);
await userEvent.type(input, '125');
await expect(await canvas.findByText('Form state: 12500 points')).toBeVisible();
},
};
export const ValidationErrors: Story = {
args: placeholderArgs,
render: () => <ValidationDemo />,
play: async ({ canvas, userEvent }) => {
const nameError = await canvas.findByText('Server name is required');
await waitFor(() => expect(nameError).toBeVisible());
await waitFor(() => expect(canvas.getByText('Port must be between 1 and 65535')).toBeVisible());
await userEvent.type(canvas.getByPlaceholderText('Aurora One'), 'Aurora One');
await userEvent.click(canvas.getByRole('button', { name: 'Validate' }));
await waitFor(() =>
expect(canvas.queryByText('Server name is required')).not.toBeInTheDocument(),
);
},
};
+98
View File
@@ -0,0 +1,98 @@
import { cloneElement, useId } from 'react';
import type { CSSProperties, ReactElement, ReactNode } from 'react';
import { Controller } from 'react-hook-form';
import type { Control, ControllerProps, FieldValues, Path } from 'react-hook-form';
import { Form } from 'antd';
import { useTranslation } from 'react-i18next';
import { normalizeAntdOnChange, type ValueProp } from './normalizeAntdOnChange';
import { toDotted, type FieldName } from './toDotted';
interface FormFieldTransform {
input?: (value: unknown) => unknown;
output?: (eventValue: unknown) => unknown;
}
export interface FormFieldProps<T extends FieldValues = FieldValues> {
name: FieldName;
control?: Control<T>;
label?: ReactNode;
tooltip?: ReactNode;
extra?: ReactNode;
valueProp?: ValueProp;
transform?: FormFieldTransform;
onAfterChange?: (value: unknown) => void;
rules?: ControllerProps<T>['rules'];
required?: boolean;
noStyle?: boolean;
className?: string;
style?: CSSProperties;
children: ReactElement;
}
export function FormField<T extends FieldValues = FieldValues>({
name,
control,
label,
tooltip,
extra,
valueProp = 'value',
transform,
onAfterChange,
rules,
required,
noStyle,
className,
style,
children,
}: FormFieldProps<T>) {
const { t } = useTranslation();
const dottedName = toDotted(name) as Path<T>;
const generatedId = useId();
const explicitId = (children as ReactElement<{ id?: string }>).props.id;
const fieldId = explicitId ?? generatedId;
return (
<Controller
control={control}
name={dottedName}
rules={rules}
render={({ field, fieldState }) => {
const displayValue = transform?.input ? transform.input(field.value) : field.value;
const help = fieldState.error?.message
? t(fieldState.error.message, { defaultValue: fieldState.error.message })
: undefined;
const childProps: Record<string, unknown> = {
id: fieldId,
[valueProp]: displayValue,
onChange: (...args: unknown[]) => {
const raw = normalizeAntdOnChange(args, valueProp);
const next = transform?.output ? transform.output(raw) : raw;
field.onChange(next);
onAfterChange?.(next);
},
onBlur: field.onBlur,
ref: field.ref,
};
return (
<Form.Item
label={label}
htmlFor={label ? fieldId : undefined}
tooltip={tooltip}
extra={extra}
required={required}
validateStatus={fieldState.error ? 'error' : undefined}
help={help}
noStyle={noStyle}
className={className}
style={style}
>
{cloneElement(children as ReactElement<Record<string, unknown>>, childProps)}
</Form.Item>
);
}}
/>
);
}
export default FormField;
+5
View File
@@ -0,0 +1,5 @@
export { FormField, type FormFieldProps } from './FormField';
export { useZodForm } from './useZodForm';
export { rhfZodValidate } from './rhfZodValidate';
export { normalizeAntdOnChange, type ValueProp } from './normalizeAntdOnChange';
export { toDotted, type FieldName } from './toDotted';
@@ -0,0 +1,10 @@
export type ValueProp = 'value' | 'checked';
export function normalizeAntdOnChange(args: unknown[], valueProp: ValueProp): unknown {
const first = args[0];
if (first !== null && typeof first === 'object' && 'target' in first) {
const target = (first as { target: { value?: unknown; checked?: unknown } }).target;
return valueProp === 'checked' ? target.checked : target.value;
}
return first;
}
@@ -0,0 +1,9 @@
import type { z } from 'zod';
export function rhfZodValidate<S extends z.ZodType>(schema: S) {
return (value: unknown): string | true => {
const result = schema.safeParse(value);
if (result.success) return true;
return result.error.issues[0]?.message ?? 'validation.invalid';
};
}
+5
View File
@@ -0,0 +1,5 @@
export type FieldName = string | ReadonlyArray<string | number>;
export function toDotted(name: FieldName): string {
return typeof name === 'string' ? name : name.join('.');
}
+20
View File
@@ -0,0 +1,20 @@
import { useForm } from 'react-hook-form';
import type { FieldValues, Resolver, UseFormProps, UseFormReturn } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import type { z } from 'zod';
export function useZodForm<TFieldValues extends FieldValues>(
schema: z.ZodType<TFieldValues>,
options?: Omit<UseFormProps<TFieldValues>, 'resolver'>,
): UseFormReturn<TFieldValues> {
const resolver = zodResolver(
schema as z.ZodType<TFieldValues, TFieldValues>,
) as Resolver<TFieldValues>;
return useForm<TFieldValues>({
mode: 'onSubmit',
reValidateMode: 'onChange',
shouldUnregister: false,
...options,
resolver,
});
}
+113
View File
@@ -0,0 +1,113 @@
.sparkline-plot {
display: block;
width: 100%;
}
.sparkline-plot .uplot,
.sparkline-plot .u-wrap {
width: 100%;
}
.sparkline-plot .u-cursor-x {
border-right: 1px dashed var(--ant-color-border);
}
.sparkline-container {
position: relative;
width: 100%;
}
.sparkline-tooltip {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
white-space: nowrap;
z-index: 2;
padding: 6px 10px;
border: 1px solid var(--ant-color-border-secondary);
border-radius: 6px;
background: var(--ant-color-bg-elevated);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12);
color: var(--ant-color-text);
font-size: 12px;
}
.sparkline-tooltip .spk-tt-label {
margin-bottom: 4px;
color: var(--ant-color-text-tertiary);
font-size: 11px;
}
.sparkline-tooltip .spk-tt-row {
display: flex;
align-items: center;
gap: 6px;
font-weight: 500;
line-height: 16px;
}
.sparkline-tooltip .spk-tt-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex: none;
}
.sparkline-tooltip .spk-tt-name {
color: var(--ant-color-text-tertiary);
}
.sparkline-tooltip .spk-tt-val {
margin-left: auto;
}
.sparkline-extrema {
position: absolute;
top: 2px;
right: 8px;
display: inline-flex;
align-items: center;
gap: 12px;
padding: 2px 8px;
background: color-mix(in srgb, var(--ant-color-bg-elevated) 88%, transparent);
border: 1px solid var(--ant-color-border-secondary);
border-radius: 999px;
font-size: 11px;
font-weight: 600;
line-height: 16px;
pointer-events: none;
z-index: 1;
}
.sparkline-extrema .extrema-item {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.sparkline-legend {
position: absolute;
top: 2px;
right: 8px;
display: inline-flex;
align-items: center;
gap: 12px;
padding: 2px 8px;
background: color-mix(in srgb, var(--ant-color-bg-elevated) 88%, transparent);
border: 1px solid var(--ant-color-border-secondary);
border-radius: 999px;
font-size: 11px;
font-weight: 600;
line-height: 16px;
pointer-events: none;
z-index: 1;
}
.sparkline-legend .extrema-item {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
+64
View File
@@ -0,0 +1,64 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import Sparkline from './Sparkline';
const wave = Array.from(
{ length: 48 },
(_, i) => 45 + Math.round(28 * Math.sin(i / 4) + (i % 5) * 3),
);
const inverse = wave.map((v) => Math.max(0, 100 - v));
const meta = {
title: 'Viz/Sparkline',
component: Sparkline,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Compact canvas line chart (uPlot) for CPU, memory, and traffic trends. Supports up to three series, optional axes/grid, a hover tooltip, min/max markers, reference lines, and light/dark theming.',
},
},
},
argTypes: {
data: { description: 'Primary series values, oldest to newest.' },
data2: { description: 'Optional second series (e.g. download vs upload).' },
data3: { description: 'Optional third series.' },
height: { description: 'Chart height in pixels.' },
name1: { description: 'Legend/tooltip label for the primary series.' },
name2: { description: 'Legend/tooltip label for the second series.' },
showAxes: { description: 'Render x/y axes and tick labels.' },
showGrid: { description: 'Draw horizontal grid lines.' },
showTooltip: { description: 'Show a value tooltip on hover.' },
extrema: { description: 'Highlight the min and max points (single-series only).' },
},
} satisfies Meta<typeof Sparkline>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: { data: wave, height: 80 },
};
export const AxesAndGrid: Story = {
args: { data: wave, height: 140, showAxes: true, showGrid: true, name1: 'CPU' },
};
export const Extrema: Story = {
args: { data: wave, height: 140, name1: 'CPU', extrema: { show: true } },
};
export const MultiSeriesTooltip: Story = {
args: {
data: wave,
data2: inverse,
name1: 'Upload',
name2: 'Download',
height: 140,
showTooltip: true,
showAxes: true,
},
};
+611
View File
@@ -0,0 +1,611 @@
import { useEffect, useMemo, useRef } from 'react';
import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css';
import './Sparkline.css';
export interface SparklineReferenceLine {
y: number;
label?: string;
color?: string;
dash?: string;
}
export interface SparklineExtrema {
show?: boolean;
formatter?: (v: number) => string;
minColor?: string;
maxColor?: string;
}
const DEFAULT_STROKE = '#008771';
const DEFAULT_STROKE2 = '#722ed1';
const DEFAULT_STROKE3 = '#a0d911';
const DEFAULT_MIN_COLOR = '#52c41a';
const DEFAULT_MAX_COLOR = '#fa541c';
const GRID_COLOR = 'rgba(128, 128, 140, 0.35)';
const AXIS_FONT = '10px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif';
const LABEL_FONT = 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif';
interface SparklineProps {
data: number[];
data2?: number[];
data3?: number[];
stroke2?: string;
stroke3?: string;
name1?: string;
name2?: string;
name3?: string;
labels?: (string | number)[];
height?: number;
stroke?: string;
strokeWidth?: number;
maxPoints?: number;
showGrid?: boolean;
fillOpacity?: number;
showMarker?: boolean;
markerRadius?: number;
showAxes?: boolean;
yTickStep?: number;
tickCountX?: number;
showTooltip?: boolean;
showLegend?: boolean;
valueMin?: number;
valueMax?: number | null;
yFormatter?: (v: number) => string;
tooltipFormatter?: ((v: number) => string) | null;
tooltipLabelFormatter?: ((label: string) => string) | null;
referenceLines?: SparklineReferenceLine[];
extrema?: SparklineExtrema;
}
interface ChartPoint {
index: number;
value: number;
value2: number;
value3: number;
label: string;
}
interface ExtremaResult {
min: ChartPoint;
max: ChartPoint;
minIdx: number;
maxIdx: number;
}
interface SparklineView {
points: ChartPoint[];
yDomain: [number, number];
yTicks: number[] | undefined;
xTickIndexes: number[] | undefined;
extremaPoints: ExtremaResult | null;
}
function hexToRgba(color: string, alpha: number): string {
const trimmed = color.trim();
const fn = trimmed.match(/^rgba?\(([^)]+)\)$/i);
if (fn) {
const parts = fn[1]
.split(/[,/]\s*|\s+/)
.filter(Boolean)
.map(Number);
if (parts.length >= 3 && parts.slice(0, 3).every((n) => Number.isFinite(n))) {
const baseAlpha = parts.length > 3 && Number.isFinite(parts[3]) ? parts[3] : 1;
return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${baseAlpha * alpha})`;
}
return trimmed;
}
let h = trimmed;
if (h.startsWith('#')) h = h.slice(1);
if (h.length === 3)
h = h
.split('')
.map((c) => c + c)
.join('');
if (h.length !== 6) return trimmed;
const int = Number.parseInt(h, 16);
if (Number.isNaN(int)) return trimmed;
const r = (int >> 16) & 255;
const g = (int >> 8) & 255;
const b = int & 255;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
function cssVar(el: HTMLElement, name: string, fallback: string): string {
const v = getComputedStyle(el).getPropertyValue(name).trim();
return v || fallback;
}
function parseDash(dash: string, dpr: number): number[] {
return dash
.trim()
.split(/\s+/)
.map((n) => (Number(n) || 0) * dpr);
}
function dprOf(u: uPlot): number {
return u.width > 0 ? u.ctx.canvas.width / u.width : uPlot.pxRatio || 1;
}
export default function Sparkline(props: SparklineProps) {
const {
data,
data2 = [],
data3 = [],
stroke = DEFAULT_STROKE,
stroke2 = DEFAULT_STROKE2,
stroke3 = DEFAULT_STROKE3,
name1,
name2,
name3,
labels = [],
height = 80,
strokeWidth = 2,
maxPoints = 120,
showGrid = true,
fillOpacity = 0.22,
showMarker = true,
markerRadius = 3,
showAxes = false,
yTickStep = 25,
tickCountX = 4,
showTooltip = false,
showLegend = true,
valueMin = 0,
valueMax = 100,
yFormatter = (v: number) => `${Math.round(v)}%`,
tooltipFormatter = null,
tooltipLabelFormatter = null,
referenceLines,
extrema,
} = props;
const hasSeries2 = data2.length > 0;
const hasSeries3 = data3.length > 0;
const multiSeries = hasSeries2 || hasSeries3;
const points = useMemo<ChartPoint[]>(() => {
const n = Math.min(data.length, maxPoints);
if (n === 0) return [];
const sliceStart = data.length - n;
const labelStart = Math.max(0, labels.length - n);
const slice2Start = data2.length - n;
const slice3Start = data3.length - n;
return data.slice(sliceStart).map((value, i) => ({
index: i,
value: Number(value) || 0,
value2: data2.length ? Number(data2[slice2Start + i]) || 0 : 0,
value3: data3.length ? Number(data3[slice3Start + i]) || 0 : 0,
label: String(labels[labelStart + i] ?? i + 1),
}));
}, [data, data2, data3, labels, maxPoints]);
const yDomain = useMemo<[number, number]>(() => {
if (valueMax != null) return [valueMin, valueMax];
let max = valueMin;
for (const p of points) {
if (Number.isFinite(p.value) && p.value > max) max = p.value;
if (hasSeries2 && Number.isFinite(p.value2) && p.value2 > max) max = p.value2;
if (hasSeries3 && Number.isFinite(p.value3) && p.value3 > max) max = p.value3;
}
if (max <= valueMin) max = valueMin + 1;
return [valueMin, max * 1.1];
}, [points, valueMin, valueMax, hasSeries2, hasSeries3]);
const yTicks = useMemo<number[] | undefined>(() => {
if (!showAxes) return undefined;
const [min, max] = yDomain;
if (valueMax === 100 && valueMin === 0 && yTickStep > 0) {
const out: number[] = [];
for (let v = min; v <= max; v += yTickStep) out.push(v);
return out;
}
const n = 5;
return Array.from({ length: n }, (_, i) => min + ((max - min) * i) / (n - 1));
}, [showAxes, yDomain, valueMin, valueMax, yTickStep]);
const xTickIndexes = useMemo<number[] | undefined>(() => {
if (!showAxes || points.length === 0) return undefined;
const m = Math.max(2, tickCountX);
return Array.from({ length: m }, (_, i) => Math.round((i * (points.length - 1)) / (m - 1)));
}, [showAxes, tickCountX, points.length]);
const extremaPoints = useMemo<ExtremaResult | null>(() => {
if (!extrema?.show || multiSeries || points.length < 2) return null;
let minIdx = 0;
let maxIdx = 0;
for (let i = 1; i < points.length; i++) {
if (points[i].value < points[minIdx].value) minIdx = i;
if (points[i].value > points[maxIdx].value) maxIdx = i;
}
if (minIdx === maxIdx) return null;
return { min: points[minIdx], max: points[maxIdx], minIdx, maxIdx };
}, [points, extrema?.show, multiSeries]);
const legendItems = useMemo(
() =>
[
{ name: name1, color: stroke },
{ name: name2, color: stroke2 },
{ name: name3, color: stroke3 },
].filter((s, i) => s.name && (i === 0 ? multiSeries : i === 1 ? hasSeries2 : hasSeries3)),
[name1, name2, name3, stroke, stroke2, stroke3, multiSeries, hasSeries2, hasSeries3],
);
const fmtExtrema = extrema?.formatter ?? yFormatter;
const minColor = extrema?.minColor ?? DEFAULT_MIN_COLOR;
const maxColor = extrema?.maxColor ?? DEFAULT_MAX_COLOR;
const ariaSummary = useMemo(() => {
if (points.length === 0) return name1 ?? '';
const last = points[points.length - 1];
const parts: string[] = [];
parts.push(name1 ? `${name1}: ${yFormatter(last.value)}` : yFormatter(last.value));
if (hasSeries2 && name2) parts.push(`${name2}: ${yFormatter(last.value2)}`);
if (hasSeries3 && name3) parts.push(`${name3}: ${yFormatter(last.value3)}`);
return parts.join(', ');
}, [points, name1, name2, name3, hasSeries2, hasSeries3, yFormatter]);
const cfg = {
stroke,
stroke2,
stroke3,
strokeWidth,
fillOpacity,
markerRadius,
showGrid,
showMarker,
showAxes,
showTooltip,
height,
name1,
name2,
name3,
yFormatter,
tooltipFormatter,
tooltipLabelFormatter,
referenceLines,
extrema,
};
const cfgRef = useRef(cfg);
const viewRef = useRef<SparklineView>({ points, yDomain, yTicks, xTickIndexes, extremaPoints });
useEffect(() => {
cfgRef.current = cfg;
viewRef.current = { points, yDomain, yTicks, xTickIndexes, extremaPoints };
});
const containerRef = useRef<HTMLDivElement>(null);
const plotRef = useRef<uPlot | null>(null);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let tooltipEl: HTMLDivElement | null = null;
const seriesColor = (idx: number): string => {
const p = cfgRef.current;
if (idx <= 1) return p.stroke ?? DEFAULT_STROKE;
if (idx === 2) return p.stroke2 ?? DEFAULT_STROKE2;
return p.stroke3 ?? DEFAULT_STROKE3;
};
const gridTicks = (): number[] => {
const yt = viewRef.current.yTicks;
if (yt && yt.length) return yt;
const [mn, mx] = viewRef.current.yDomain;
const n = 4;
return Array.from({ length: n + 1 }, (_, i) => mn + ((mx - mn) * i) / n);
};
const buildData = (): uPlot.AlignedData => {
const v = viewRef.current;
const xs = v.points.map((_, i) => i);
const series: number[][] = [v.points.map((p) => p.value)];
if (hasSeries2) series.push(v.points.map((p) => p.value2));
if (hasSeries3) series.push(v.points.map((p) => p.value3));
return [xs, ...series];
};
const makeSeries = (): uPlot.Series => ({
stroke: (_u, sidx) => seriesColor(sidx),
width: cfgRef.current.strokeWidth ?? 2,
fill: (u, sidx) => {
const { ctx, bbox } = u;
const color = seriesColor(sidx);
const grad = ctx.createLinearGradient(0, bbox.top, 0, bbox.top + bbox.height);
grad.addColorStop(0, hexToRgba(color, cfgRef.current.fillOpacity ?? 0.22));
grad.addColorStop(1, hexToRgba(color, 0));
return grad;
},
paths: uPlot.paths.spline?.(),
points: { show: false },
spanGaps: true,
});
const series: uPlot.Series[] = [{}, makeSeries()];
if (hasSeries2) series.push(makeSeries());
if (hasSeries3) series.push(makeSeries());
const axisStroke = (u: uPlot) => cssVar(u.root, '--ant-color-text-tertiary', '#8c8c8c');
const axes: uPlot.Axis[] = [
{
show: showAxes,
stroke: axisStroke,
grid: { show: false },
ticks: { show: false },
font: AXIS_FONT,
gap: 6,
size: 28,
splits: () => viewRef.current.xTickIndexes ?? [],
values: (_u, splits) => splits.map((i) => viewRef.current.points[i]?.label ?? ''),
},
{
show: showAxes,
scale: 'y',
side: 3,
stroke: axisStroke,
grid: { show: false },
ticks: { show: false },
font: AXIS_FONT,
gap: 4,
size: 56,
splits: () => viewRef.current.yTicks ?? [],
values: (_u, splits) =>
splits.map((v) => (cfgRef.current.yFormatter ? cfgRef.current.yFormatter(v) : String(v))),
},
];
const drawGrid = (u: uPlot) => {
if (cfgRef.current.showGrid === false) return;
const { ctx, bbox } = u;
const dpr = dprOf(u);
ctx.save();
ctx.strokeStyle = GRID_COLOR;
ctx.lineWidth = dpr;
ctx.setLineDash([3 * dpr, 4 * dpr]);
ctx.beginPath();
for (const ty of gridTicks()) {
const py = Math.round(u.valToPos(ty, 'y', true)) + 0.5;
ctx.moveTo(bbox.left, py);
ctx.lineTo(bbox.left + bbox.width, py);
}
ctx.stroke();
ctx.restore();
};
const drawOverlay = (u: uPlot) => {
const p = cfgRef.current;
const v = viewRef.current;
const { ctx, bbox } = u;
const dpr = dprOf(u);
const right = bbox.left + bbox.width;
if (p.referenceLines?.length) {
for (const rl of p.referenceLines) {
const color = rl.color || p.stroke || DEFAULT_STROKE;
const py = Math.round(u.valToPos(rl.y, 'y', true)) + 0.5;
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = 1.4 * dpr;
ctx.setLineDash(parseDash(rl.dash ?? '5 4', dpr));
ctx.beginPath();
ctx.moveTo(bbox.left, py);
ctx.lineTo(right, py);
ctx.stroke();
ctx.restore();
if (rl.label) {
ctx.save();
ctx.fillStyle = color;
ctx.font = `600 ${10 * dpr}px ${LABEL_FONT}`;
ctx.textAlign = 'right';
ctx.textBaseline = 'bottom';
ctx.fillText(rl.label, right - 4 * dpr, py - 3 * dpr);
ctx.restore();
}
}
}
const ex = v.extremaPoints;
if (p.extrema?.show && ex) {
const ringColor = cssVar(u.root, '--ant-color-bg-elevated', '#ffffff');
const dot = (value: number, idx: number, color: string) => {
const px = u.valToPos(idx, 'x', true);
const py = u.valToPos(value, 'y', true);
ctx.save();
ctx.beginPath();
ctx.arc(px, py, 4.5 * dpr, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.lineWidth = 2 * dpr;
ctx.strokeStyle = ringColor;
ctx.stroke();
ctx.restore();
};
dot(ex.max.value, ex.maxIdx, p.extrema.maxColor ?? DEFAULT_MAX_COLOR);
dot(ex.min.value, ex.minIdx, p.extrema.minColor ?? DEFAULT_MIN_COLOR);
}
};
const updateTooltip = (u: uPlot) => {
if (!tooltipEl) return;
const idx = u.cursor.idx;
const v = viewRef.current;
const p = cfgRef.current;
if (idx == null || idx < 0 || idx >= v.points.length) {
tooltipEl.style.display = 'none';
return;
}
const pt = v.points[idx];
const fmt = p.tooltipFormatter ?? p.yFormatter ?? ((x: number) => String(x));
const label = p.tooltipLabelFormatter
? p.tooltipLabelFormatter(String(pt.label))
: String(pt.label);
const multi = hasSeries2 || hasSeries3;
tooltipEl.textContent = '';
const labelDiv = document.createElement('div');
labelDiv.className = 'spk-tt-label';
labelDiv.textContent = label;
tooltipEl.appendChild(labelDiv);
const rows = [
{ name: p.name1, color: p.stroke ?? DEFAULT_STROKE, val: pt.value, on: true },
{ name: p.name2, color: p.stroke2 ?? DEFAULT_STROKE2, val: pt.value2, on: hasSeries2 },
{ name: p.name3, color: p.stroke3 ?? DEFAULT_STROKE3, val: pt.value3, on: hasSeries3 },
];
for (const r of rows) {
if (!r.on) continue;
const row = document.createElement('div');
row.className = 'spk-tt-row';
if (multi) {
const marker = document.createElement('span');
marker.className = 'spk-tt-dot';
marker.style.background = r.color;
row.appendChild(marker);
const nm = document.createElement('span');
nm.className = 'spk-tt-name';
nm.textContent = r.name ?? '';
row.appendChild(nm);
}
const val = document.createElement('span');
val.className = 'spk-tt-val';
val.textContent = fmt(r.val);
row.appendChild(val);
tooltipEl.appendChild(row);
}
tooltipEl.style.display = '';
const overW = u.over.clientWidth;
const overH = u.over.clientHeight;
const cx = u.cursor.left ?? 0;
const cy = u.cursor.top ?? 0;
const w = tooltipEl.offsetWidth;
const h = tooltipEl.offsetHeight;
let x = cx + 12;
if (x + w + 8 > overW) x = cx - w - 12;
if (x < 0) x = 4;
let y = cy - h - 12;
if (y < 0) y = Math.min(cy + 12, overH - h - 4);
tooltipEl.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`;
};
const opts: uPlot.Options = {
width: container.clientWidth || 600,
height,
padding: [8, 8, showAxes ? 0 : 2, showAxes ? 0 : 2],
legend: { show: false },
cursor: {
show: showTooltip,
x: showTooltip,
y: false,
drag: { x: false, y: false, setScale: false },
points: {
show: showMarker,
size: () => (cfgRef.current.markerRadius ?? 3) * 2,
width: 0,
stroke: (_u, sidx) => seriesColor(sidx),
fill: (_u, sidx) => seriesColor(sidx),
},
},
scales: {
x: {
time: false,
range: (_u, dmin, dmax) => (dmin === dmax ? [dmin - 0.5, dmax + 0.5] : [dmin, dmax]),
},
y: {
range: () => {
const [mn, mx] = viewRef.current.yDomain;
return [mn, mx];
},
},
},
series,
axes,
hooks: {
init: [
(u) => {
if (!cfgRef.current.showTooltip) return;
tooltipEl = document.createElement('div');
tooltipEl.className = 'sparkline-tooltip';
tooltipEl.style.display = 'none';
u.over.appendChild(tooltipEl);
},
],
drawClear: [drawGrid],
draw: [drawOverlay],
setCursor: [updateTooltip],
},
};
const u = new uPlot(opts, buildData(), container);
plotRef.current = u;
const ro = new ResizeObserver(() => {
const w = container.clientWidth;
if (w > 0) u.setSize({ width: w, height });
});
ro.observe(container);
return () => {
ro.disconnect();
u.destroy();
plotRef.current = null;
tooltipEl = null;
};
}, [hasSeries2, hasSeries3, showAxes, showTooltip, showMarker, height]);
useEffect(() => {
plotRef.current?.setData(
(() => {
const xs = points.map((_, i) => i);
const s: number[][] = [points.map((p) => p.value)];
if (hasSeries2) s.push(points.map((p) => p.value2));
if (hasSeries3) s.push(points.map((p) => p.value3));
return [xs, ...s] as uPlot.AlignedData;
})(),
);
}, [points, hasSeries2, hasSeries3, valueMin, valueMax]);
useEffect(() => {
const redraw = () => plotRef.current?.redraw(false);
const moBody = new MutationObserver(redraw);
moBody.observe(document.body, { attributes: true, attributeFilter: ['class'] });
const moRoot = new MutationObserver(redraw);
moRoot.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => {
moBody.disconnect();
moRoot.disconnect();
};
}, []);
return (
<div
className="sparkline-container"
role={ariaSummary ? 'img' : undefined}
aria-label={ariaSummary || undefined}
>
{extremaPoints && (
<div className="sparkline-extrema" aria-hidden="true">
<span className="extrema-item" style={{ color: maxColor }}>
▲ {fmtExtrema(extremaPoints.max.value)}
</span>
<span className="extrema-item" style={{ color: minColor }}>
▼ {fmtExtrema(extremaPoints.min.value)}
</span>
</div>
)}
{showLegend && legendItems.length > 0 && (
<div className="sparkline-legend" aria-hidden="true">
{legendItems.map((s) => (
<span key={s.name} className="extrema-item" style={{ color: s.color }}>
● {s.name}
</span>
))}
</div>
)}
<div ref={containerRef} className="sparkline-plot" style={{ height }} />
</div>
);
}
+1
View File
@@ -0,0 +1 @@
export { default as Sparkline } from './Sparkline';
+37
View File
@@ -0,0 +1,37 @@
import { createRoot } from 'react-dom/client';
import { message } from 'antd';
import 'antd/dist/reset.css';
import '@/styles/global.css';
import { setupHttp } from '@/api/http-init';
import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme';
import { QueryProvider } from '@/api/QueryProvider';
import LoginPage from '@/pages/login/LoginPage';
import { enableMockApi } from '@/mocks';
async function bootstrap() {
await enableMockApi();
setupHttp();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
await readyI18n();
const root = document.getElementById('app');
if (root) {
createRoot(root).render(
<ThemeProvider>
<QueryProvider>
<LoginPage />
</QueryProvider>
</ThemeProvider>,
);
}
}
void bootstrap();
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="vite/client" />
interface Window {
ADMIN_UI_BASE_PATH?: string;
}
+22
View File
@@ -0,0 +1,22 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router';
const STATIC_TITLES: Record<string, string> = {
'/': '总览',
'/accounts': '账号管理',
'/servers': '区服管理',
'/players': '玩家管理',
'/cdks': 'CDK管理',
'/announcements': '公告管理',
'/orders': '订单管理',
};
export function usePageTitle() {
const { pathname } = useLocation();
useEffect(() => {
const title = STATIC_TITLES[pathname] || '游戏运营后台';
const host = window.location.hostname;
document.title = host ? `${host} - ${title}` : title;
}, [pathname]);
}
+188
View File
@@ -0,0 +1,188 @@
import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { theme as antdTheme } from 'antd';
import type { ThemeConfig } from 'antd';
const STORAGE_DARK = 'dark-mode';
const STORAGE_ULTRA = 'isUltraDarkThemeEnabled';
function readBool(key: string, fallback: boolean): boolean {
const raw = localStorage.getItem(key);
if (raw === null) return fallback;
return raw === 'true';
}
function applyDom(isDark: boolean, isUltra: boolean) {
document.body.classList.remove('dark', 'light');
document.body.classList.add(isDark ? 'dark' : 'light');
if (isUltra) {
document.documentElement.setAttribute('data-theme', 'ultra-dark');
} else {
document.documentElement.removeAttribute('data-theme');
}
const msg = document.getElementById('message');
if (msg) {
msg.classList.remove('dark', 'light');
msg.classList.add(isDark ? 'dark' : 'light');
}
}
// module load so the document is in the right theme before React mounts.
const initialDark = readBool(STORAGE_DARK, true);
const initialUltra = readBool(STORAGE_ULTRA, false);
applyDom(initialDark, initialUltra);
const DARK_TOKENS = {
colorBgBase: '#1a1b1f',
colorBgLayout: '#1a1b1f',
colorBgContainer: '#23252b',
colorBgElevated: '#2d2f37',
};
const ULTRA_DARK_TOKENS = {
colorBgBase: '#000',
colorBgLayout: '#000',
colorBgContainer: '#101013',
colorBgElevated: '#1a1a1e',
};
const DARK_LAYOUT_TOKENS = {
bodyBg: '#1a1b1f',
headerBg: '#15161a',
headerColor: '#ffffff',
footerBg: '#1a1b1f',
siderBg: '#15161a',
triggerBg: '#23252b',
triggerColor: '#ffffff',
};
const ULTRA_DARK_LAYOUT_TOKENS = {
bodyBg: '#000',
headerBg: '#050507',
headerColor: '#ffffff',
footerBg: '#000',
siderBg: '#050507',
triggerBg: '#1a1a1e',
triggerColor: '#ffffff',
};
const DARK_MENU_TOKENS = {
darkItemBg: '#15161a',
darkSubMenuItemBg: '#1a1b1f',
darkPopupBg: '#23252b',
};
const ULTRA_DARK_MENU_TOKENS = {
darkItemBg: '#050507',
darkSubMenuItemBg: '#000',
darkPopupBg: '#101013',
};
const DARK_CARD_TOKENS = {
colorBorderSecondary: 'rgba(255, 255, 255, 0.06)',
};
const ULTRA_DARK_CARD_TOKENS = {
colorBorderSecondary: 'rgba(255, 255, 255, 0.04)',
};
const STATISTIC_TOKENS = {
contentFontSize: 17,
titleFontSize: 11,
};
const LIGHT_CONTRAST_TOKENS = {
colorTextDescription: 'rgba(0, 0, 0, 0.58)',
colorTextTertiary: 'rgba(0, 0, 0, 0.58)',
colorTextPlaceholder: '#767676',
colorError: '#cf1322',
colorErrorText: '#cf1322',
colorSuccessText: '#237804',
};
const LIGHT_BUTTON_TOKENS = {
colorPrimary: '#0958d9',
colorPrimaryHover: '#2468e5',
colorPrimaryActive: '#073ea8',
};
// hashed:false drops the `:where(.css-<hash>)` wrapper antd puts around every
// rule. It costs nothing in specificity — `:where()` contributes zero, so the
// panel's own `.ant-*` overrides still win — and it removes roughly 5,700
// wrappers, 16% of the generated stylesheet, from what the browser has to parse.
//
// cssVar.key pins the CSS-variable scope. Every panel page mounts its own
// ConfigProvider (there is no root one), and without a fixed key each mints a
// fresh useId-derived scope, so navigating re-serialises and re-injects the whole
// token block under a new class instead of reusing the one already in the head.
const SHARED_STYLE_CONFIG = {
hashed: false,
cssVar: { key: 'game-admin' },
} as const;
export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeConfig {
if (!isDark) {
return {
...SHARED_STYLE_CONFIG,
algorithm: antdTheme.defaultAlgorithm,
token: LIGHT_CONTRAST_TOKENS,
components: {
Statistic: STATISTIC_TOKENS,
Button: LIGHT_BUTTON_TOKENS,
},
};
}
return {
...SHARED_STYLE_CONFIG,
algorithm: antdTheme.darkAlgorithm,
token: isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS,
components: {
Layout: isUltra ? ULTRA_DARK_LAYOUT_TOKENS : DARK_LAYOUT_TOKENS,
Menu: isUltra ? ULTRA_DARK_MENU_TOKENS : DARK_MENU_TOKENS,
Card: isUltra ? ULTRA_DARK_CARD_TOKENS : DARK_CARD_TOKENS,
Statistic: STATISTIC_TOKENS,
},
};
}
export function pauseAnimationsUntilLeave(elementId: string): void {
document.documentElement.setAttribute('data-theme-animations', 'off');
const el = document.getElementById(elementId);
if (!el) return;
const restore = () => {
document.documentElement.removeAttribute('data-theme-animations');
el.removeEventListener('mouseleave', restore);
el.removeEventListener('touchend', restore);
};
el.addEventListener('mouseleave', restore);
el.addEventListener('touchend', restore);
}
interface ThemeContextValue {
isDark: boolean;
isUltra: boolean;
toggleTheme: () => void;
toggleUltra: () => void;
antdThemeConfig: ThemeConfig;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [isDark, setIsDark] = useState<boolean>(initialDark);
const [isUltra, setIsUltra] = useState<boolean>(initialUltra);
useLayoutEffect(() => {
applyDom(isDark, isUltra);
localStorage.setItem(STORAGE_DARK, String(isDark));
localStorage.setItem(STORAGE_ULTRA, String(isUltra));
}, [isDark, isUltra]);
const toggleTheme = useCallback(() => setIsDark((v) => !v), []);
const toggleUltra = useCallback(() => setIsUltra((v) => !v), []);
const antdThemeConfig = useMemo(() => buildAntdThemeConfig(isDark, isUltra), [isDark, isUltra]);
const value = useMemo<ThemeContextValue>(
() => ({ isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig }),
[isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used inside <ThemeProvider>');
return ctx;
}
+18
View File
@@ -0,0 +1,18 @@
{
"username": "اسم المستخدم",
"password": "الباسورد",
"twoFactorCode": "الكود",
"login": "تسجيل الدخول",
"menu": {
"theme": "الثيم"
},
"pages": {
"login": {
"hello": "أهلا",
"title": "أهلاً وسهلاً"
},
"settings": {
"language": "اللغة"
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"username": "Username",
"password": "Password",
"twoFactorCode": "Code",
"login": "Log In",
"menu": {
"theme": "Theme"
},
"pages": {
"login": {
"hello": "Hello",
"title": "Welcome"
},
"settings": {
"language": "Language"
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"username": "Nombre de Usuario",
"password": "Contraseña",
"twoFactorCode": "Código",
"login": "Acceder",
"menu": {
"theme": "Tema"
},
"pages": {
"login": {
"hello": "Hola",
"title": "Bienvenido"
},
"settings": {
"language": "Idioma"
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"username": "نام‌کاربری",
"password": "رمزعبور",
"twoFactorCode": "کد",
"login": "ورود",
"menu": {
"theme": "تم"
},
"pages": {
"login": {
"hello": "سلام",
"title": "خوش‌آمدید"
},
"settings": {
"language": "زبان"
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"username": "Nama Pengguna",
"password": "Kata Sandi",
"twoFactorCode": "Kode",
"login": "Masuk",
"menu": {
"theme": "Tema"
},
"pages": {
"login": {
"hello": "Halo",
"title": "Selamat Datang"
},
"settings": {
"language": "Bahasa"
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"username": "ユーザー名",
"password": "パスワード",
"twoFactorCode": "コード",
"login": "ログイン",
"menu": {
"theme": "テーマ"
},
"pages": {
"login": {
"hello": "こんにちは",
"title": "ようこそ"
},
"settings": {
"language": "言語"
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"username": "Nome de Usuário",
"password": "Senha",
"twoFactorCode": "Código",
"login": "Entrar",
"menu": {
"theme": "Tema"
},
"pages": {
"login": {
"hello": "Olá",
"title": "Bem-vindo"
},
"settings": {
"language": "Idioma"
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"username": "Имя пользователя",
"password": "Пароль",
"twoFactorCode": "Код 2FA",
"login": "Войти",
"menu": {
"theme": "Тема"
},
"pages": {
"login": {
"hello": "Привет!",
"title": "Добро пожаловать!"
},
"settings": {
"language": "Язык интерфейса"
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"username": "Kullanıcı Adı",
"password": "Şifre",
"twoFactorCode": "Kod",
"login": "Giriş Yap",
"menu": {
"theme": "Tema"
},
"pages": {
"login": {
"hello": "Merhaba",
"title": "Hoş Geldiniz"
},
"settings": {
"language": "Dil"
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"username": "Ім'я користувача",
"password": "Пароль",
"twoFactorCode": "Код",
"login": "Увійти",
"menu": {
"theme": "Тема"
},
"pages": {
"login": {
"hello": "Привіт",
"title": "Привітання!"
},
"settings": {
"language": "Мова"
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"username": "Tên người dùng",
"password": "Mật khẩu",
"twoFactorCode": "Mã",
"login": "Đăng nhập",
"menu": {
"theme": "Chủ đề"
},
"pages": {
"login": {
"hello": "Xin chào",
"title": "Chào mừng"
},
"settings": {
"language": "Ngôn ngữ"
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"username": "用户名",
"password": "密码",
"twoFactorCode": "代码",
"login": "登录",
"menu": {
"theme": "主题"
},
"pages": {
"login": {
"hello": "你好",
"title": "欢迎"
},
"settings": {
"language": "语言"
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"username": "使用者名稱",
"password": "密碼",
"twoFactorCode": "代碼",
"login": "登入",
"menu": {
"theme": "主題"
},
"pages": {
"login": {
"hello": "你好",
"title": "歡迎"
},
"settings": {
"language": "語言"
}
}
}
+45
View File
@@ -0,0 +1,45 @@
import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
import { LanguageManager } from '@/utils';
import enUS from './locales/en-US.json';
const FALLBACK = 'en-US';
const lazyModules = import.meta.glob(['./locales/*.json', '!./locales/en-US.json']);
function moduleKeyFor(code: string): string {
return `./locales/${code}.json`;
}
let active: string = LanguageManager.getLanguage();
if (
active !== FALLBACK &&
!Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))
) {
active = FALLBACK;
}
export async function readyI18n() {
await i18next.use(initReactI18next).init({
lng: active,
fallbackLng: FALLBACK,
resources: { [FALLBACK]: { translation: enUS } },
interpolation: { escapeValue: false, prefix: '{', suffix: '}' },
returnNull: false,
});
if (active !== FALLBACK) {
const loader = lazyModules[moduleKeyFor(active)] as
| (() => Promise<{ default: Record<string, unknown> }>)
| undefined;
if (loader) {
const mod = await loader();
const messages = (mod.default ?? mod) as Record<string, unknown>;
i18next.addResourceBundle(active, 'translation', messages, true, true);
await i18next.changeLanguage(active);
}
}
return i18next;
}
export { i18next as i18n };
+170
View File
@@ -0,0 +1,170 @@
.ant-sidebar {
flex: 0 0 220px;
width: 220px;
position: relative;
z-index: 210;
}
.ant-sidebar > .ant-layout-sider {
position: sticky;
top: 0;
height: 100vh;
align-self: flex-start;
}
.sider-nav .ant-menu-item .anticon,
.sider-nav .ant-menu-submenu-title .anticon {
font-size: 16px;
}
.sider-brand,
.drawer-brand {
font-weight: 600;
font-size: 18px;
letter-spacing: 0.5px;
color: var(--ant-color-text);
}
.sider-brand {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
height: 42px;
padding: 0 16px 0 24px;
border-bottom: 1px solid var(--ant-color-border-secondary);
user-select: none;
white-space: nowrap;
overflow: hidden;
}
.brand-block {
display: inline-flex;
align-items: center;
min-width: 0;
line-height: 1.1;
}
.drawer-handle {
position: fixed;
top: 12px;
left: 12px;
z-index: 1100;
background: rgba(0, 0, 0, 0.55);
color: #fff;
border: none;
width: 40px;
height: 40px;
border-radius: 50%;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
font-size: 18px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
}
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--ant-color-border-secondary);
}
.drawer-close {
background: transparent;
border: none;
width: 32px;
height: 32px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 16px;
color: var(--ant-color-text-secondary);
}
.drawer-close:hover,
.drawer-close:focus-visible {
background: var(--ant-color-fill-tertiary);
}
.drawer-menu .ant-menu-item {
height: 48px;
line-height: 48px;
margin: 0;
border-radius: 0;
}
.drawer-menu .ant-menu-item .anticon {
font-size: 16px;
}
.ant-sidebar > .ant-layout-sider .ant-layout-sider-children {
display: flex;
flex-direction: column;
height: 100%;
}
.sider-nav {
flex: 1 1 auto;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
}
@media (max-width: 768px) {
.drawer-handle {
display: inline-flex;
}
.ant-sidebar > .ant-layout-sider .ant-layout-sider-children {
display: none;
}
.ant-sidebar > .ant-layout-sider {
flex: 0 0 0 !important;
max-width: 0 !important;
min-width: 0 !important;
width: 0 !important;
}
.ant-sidebar {
flex: 0 0 0;
width: 0;
}
}
body.dark .ant-drawer-content,
body.dark .ant-drawer-body {
background-color: #15161a;
}
html[data-theme='ultra-dark'] body.dark .ant-drawer-content,
html[data-theme='ultra-dark'] body.dark .ant-drawer-body {
background-color: #050507;
}
body.dark .ant-drawer-body .drawer-menu,
body.dark .ant-drawer-body .drawer-menu.ant-menu-dark,
body.dark .ant-drawer-body .drawer-menu .ant-menu-item,
body.dark .ant-drawer-body .drawer-menu .ant-menu-sub,
body.dark .ant-drawer-body .drawer-menu .ant-menu-item-group-list {
background-color: transparent;
}
.sider-nav .ant-menu-item-selected,
.drawer-menu .ant-menu-item-selected {
background-color: color-mix(in srgb, var(--ant-color-primary) 20%, transparent) !important;
color: var(--ant-color-primary) !important;
}
.sider-nav .ant-menu-item-active:not(.ant-menu-item-selected),
.drawer-menu .ant-menu-item-active:not(.ant-menu-item-selected),
.sider-nav .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
.drawer-menu .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover {
background-color: color-mix(in srgb, var(--ant-color-primary) 10%, transparent) !important;
color: var(--ant-color-primary) !important;
}
+148
View File
@@ -0,0 +1,148 @@
import { useCallback, useState } from 'react';
import type { ComponentType } from 'react';
import { useLocation, useNavigate } from 'react-router';
import { Drawer, Layout, Menu } from 'antd';
import type { MenuProps } from 'antd';
import {
BellOutlined,
CloseOutlined,
CloudServerOutlined,
DashboardOutlined,
IdcardOutlined,
KeyOutlined,
MenuOutlined,
ShoppingCartOutlined,
UserOutlined,
} from '@ant-design/icons';
import { useTheme } from '@/hooks/useTheme';
import './AppSidebar.css';
const SIDER_WIDTH = 220;
const SIDEBAR_BRAND = '游戏运营后台';
type IconName =
| 'dashboard'
| 'accounts'
| 'servers'
| 'players'
| 'cdks'
| 'announcements'
| 'orders';
interface SidebarTab {
key: string;
icon: IconName;
title: string;
}
const iconByName: Record<IconName, ComponentType> = {
dashboard: DashboardOutlined,
accounts: IdcardOutlined,
servers: CloudServerOutlined,
players: UserOutlined,
cdks: KeyOutlined,
announcements: BellOutlined,
orders: ShoppingCartOutlined,
};
const tabs: SidebarTab[] = [
{ key: '/', icon: 'dashboard', title: '总览' },
{ key: '/accounts', icon: 'accounts', title: '账号管理' },
{ key: '/servers', icon: 'servers', title: '区服管理' },
{ key: '/players', icon: 'players', title: '玩家管理' },
{ key: '/cdks', icon: 'cdks', title: 'CDK管理' },
{ key: '/announcements', icon: 'announcements', title: '公告管理' },
{ key: '/orders', icon: 'orders', title: '订单管理' },
];
const menuItems: MenuProps['items'] = tabs.map((tab) => {
const Icon = iconByName[tab.icon];
return { key: tab.key, icon: <Icon />, label: tab.title, title: '' };
});
export default function AppSidebar() {
const { isDark } = useTheme();
const navigate = useNavigate();
const { pathname } = useLocation();
const [drawerOpen, setDrawerOpen] = useState(false);
const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
const selectedKey = pathname || '/';
const openLink = useCallback((key: string) => navigate(key), [navigate]);
const onMenuClick = useCallback<NonNullable<MenuProps['onClick']>>(
({ key }) => openLink(String(key)),
[openLink],
);
return (
<div className="ant-sidebar">
<Layout.Sider theme={currentTheme} width={SIDER_WIDTH}>
<div className="sider-brand">
<div className="brand-block">
<span className="brand-text">{SIDEBAR_BRAND}</span>
</div>
</div>
<Menu
theme={currentTheme}
mode="inline"
selectedKeys={[selectedKey]}
className="sider-nav"
items={menuItems}
onClick={onMenuClick}
/>
</Layout.Sider>
<Drawer
placement="left"
closable={false}
open={drawerOpen}
rootClassName={currentTheme}
size="min(82vw, 320px)"
styles={{
wrapper: { padding: 0 },
body: { padding: 0, display: 'flex', flexDirection: 'column', height: '100%' },
header: { display: 'none' },
}}
onClose={() => setDrawerOpen(false)}
>
<div className="drawer-header">
<div className="brand-block">
<span className="drawer-brand">{SIDEBAR_BRAND}</span>
</div>
<button
className="drawer-close"
type="button"
aria-label="关闭菜单"
onClick={() => setDrawerOpen(false)}
>
<CloseOutlined />
</button>
</div>
<Menu
theme={currentTheme}
mode="inline"
selectedKeys={[selectedKey]}
className="drawer-menu drawer-nav"
items={menuItems}
onClick={(info) => {
onMenuClick(info);
setDrawerOpen(false);
}}
/>
</Drawer>
{!drawerOpen && (
<button
className="drawer-handle"
type="button"
aria-label="打开菜单"
onClick={() => setDrawerOpen(true)}
>
<MenuOutlined />
</button>
)}
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
import { Outlet } from 'react-router';
import { usePageTitle } from '@/hooks/usePageTitle';
export default function PanelLayout() {
usePageTitle();
return <Outlet />;
}
+215
View File
@@ -0,0 +1,215 @@
.workspace-tabs {
position: sticky;
top: 0;
z-index: 30;
display: flex;
flex: 0 0 42px;
align-items: flex-end;
min-width: 0;
height: 42px;
padding: 0 12px;
border-bottom: 1px solid var(--ant-color-border-secondary);
background: var(--ant-color-bg-container);
box-shadow: 0 1px 3px rgb(0 0 0 / 5%);
}
.workspace-tabs__page-title {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.workspace-tabs__desktop {
display: flex;
align-items: flex-end;
width: 100%;
min-width: 0;
height: 100%;
}
.workspace-tabs__track {
display: flex;
flex: 1 1 auto;
align-items: flex-end;
min-width: 0;
height: 100%;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
.workspace-tabs__track::-webkit-scrollbar {
display: none;
}
.workspace-tab {
position: relative;
display: flex;
flex: 0 0 auto;
align-items: center;
height: 36px;
margin-top: 6px;
border-radius: 4px 4px 0 0;
color: var(--ant-color-text-secondary);
transition:
color 160ms ease,
background-color 160ms ease;
}
.workspace-tab:hover {
color: var(--ant-color-text);
background: var(--ant-color-fill-tertiary);
}
.workspace-tab.is-active {
color: var(--ant-color-primary);
background: color-mix(in srgb, var(--ant-color-primary) 10%, transparent);
}
.workspace-tab.is-active::after {
position: absolute;
right: 8px;
bottom: 0;
left: 8px;
height: 2px;
border-radius: 2px 2px 0 0;
background: var(--ant-color-primary);
content: '';
}
.workspace-tab__main,
.workspace-tab__close,
.workspace-tabs__more,
.workspace-tabs__theme,
.workspace-tabs__logout,
.workspace-tabs__mobile-trigger {
border: 0;
color: inherit;
background: transparent;
cursor: pointer;
}
.workspace-tab__main {
display: inline-flex;
align-items: center;
gap: 7px;
height: 100%;
padding: 0 10px 0 12px;
font-size: 13px;
white-space: nowrap;
}
.workspace-tab__label {
max-width: 132px;
overflow: hidden;
text-overflow: ellipsis;
}
.workspace-tab__close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
margin-right: 5px;
border-radius: 4px;
font-size: 11px;
opacity: 0.62;
}
.workspace-tab__close:hover {
color: var(--ant-color-error);
background: var(--ant-color-fill-secondary);
opacity: 1;
}
.workspace-tab__main:focus-visible,
.workspace-tab__close:focus-visible,
.workspace-tabs__more:focus-visible,
.workspace-tabs__theme:focus-visible,
.workspace-tabs__logout:focus-visible,
.workspace-tabs__mobile-trigger:focus-visible {
outline: 2px solid var(--ant-color-primary-border);
outline-offset: -2px;
}
.workspace-tabs__more,
.workspace-tabs__theme,
.workspace-tabs__logout {
display: inline-flex;
flex: 0 0 32px;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin: 0 0 4px 8px;
border-radius: 4px;
color: var(--ant-color-text-secondary);
font-size: 16px;
}
.workspace-tabs__more:hover,
.workspace-tabs__theme:hover,
.workspace-tabs__logout:hover {
color: var(--ant-color-text);
background: var(--ant-color-fill-tertiary);
}
.workspace-tabs__theme,
.workspace-tabs__logout {
margin-left: 4px;
}
.workspace-tabs__logout:hover {
color: var(--ant-color-error);
}
.workspace-tabs__mobile {
display: none;
width: 100%;
}
@media (max-width: 768px) {
.workspace-tabs {
flex-basis: 64px;
align-items: center;
height: 64px;
padding: 8px 8px 8px 60px;
}
.workspace-tabs__desktop {
display: none;
}
.workspace-tabs__mobile {
display: block;
}
.workspace-tabs__mobile-trigger {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 40px;
padding: 0 12px;
border: 1px solid var(--ant-color-border-secondary);
border-radius: 6px;
background: var(--ant-color-bg-container);
color: var(--ant-color-text);
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
}
.workspace-tabs__mobile-current {
display: inline-flex;
align-items: center;
min-width: 0;
gap: 8px;
font-weight: 600;
}
}
+329
View File
@@ -0,0 +1,329 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ComponentType } from 'react';
import { Dropdown, Tooltip } from 'antd';
import type { MenuProps } from 'antd';
import {
BellOutlined,
ClearOutlined,
CloseOutlined,
CloudServerOutlined,
DashboardOutlined,
DownOutlined,
IdcardOutlined,
KeyOutlined,
LogoutOutlined,
MoreOutlined,
MoonFilled,
MoonOutlined,
ShoppingCartOutlined,
SunOutlined,
UserOutlined,
} from '@ant-design/icons';
import { useLocation, useNavigate } from 'react-router';
import { HttpUtil } from '@/utils';
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import './WorkspaceTabs.css';
const OPEN_TABS_KEY = 'game-admin-open-tabs';
const HOME_PATH = '/';
interface WorkspaceTabDefinition {
path: string;
title: string;
icon: ComponentType;
pinned?: boolean;
}
function ThemeModeIcon({ isDark, isUltra }: { isDark: boolean; isUltra: boolean }) {
if (!isDark) return <SunOutlined />;
return isUltra ? <MoonFilled /> : <MoonOutlined />;
}
const workspaceTabDefinitions: WorkspaceTabDefinition[] = [
{ path: HOME_PATH, title: '总览', icon: DashboardOutlined, pinned: true },
{ path: '/accounts', title: '账号管理', icon: IdcardOutlined },
{ path: '/servers', title: '区服管理', icon: CloudServerOutlined },
{ path: '/players', title: '玩家管理', icon: UserOutlined },
{ path: '/cdks', title: 'CDK管理', icon: KeyOutlined },
{ path: '/announcements', title: '公告管理', icon: BellOutlined },
{ path: '/orders', title: '订单管理', icon: ShoppingCartOutlined },
];
const definitionByPath = new Map(workspaceTabDefinitions.map((tab) => [tab.path, tab]));
function normalizePath(pathname: string) {
const trimmed = pathname.replace(/\/+$/, '');
return trimmed || HOME_PATH;
}
function saveOpenedPaths(paths: string[]) {
try {
sessionStorage.setItem(OPEN_TABS_KEY, JSON.stringify(paths));
} catch {}
}
function readOpenedPaths(activePath: string) {
let storedPaths: string[] = [];
try {
const stored = sessionStorage.getItem(OPEN_TABS_KEY);
const parsed: unknown = stored ? JSON.parse(stored) : [];
if (Array.isArray(parsed)) {
storedPaths = parsed.filter(
(path): path is string => typeof path === 'string' && definitionByPath.has(path),
);
}
} catch {}
const openedPaths = Array.from(new Set(storedPaths));
if (!openedPaths.includes(HOME_PATH)) openedPaths.unshift(HOME_PATH);
if (definitionByPath.has(activePath) && !openedPaths.includes(activePath)) {
openedPaths.push(activePath);
}
return openedPaths;
}
export default function WorkspaceTabs() {
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
const navigate = useNavigate();
const { pathname } = useLocation();
const activePath = normalizePath(pathname);
const [openedPaths, setOpenedPaths] = useState(() => readOpenedPaths(activePath));
const openedTabs = useMemo(
() =>
openedPaths
.map((path) => definitionByPath.get(path))
.filter((tab): tab is WorkspaceTabDefinition => tab !== undefined),
[openedPaths],
);
const activeTab = definitionByPath.get(activePath) ?? definitionByPath.get(HOME_PATH)!;
const commitOpenedPaths = useCallback((paths: string[]) => {
saveOpenedPaths(paths);
setOpenedPaths(paths);
}, []);
useEffect(() => {
saveOpenedPaths(openedPaths);
}, [openedPaths]);
const closeTab = useCallback(
(path: string) => {
const target = definitionByPath.get(path);
if (!target || target.pinned) return;
const targetIndex = openedPaths.indexOf(path);
const next = openedPaths.filter((openedPath) => openedPath !== path);
commitOpenedPaths(next);
if (path === activePath) {
const fallbackIndex = Math.max(0, Math.min(targetIndex - 1, next.length - 1));
navigate(next[fallbackIndex] ?? HOME_PATH);
}
},
[activePath, commitOpenedPaths, navigate, openedPaths],
);
const closeOtherTabs = useCallback(() => {
const next = activePath === HOME_PATH ? [HOME_PATH] : [HOME_PATH, activePath];
commitOpenedPaths(next);
}, [activePath, commitOpenedPaths]);
const closeAllTabs = useCallback(() => {
commitOpenedPaths([HOME_PATH]);
if (activePath !== HOME_PATH) navigate(HOME_PATH);
}, [activePath, commitOpenedPaths, navigate]);
const cycleTheme = useCallback(
(id?: string) => {
if (id) pauseAnimationsUntilLeave(id);
if (!isDark) {
toggleTheme();
if (isUltra) toggleUltra();
} else if (!isUltra) {
toggleUltra();
} else {
toggleUltra();
toggleTheme();
}
},
[isDark, isUltra, toggleTheme, toggleUltra],
);
const logout = useCallback(async () => {
try {
sessionStorage.removeItem(OPEN_TABS_KEY);
} catch {}
await HttpUtil.post('/logout');
window.location.href = window.ADMIN_UI_BASE_PATH || '/';
}, []);
const actionItems = useMemo<NonNullable<MenuProps['items']>>(
() => [
{
key: 'close-current',
icon: <CloseOutlined />,
label: '关闭当前',
disabled: activeTab.pinned,
},
{
key: 'close-others',
icon: <ClearOutlined />,
label: '关闭其他',
disabled: openedPaths.every((path) => path === HOME_PATH || path === activePath),
},
{
key: 'close-all',
icon: <ClearOutlined />,
label: '关闭全部',
disabled: openedPaths.length === 1,
},
],
[activePath, activeTab.pinned, openedPaths],
);
const handleAction = useCallback<NonNullable<MenuProps['onClick']>>(
({ key }) => {
if (key === 'close-current') closeTab(activePath);
if (key === 'close-others') closeOtherTabs();
if (key === 'close-all') closeAllTabs();
},
[activePath, closeAllTabs, closeOtherTabs, closeTab],
);
const mobileItems = useMemo<NonNullable<MenuProps['items']>>(
() => [
...openedTabs.map((tab) => {
const Icon = tab.icon;
return {
key: `open:${tab.path}`,
icon: <Icon />,
label: tab.title,
};
}),
{ type: 'divider' as const },
...actionItems,
{ type: 'divider' as const },
{
key: 'theme',
icon: <ThemeModeIcon isDark={isDark} isUltra={isUltra} />,
label: '切换主题',
},
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
danger: true,
},
],
[actionItems, isDark, isUltra, openedTabs],
);
const handleMobileMenu = useCallback<NonNullable<MenuProps['onClick']>>(
(info) => {
if (info.key === 'logout') {
void logout();
return;
}
if (info.key === 'theme') {
cycleTheme();
return;
}
if (info.key.startsWith('open:')) {
navigate(info.key.slice(5));
return;
}
handleAction(info);
},
[cycleTheme, handleAction, logout, navigate],
);
const ActiveIcon = activeTab.icon;
return (
<div className="workspace-tabs">
<h1 className="workspace-tabs__page-title">{activeTab.title}</h1>
<div className="workspace-tabs__desktop">
<div className="workspace-tabs__track" role="tablist" aria-label="已打开页面">
{openedTabs.map((tab) => {
const Icon = tab.icon;
const active = tab.path === activePath;
return (
<div key={tab.path} className={`workspace-tab${active ? ' is-active' : ''}`}>
<button
type="button"
className="workspace-tab__main"
role="tab"
aria-label={tab.title}
aria-selected={active}
title={tab.title}
onClick={() => navigate(tab.path)}
>
<Icon />
<span className="workspace-tab__label">{tab.title}</span>
</button>
{!tab.pinned && (
<button
type="button"
className="workspace-tab__close"
aria-label={`关闭 ${tab.title}`}
title={`关闭 ${tab.title}`}
onClick={() => closeTab(tab.path)}
>
<CloseOutlined />
</button>
)}
</div>
);
})}
</div>
<Dropdown menu={{ items: actionItems, onClick: handleAction }} trigger={['click']}>
<Tooltip title="标签操作">
<button type="button" className="workspace-tabs__more" aria-label="标签操作">
<MoreOutlined />
</button>
</Tooltip>
</Dropdown>
<Tooltip title="主题">
<button
id="workspace-theme-cycle"
type="button"
className="workspace-tabs__theme"
aria-label="主题"
onClick={() => cycleTheme('workspace-theme-cycle')}
>
<ThemeModeIcon isDark={isDark} isUltra={isUltra} />
</button>
</Tooltip>
<Tooltip title="退出登录">
<button
type="button"
className="workspace-tabs__logout"
aria-label="退出登录"
onClick={() => void logout()}
>
<LogoutOutlined />
</button>
</Tooltip>
</div>
<div className="workspace-tabs__mobile">
<Dropdown menu={{ items: mobileItems, onClick: handleMobileMenu }} trigger={['click']}>
<button type="button" className="workspace-tabs__mobile-trigger" aria-label="已打开页面">
<span className="workspace-tabs__mobile-current">
<ActiveIcon />
<span>{activeTab.title}</span>
</span>
<DownOutlined />
</button>
</Dropdown>
</div>
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router/dom';
import { message } from 'antd';
import 'antd/dist/reset.css';
import '@/styles/global.css';
import { setupHttp } from '@/api/http-init';
import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme';
import { QueryProvider } from '@/api/QueryProvider';
import { router } from '@/routes';
import { enableMockApi } from '@/mocks';
async function bootstrap() {
await enableMockApi();
setupHttp();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
await readyI18n();
const root = document.getElementById('app');
if (root) {
createRoot(root).render(
<ThemeProvider>
<QueryProvider>
<RouterProvider router={router} />
</QueryProvider>
</ThemeProvider>,
);
}
}
void bootstrap();
+5
View File
@@ -0,0 +1,5 @@
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';
export const worker = setupWorker(...handlers);
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
export async function enableMockApi(): Promise<void> {
if (!import.meta.env.DEV || import.meta.env.VITE_MOCK_API !== 'true') return;
const { worker } = await import('./browser');
await worker.start({
onUnhandledRequest: 'bypass',
quiet: true,
});
}
+447
View File
@@ -0,0 +1,447 @@
.game-accounts-page {
--accounts-page-bg: #e6e8ec;
--accounts-card-bg: #ffffff;
--accounts-border: var(--ant-color-border-secondary);
height: 100vh;
min-height: 0;
overflow: hidden;
background: var(--accounts-page-bg);
}
.game-accounts-page.is-dark {
--accounts-page-bg: #1a1b1f;
--accounts-card-bg: #23252b;
}
.game-accounts-page.is-dark.is-ultra {
--accounts-page-bg: #000000;
--accounts-card-bg: #101013;
}
.game-accounts-page .ant-layout,
.game-accounts-page .ant-layout-content,
.accounts-content-shell {
background: transparent;
}
.accounts-content {
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow: hidden;
padding: 20px;
}
.accounts-content-shell {
height: 100%;
min-height: 0;
overflow: hidden;
}
.accounts-content > .ant-spin,
.accounts-content > .ant-spin > .ant-spin-container {
width: 100%;
min-height: 0;
}
.accounts-content > .ant-spin {
display: flex;
flex: 1 1 auto;
}
.accounts-content > .ant-spin > .ant-spin-container {
height: 100%;
}
.accounts-loading-spacer {
height: 100%;
min-height: 0;
}
.accounts-shell {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
height: 100%;
min-height: 0;
}
.accounts-summary-card,
.accounts-filter-card {
flex: 0 0 auto;
}
.accounts-summary-card,
.accounts-filter-card,
.accounts-table-card {
overflow: hidden;
border-radius: 8px;
background: var(--accounts-card-bg);
}
.accounts-table-card {
display: flex;
flex: 1 1 0;
min-height: 0;
}
.accounts-summary-card .ant-card-body {
padding: 0;
}
.accounts-metric-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
background: var(--accounts-card-bg);
}
.accounts-metric {
min-width: 0;
padding: 16px 20px;
background: var(--accounts-card-bg);
}
.accounts-metric-heading {
overflow: hidden;
color: var(--ant-color-text-secondary);
font-size: 12px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.accounts-metric-value-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
margin-top: 8px;
}
.accounts-metric-icon {
display: inline-flex;
flex: 0 0 auto;
color: var(--metric-accent);
font-size: 16px;
}
.accounts-metric-value {
min-width: 0;
overflow-wrap: anywhere;
color: var(--ant-color-text);
font-size: 22px;
font-weight: 500;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.accounts-filter-card .ant-card-body,
.accounts-table-card .ant-card-body {
min-width: 0;
padding: 0;
}
.accounts-table-card .ant-card-body {
display: flex;
flex: 1 1 auto;
min-height: 0;
}
.accounts-table-viewport {
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
.accounts-filter-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
}
.accounts-filter-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
min-width: 0;
margin-inline-start: auto;
}
.accounts-search {
display: flex;
width: min(420px, 42vw);
}
.accounts-search .ant-input-affix-wrapper {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
.accounts-search .ant-btn {
border-start-start-radius: 0;
border-end-start-radius: 0;
}
.accounts-filter-select {
width: 132px;
}
.accounts-filter-refresh {
flex: 0 0 auto;
}
.accounts-table-card .ant-table-wrapper {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.accounts-table-card .ant-table-wrapper > .ant-spin,
.accounts-table-card .ant-table-wrapper > .ant-spin > .ant-spin-container,
.accounts-table-card .ant-table {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
}
.accounts-table-card .ant-table-container {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
}
.accounts-table-card .ant-table-header {
flex: 0 0 auto;
}
.accounts-table-card .ant-table-body {
flex: 1 1 auto;
min-height: 0;
max-height: none !important;
overflow-y: auto !important;
}
.accounts-table-card .ant-table-thead .ant-table-cell {
text-align: left !important;
vertical-align: middle;
}
.accounts-table-card .ant-table-tbody > tr > .ant-table-cell {
text-align: left;
vertical-align: middle;
}
.accounts-table-card .ant-table-tbody > tr > .accounts-cell-start {
text-align: left;
}
.accounts-table-card .ant-pagination {
flex: 0 0 auto;
margin-block: 10px;
padding-inline: 16px;
}
.accounts-identity,
.accounts-stack {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.accounts-identity .ant-typography,
.accounts-stack .ant-typography {
overflow: hidden;
margin: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.accounts-stack .ant-typography-secondary {
font-size: 12px;
}
.accounts-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.accounts-numeric,
.accounts-time {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.accounts-row-actions {
min-width: 0;
justify-content: flex-start;
}
.accounts-row-actions .ant-btn {
width: 32px;
height: 32px;
}
.accounts-detail {
display: flex;
flex-direction: column;
gap: 18px;
}
.accounts-detail-heading {
display: grid;
grid-template-columns: 42px minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
}
.accounts-detail-heading .ant-typography {
margin: 0;
}
.accounts-detail-heading h4.ant-typography {
overflow-wrap: anywhere;
font-size: 17px;
}
.accounts-detail-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 6px;
background: var(--ant-color-primary-bg);
color: var(--ant-color-primary);
font-size: 20px;
}
.accounts-detail .ant-descriptions-item-label {
width: 116px;
white-space: nowrap;
}
.accounts-detail .ant-descriptions-item-content {
overflow-wrap: anywhere;
}
.accounts-confirm-copy {
margin-bottom: 0;
}
.accounts-detail-drawer .ant-drawer-content-wrapper {
height: 100dvh !important;
max-height: 100dvh;
}
.accounts-detail-drawer .ant-drawer-content {
max-height: 100dvh;
overflow: hidden;
}
.accounts-detail-drawer .ant-drawer-body {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
@media (max-width: 1120px) {
.accounts-filter-bar {
flex-wrap: wrap;
}
.accounts-filter-actions {
width: 100%;
margin-inline-start: 0;
}
.accounts-search {
flex: 1 1 320px;
width: min(100%, 460px);
}
}
@media (max-width: 768px) {
.accounts-content {
padding: 8px;
}
.accounts-shell {
gap: 8px;
}
.accounts-metric {
padding: 12px 8px;
}
.accounts-metric-heading {
font-size: 11px;
}
.accounts-metric-value-row {
gap: 5px;
margin-top: 7px;
}
.accounts-metric-icon {
font-size: 14px;
}
.accounts-metric-value {
font-size: 20px;
}
.accounts-filter-bar {
padding: 10px 12px;
}
.accounts-filter-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 32px;
}
.accounts-search {
grid-column: 1 / -1;
width: 100%;
}
.accounts-filter-select {
width: 100%;
}
.accounts-filter-refresh {
width: 32px;
padding-inline: 0;
}
.accounts-table-card .ant-pagination {
padding-inline: 12px;
}
.accounts-detail-heading {
grid-template-columns: 38px minmax(0, 1fr);
}
.accounts-detail-heading > .ant-tag {
grid-column: 2;
justify-self: start;
}
.accounts-detail-icon {
width: 38px;
height: 38px;
}
.accounts-detail .ant-descriptions-item-label {
width: 96px;
}
}
+634
View File
@@ -0,0 +1,634 @@
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,
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,
EyeOutlined,
IdcardOutlined,
LockOutlined,
ReloadOutlined,
SafetyCertificateOutlined,
SearchOutlined,
StopOutlined,
UnlockOutlined,
UserAddOutlined,
UserOutlined,
} from '@ant-design/icons';
import {
useGameAccountsQuery,
useGameAccountStatusMutation,
} from '@/api/queries/useGameAccountsQuery';
import type { GameAccountFilters } from '@/api/queries/useGameAccountsQuery';
import { useTheme } from '@/hooks/useTheme';
import AppSidebar from '@/layouts/AppSidebar';
import WorkspaceTabs from '@/layouts/WorkspaceTabs';
import type { GameAccount, GameAccountPlatform, GameAccountStatus } from '@/schemas/gameAccount';
import { setMessageInstance } from '@/utils/messageBus';
import './AccountsPage.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<GameAccountStatus, { color: string; label: string }> = {
active: { color: 'success', label: '正常' },
banned: { color: 'error', label: '已封禁' },
locked: { color: 'warning', label: '已锁定' },
};
const platformLabels: Record<GameAccountPlatform, string> = {
android: 'Android',
ios: 'iOS',
pc: 'PC',
};
const statusOptions = [
{ value: 'all', label: '全部状态' },
{ value: 'active', label: '正常' },
{ value: 'banned', label: '已封禁' },
{ value: 'locked', label: '已锁定' },
] satisfies Array<{ value: GameAccountStatus | '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 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: GameAccountStatus }) {
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="accounts-metric" style={{ '--metric-accent': accent } as CSSProperties}>
<div className="accounts-metric-heading">{label}</div>
<div className="accounts-metric-value-row">
<span className="accounts-metric-icon">{icon}</span>
<span className="accounts-metric-value">{integerFormatter.format(value)}</span>
</div>
</div>
);
}
export default function AccountsPage() {
const { isDark, isUltra, antdThemeConfig } = useTheme();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => {
setMessageInstance(messageApi);
}, [messageApi]);
const [filters, setFilters] = useState<GameAccountFilters>({
page: 1,
pageSize: 20,
keyword: '',
status: 'all',
platform: 'all',
});
const [keywordInput, setKeywordInput] = useState('');
const [selectedAccount, setSelectedAccount] = useState<GameAccount | null>(null);
const [banTarget, setBanTarget] = useState<GameAccount | null>(null);
const [recoverTarget, setRecoverTarget] = useState<GameAccount | null>(null);
const tableViewportRef = useRef<HTMLDivElement>(null);
const [tableScrollHeight, setTableScrollHeight] = useState(DEFAULT_TABLE_SCROLL_HEIGHT);
const [banForm] = Form.useForm<BanFormValues>();
const statusMutation = useGameAccountStatusMutation();
const { accounts, total, summary, platformOptions, loading, fetched, fetchError, refresh } =
useGameAccountsQuery(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-accounts-page'];
if (isDark) classes.push('is-dark');
if (isUltra) classes.push('is-ultra');
return classes.join(' ');
}, [isDark, isUltra]);
const updateFilters = useCallback((patch: Partial<GameAccountFilters>) => {
setFilters((current) => ({ ...current, ...patch, page: patch.page ?? 1 }));
}, []);
const openBanModal = useCallback(
(account: GameAccount) => {
setBanTarget(account);
banForm.setFieldsValue({ reason: '', durationDays: 7 });
},
[banForm],
);
const submitBan = async (values: BanFormValues) => {
if (!banTarget) return;
try {
const updated = await statusMutation.mutateAsync({
accountId: banTarget.id,
status: 'banned',
reason: values.reason,
durationDays: values.durationDays,
});
setSelectedAccount((current) => (current?.id === updated.id ? updated : current));
messageApi.success('账号已封禁');
setBanTarget(null);
banForm.resetFields();
} catch {
// The HTTP layer already reports the server error and the modal remains open for correction.
}
};
const submitRecover = async () => {
if (!recoverTarget) return;
try {
const updated = await statusMutation.mutateAsync({
accountId: recoverTarget.id,
status: 'active',
});
setSelectedAccount((current) => (current?.id === updated.id ? updated : current));
messageApi.success(recoverTarget.status === 'locked' ? '账号已解锁' : '账号已解封');
setRecoverTarget(null);
} catch {
// The HTTP layer already reports the server error and keeps the confirmation open.
}
};
const columns = useMemo<TableProps<GameAccount>['columns']>(
() => [
{
title: '账号',
key: 'account',
width: 210,
className: 'accounts-cell-start',
render: (_, account) => (
<div className="accounts-identity">
<Typography.Text strong>{account.username}</Typography.Text>
<Typography.Text type="secondary" className="accounts-mono">
ID {account.id}
</Typography.Text>
</div>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 94,
className: 'accounts-cell-start',
render: (status: GameAccountStatus) => <StatusTag status={status} />,
},
{
title: '平台 / 渠道',
key: 'platform',
width: 130,
className: 'accounts-cell-start',
render: (_, account) => (
<div className="accounts-stack">
<span>{platformLabels[account.platform]}</span>
<Typography.Text type="secondary">{account.channel}</Typography.Text>
</div>
),
},
{
title: '角色数',
dataIndex: 'characterCount',
key: 'characterCount',
width: 82,
className: 'accounts-cell-start',
render: (value: number) => integerFormatter.format(value),
},
{
title: '设备数',
dataIndex: 'deviceCount',
key: 'deviceCount',
width: 82,
className: 'accounts-cell-start',
render: (value: number) => integerFormatter.format(value),
},
{
title: '累计充值',
dataIndex: 'totalRecharge',
key: 'totalRecharge',
width: 120,
className: 'accounts-cell-start',
render: (value: number) => (
<span className="accounts-numeric">{currencyFormatter.format(value)}</span>
),
},
{
title: '注册时间',
dataIndex: 'registerTime',
key: 'registerTime',
width: 166,
className: 'accounts-cell-start',
render: (value: string) => <span className="accounts-time">{formatDateTime(value)}</span>,
},
{
title: '最后登录',
key: 'lastLogin',
width: 190,
className: 'accounts-cell-start',
render: (_, account) => (
<div className="accounts-stack">
<span className="accounts-time">{formatDateTime(account.lastLoginTime)}</span>
<Typography.Text type="secondary" className="accounts-mono">
{account.lastLoginIp}
</Typography.Text>
</div>
),
},
{
title: '操作',
key: 'actions',
width: 112,
fixed: 'right',
className: 'accounts-cell-start',
render: (_, account) => (
<Space size={2} className="accounts-row-actions">
<Tooltip title="查看详情">
<Button
type="text"
aria-label={`查看账号 ${account.username}`}
icon={<EyeOutlined />}
onClick={() => setSelectedAccount(account)}
/>
</Tooltip>
{account.status === 'active' ? (
<Tooltip title="封禁账号">
<Button
type="text"
danger
aria-label={`封禁账号 ${account.username}`}
icon={<StopOutlined />}
disabled={statusMutation.isPending}
onClick={() => openBanModal(account)}
/>
</Tooltip>
) : (
<Tooltip title={account.status === 'locked' ? '解锁账号' : '解除封禁'}>
<Button
type="text"
aria-label={`${account.status === 'locked' ? '解锁' : '解封'}账号 ${account.username}`}
icon={account.status === 'locked' ? <UnlockOutlined /> : <CheckCircleOutlined />}
disabled={statusMutation.isPending}
onClick={() => setRecoverTarget(account)}
/>
</Tooltip>
)}
</Space>
),
},
],
[openBanModal, statusMutation.isPending],
);
return (
<ConfigProvider locale={zhCN} theme={antdThemeConfig}>
{messageContextHolder}
<Layout className={pageClass}>
<AppSidebar />
<Layout className="accounts-content-shell">
<WorkspaceTabs />
<Layout.Content className="accounts-content">
<Spin spinning={!fetched} delay={150} size="large" description="正在加载">
{!fetched ? (
<div className="accounts-loading-spacer" />
) : fetchError ? (
<Result
status="error"
title="账号数据加载失败"
subTitle={fetchError}
extra={
<Button type="primary" loading={loading} onClick={() => refresh()}>
重新加载
</Button>
}
/>
) : (
<div className="accounts-shell">
<Card size="small" className="accounts-summary-card">
<div className="accounts-metric-grid">
<SummaryMetric
label="总账号"
value={summary.total}
icon={<UserOutlined />}
accent="#1677ff"
/>
<SummaryMetric
label="今日新增"
value={summary.newToday}
icon={<UserAddOutlined />}
accent="#13a8a8"
/>
<SummaryMetric
label="正常"
value={summary.active}
icon={<SafetyCertificateOutlined />}
accent="#52c41a"
/>
<SummaryMetric
label="已封禁"
value={summary.banned}
icon={<StopOutlined />}
accent="#ff4d4f"
/>
<SummaryMetric
label="已锁定"
value={summary.locked}
icon={<LockOutlined />}
accent="#faad14"
/>
</div>
</Card>
<Card size="small" className="accounts-filter-card">
<div className="accounts-filter-bar">
<div className="accounts-filter-actions">
<div className="accounts-search">
<Input
allowClear
aria-label="搜索账号"
placeholder="账号 ID、账号名、最后登录 IP"
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="accounts-filter-select"
aria-label="账号状态筛选"
value={filters.status}
options={statusOptions}
onChange={(status) => updateFilters({ status })}
/>
<Select
className="accounts-filter-select"
aria-label="账号平台筛选"
value={filters.platform}
options={[
{ value: 'all', label: '全部平台' },
...platformOptions.map((option) => ({
value: option.value,
label: option.label,
})),
]}
onChange={(platform) => updateFilters({ platform })}
/>
<Button
className="accounts-filter-refresh"
aria-label="刷新账号数据"
title="刷新账号数据"
icon={<ReloadOutlined />}
loading={loading}
onClick={() => refresh()}
/>
</div>
</div>
</Card>
<Card size="small" className="accounts-table-card">
<div ref={tableViewportRef} className="accounts-table-viewport">
<Table<GameAccount>
rowKey="id"
size="middle"
columns={columns}
dataSource={accounts}
loading={loading}
scroll={{ x: 1286, 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, 520px)"
open={selectedAccount !== null}
rootClassName={`accounts-detail-drawer ${isDark ? 'dark' : 'light'}`}
onClose={() => setSelectedAccount(null)}
>
{selectedAccount && (
<div className="accounts-detail">
<div className="accounts-detail-heading">
<span className="accounts-detail-icon">
<IdcardOutlined />
</span>
<div>
<Typography.Title level={4}>{selectedAccount.username}</Typography.Title>
<Typography.Text type="secondary" className="accounts-mono">
ID {selectedAccount.id}
</Typography.Text>
</div>
<StatusTag status={selectedAccount.status} />
</div>
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="平台">
{platformLabels[selectedAccount.platform]}
</Descriptions.Item>
<Descriptions.Item label="渠道">{selectedAccount.channel}</Descriptions.Item>
<Descriptions.Item label="角色数">
{integerFormatter.format(selectedAccount.characterCount)}
</Descriptions.Item>
<Descriptions.Item label="绑定设备">
{integerFormatter.format(selectedAccount.deviceCount)}
</Descriptions.Item>
<Descriptions.Item label="累计充值">
{currencyFormatter.format(selectedAccount.totalRecharge)}
</Descriptions.Item>
<Descriptions.Item label="注册时间">
{formatDateTime(selectedAccount.registerTime)}
</Descriptions.Item>
<Descriptions.Item label="最后登录">
{formatDateTime(selectedAccount.lastLoginTime)}
</Descriptions.Item>
<Descriptions.Item label="最后登录 IP">
<span className="accounts-mono">{selectedAccount.lastLoginIp}</span>
</Descriptions.Item>
{selectedAccount.status === 'banned' && (
<>
<Descriptions.Item label="封禁原因">
{selectedAccount.banReason || '—'}
</Descriptions.Item>
<Descriptions.Item label="封禁截止">
{formatDateTime(selectedAccount.banUntil)}
</Descriptions.Item>
</>
)}
</Descriptions>
</div>
)}
</Drawer>
<Modal
title={`封禁账号${banTarget ? `:${banTarget.username}` : ''}`}
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={recoverTarget?.status === 'locked' ? '解锁账号' : '解除封禁'}
open={recoverTarget !== null}
okText={recoverTarget?.status === 'locked' ? '确认解锁' : '确认解封'}
cancelText="取消"
confirmLoading={statusMutation.isPending}
onOk={submitRecover}
onCancel={() => setRecoverTarget(null)}
>
<Typography.Paragraph className="accounts-confirm-copy">
{recoverTarget ? `账号 ${recoverTarget.username} 将恢复为正常状态。` : ''}
</Typography.Paragraph>
</Modal>
</Layout>
</ConfigProvider>
);
}
@@ -0,0 +1,511 @@
.game-announcements-page {
--announcements-page-bg: #e6e8ec;
--announcements-card-bg: #ffffff;
height: 100vh;
min-height: 0;
overflow: hidden;
background: var(--announcements-page-bg);
}
.game-announcements-page.is-dark {
--announcements-page-bg: #1a1b1f;
--announcements-card-bg: #23252b;
}
.game-announcements-page.is-dark.is-ultra {
--announcements-page-bg: #000000;
--announcements-card-bg: #101013;
}
.game-announcements-page .ant-layout,
.game-announcements-page .ant-layout-content,
.announcements-content-shell {
background: transparent;
}
.announcements-content-shell {
height: 100%;
min-height: 0;
overflow: hidden;
}
.announcements-content {
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow: hidden;
padding: 20px;
}
.announcements-content > .ant-spin,
.announcements-content > .ant-spin > .ant-spin-container {
width: 100%;
min-height: 0;
}
.announcements-content > .ant-spin {
display: flex;
flex: 1 1 auto;
}
.announcements-content > .ant-spin > .ant-spin-container {
height: 100%;
}
.announcements-loading-spacer {
height: 100%;
min-height: 0;
}
.announcements-shell {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
height: 100%;
min-height: 0;
}
.announcements-summary-card,
.announcements-filter-card {
flex: 0 0 auto;
}
.announcements-summary-card,
.announcements-filter-card,
.announcements-table-card {
overflow: hidden;
border-radius: 8px;
background: var(--announcements-card-bg);
}
.announcements-table-card {
display: flex;
flex: 1 1 0;
min-height: 0;
}
.announcements-summary-card .ant-card-body {
padding: 0;
}
.announcements-metric-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
background: var(--announcements-card-bg);
}
.announcements-metric {
min-width: 0;
padding: 16px 20px;
background: var(--announcements-card-bg);
}
.announcements-metric-heading {
overflow: hidden;
color: var(--ant-color-text-secondary);
font-size: 12px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.announcements-metric-value-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
margin-top: 8px;
}
.announcements-metric-icon {
display: inline-flex;
flex: 0 0 auto;
color: var(--metric-accent);
font-size: 16px;
}
.announcements-metric-value {
min-width: 0;
overflow-wrap: anywhere;
color: var(--ant-color-text);
font-size: 22px;
font-weight: 500;
font-variant-numeric: tabular-nums;
line-height: 1;
white-space: nowrap;
}
.announcements-filter-card .ant-card-body,
.announcements-table-card .ant-card-body {
min-width: 0;
padding: 0;
}
.announcements-table-card .ant-card-body {
display: flex;
flex: 1 1 auto;
min-height: 0;
}
.announcements-table-viewport {
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
.announcements-filter-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
}
.announcements-filter-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
min-width: 0;
margin-inline-start: auto;
}
.announcements-create-button,
.announcements-filter-refresh {
flex: 0 0 auto;
}
.announcements-search {
display: flex;
width: min(370px, 33vw);
}
.announcements-search .ant-input-affix-wrapper {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
.announcements-search .ant-btn {
border-start-start-radius: 0;
border-end-start-radius: 0;
}
.announcements-filter-select,
.announcements-channel-select {
width: 126px;
}
.announcements-table-card .ant-table-wrapper {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.announcements-table-card .ant-table-wrapper > .ant-spin,
.announcements-table-card .ant-table-wrapper > .ant-spin > .ant-spin-container,
.announcements-table-card .ant-table {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
}
.announcements-table-card .ant-table-container {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
}
.announcements-table-card .ant-table-header {
flex: 0 0 auto;
}
.announcements-table-card .ant-table-body {
flex: 1 1 auto;
min-height: 0;
max-height: none !important;
overflow-y: auto !important;
}
.announcements-table-card .ant-table-thead .ant-table-cell,
.announcements-table-card .ant-table-tbody > tr > .ant-table-cell,
.announcements-table-card .ant-table-tbody > tr > .announcements-cell-start {
text-align: left !important;
vertical-align: middle;
}
.announcements-table-card .ant-pagination {
flex: 0 0 auto;
margin-block: 10px;
padding-inline: 16px;
}
.announcements-identity,
.announcements-stack {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.announcements-identity .ant-typography,
.announcements-stack .ant-typography {
overflow: hidden;
margin: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.announcements-stack .ant-typography-secondary,
.announcements-mono {
font-size: 12px;
}
.announcements-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.announcements-numeric,
.announcements-time {
font-variant-numeric: tabular-nums;
}
.announcements-time,
.announcements-time-range {
white-space: nowrap;
}
.announcements-row-actions {
min-width: 0;
justify-content: flex-start;
}
.announcements-row-actions .ant-btn {
width: 32px;
height: 32px;
}
.announcements-table-card .announcements-actions-cell {
padding-inline: 8px !important;
}
.announcements-detail {
display: flex;
flex-direction: column;
gap: 18px;
}
.announcements-detail-heading {
display: grid;
grid-template-columns: 42px minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
}
.announcements-detail-heading .ant-typography {
margin: 0;
}
.announcements-detail-heading h4.ant-typography {
font-size: 17px;
}
.announcements-detail-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 6px;
background: var(--ant-color-primary-bg);
color: var(--ant-color-primary);
font-size: 20px;
}
.announcements-detail .ant-descriptions-item-label {
width: 116px;
white-space: nowrap;
}
.announcements-detail .ant-descriptions-item-content {
overflow-wrap: anywhere;
}
.announcements-detail-content {
white-space: pre-wrap;
}
.announcements-detail-drawer .ant-drawer-content-wrapper {
height: 100dvh !important;
max-height: 100dvh;
}
.announcements-detail-drawer .ant-drawer-content {
max-height: 100dvh;
overflow: hidden;
}
.announcements-detail-drawer .ant-drawer-body {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
.announcements-editor-modal .ant-modal {
top: 24px;
padding-bottom: 24px;
}
.announcements-editor-modal .ant-modal-body {
max-height: calc(100dvh - 210px);
overflow-y: auto;
overscroll-behavior: contain;
}
.announcements-form-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 180px;
gap: 0 14px;
}
.announcements-form-grid .ant-input-number,
.announcements-editor-modal .ant-picker {
width: 100%;
}
.announcements-confirm-copy {
margin-bottom: 0;
}
@media (max-width: 1240px) {
.announcements-filter-bar {
flex-wrap: wrap;
}
.announcements-filter-actions {
width: 100%;
margin-inline-start: 0;
}
.announcements-search {
flex: 1 1 280px;
width: min(100%, 420px);
}
}
@media (max-width: 900px) {
.announcements-metric {
padding-inline: 14px;
}
.announcements-metric-value-row {
gap: 6px;
}
.announcements-metric-value {
font-size: 20px;
}
}
@media (max-width: 768px) {
.announcements-editor-modal .ant-modal {
top: 8px;
max-width: calc(100vw - 16px);
padding-bottom: 8px;
}
.announcements-content {
padding: 8px;
}
.announcements-shell {
gap: 8px;
}
.announcements-metric {
padding: 12px 8px;
}
.announcements-metric-heading {
font-size: 11px;
}
.announcements-metric-value-row {
gap: 5px;
margin-top: 7px;
}
.announcements-metric-icon {
font-size: 14px;
}
.announcements-metric-value {
font-size: 18px;
}
.announcements-filter-bar {
padding: 10px 12px;
}
.announcements-filter-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 32px;
}
.announcements-create-button,
.announcements-search {
grid-column: 1 / -1;
}
.announcements-create-button {
justify-self: end;
}
.announcements-search,
.announcements-filter-select,
.announcements-channel-select {
width: 100%;
}
.announcements-filter-refresh {
width: 32px;
padding-inline: 0;
}
.announcements-table-card .ant-pagination {
padding-inline: 12px;
}
.announcements-table-card .ant-tag {
margin-inline-end: 0;
padding-inline: 4px;
font-size: 12px;
}
.announcements-detail-heading {
grid-template-columns: 38px minmax(0, 1fr);
}
.announcements-detail-heading > .ant-tag {
grid-column: 2;
justify-self: start;
}
.announcements-detail-icon {
width: 38px;
height: 38px;
}
.announcements-detail .ant-descriptions-item-label {
width: 96px;
}
.announcements-form-grid {
grid-template-columns: minmax(0, 1fr);
}
}
@@ -0,0 +1,793 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { CSSProperties, ReactNode } from 'react';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import {
Button,
Card,
ConfigProvider,
DatePicker,
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 {
BellOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
EditOutlined,
EyeOutlined,
FileTextOutlined,
PauseCircleOutlined,
PlusOutlined,
ReloadOutlined,
SearchOutlined,
SendOutlined,
StopOutlined,
} from '@ant-design/icons';
import {
useGameAnnouncementsQuery,
useGameAnnouncementStatusMutation,
useSaveGameAnnouncementMutation,
} from '@/api/queries/useGameAnnouncementsQuery';
import type { GameAnnouncementFilters } from '@/api/queries/useGameAnnouncementsQuery';
import { useTheme } from '@/hooks/useTheme';
import AppSidebar from '@/layouts/AppSidebar';
import WorkspaceTabs from '@/layouts/WorkspaceTabs';
import type {
GameAnnouncement,
GameAnnouncementChannel,
GameAnnouncementStatus,
} from '@/schemas/gameAnnouncement';
import { setMessageInstance } from '@/utils/messageBus';
import './AnnouncementsPage.css';
const integerFormatter = new Intl.NumberFormat('zh-CN', {
useGrouping: false,
maximumFractionDigits: 0,
});
const DEFAULT_TABLE_SCROLL_HEIGHT = 240;
const MIN_TABLE_SCROLL_HEIGHT = 96;
const statusMeta: Record<GameAnnouncementStatus, { color: string; label: string }> = {
draft: { color: 'default', label: '草稿' },
scheduled: { color: 'processing', label: '待发布' },
active: { color: 'success', label: '生效中' },
ended: { color: 'default', label: '已结束' },
offline: { color: 'error', label: '已下线' },
};
const channelMeta: Record<GameAnnouncementChannel, { color: string; label: string }> = {
login: { color: 'blue', label: '登录公告' },
banner: { color: 'cyan', label: '活动横幅' },
marquee: { color: 'gold', label: '跑马灯' },
};
const statusOptions = [
{ value: 'all', label: '全部状态' },
{ value: 'draft', label: '草稿' },
{ value: 'scheduled', label: '待发布' },
{ value: 'active', label: '生效中' },
{ value: 'ended', label: '已结束' },
{ value: 'offline', label: '已下线' },
] satisfies Array<{ value: GameAnnouncementStatus | 'all'; label: string }>;
const channelOptions = [
{ value: 'all', label: '全部渠道' },
{ value: 'login', label: '登录公告' },
{ value: 'banner', label: '活动横幅' },
{ value: 'marquee', label: '跑马灯' },
] satisfies Array<{ value: GameAnnouncementChannel | 'all'; label: string }>;
interface AnnouncementFormValues {
title: string;
content: string;
channel: GameAnnouncementChannel;
priority: number;
serverIds: string[];
startAt: Dayjs;
endAt: Dayjs;
}
interface StatusTarget {
announcement: GameAnnouncement;
action: 'publish' | 'offline';
}
function formatDateTime(value: string | null) {
return value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '—';
}
function StatusTag({ status }: { status: GameAnnouncementStatus }) {
const meta = statusMeta[status];
return (
<Tag color={meta.color} variant="filled">
{meta.label}
</Tag>
);
}
function ChannelTag({ channel }: { channel: GameAnnouncementChannel }) {
const meta = channelMeta[channel];
return <Tag color={meta.color}>{meta.label}</Tag>;
}
function SummaryMetric({
label,
value,
icon,
accent,
}: {
label: string;
value: number;
icon: ReactNode;
accent: string;
}) {
return (
<div className="announcements-metric" style={{ '--metric-accent': accent } as CSSProperties}>
<div className="announcements-metric-heading">{label}</div>
<div className="announcements-metric-value-row">
<span className="announcements-metric-icon">{icon}</span>
<span className="announcements-metric-value">{integerFormatter.format(value)}</span>
</div>
</div>
);
}
function scopeSummary(announcement: GameAnnouncement) {
if (announcement.scope === 'all') return '全区服';
if (announcement.serverNames.length <= 2) return announcement.serverNames.join(' / ');
return `${announcement.serverNames.slice(0, 2).join(' / ')} 等 ${announcement.serverNames.length} 个区服`;
}
export default function AnnouncementsPage() {
const { isDark, isUltra, antdThemeConfig } = useTheme();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => {
setMessageInstance(messageApi);
}, [messageApi]);
const [filters, setFilters] = useState<GameAnnouncementFilters>({
page: 1,
pageSize: 20,
keyword: '',
status: 'all',
channel: 'all',
});
const [keywordInput, setKeywordInput] = useState('');
const [selectedAnnouncement, setSelectedAnnouncement] = useState<GameAnnouncement | null>(null);
const [editingAnnouncement, setEditingAnnouncement] = useState<GameAnnouncement | null>(null);
const [editorOpen, setEditorOpen] = useState(false);
const [statusTarget, setStatusTarget] = useState<StatusTarget | null>(null);
const tableViewportRef = useRef<HTMLDivElement>(null);
const [tableScrollHeight, setTableScrollHeight] = useState(DEFAULT_TABLE_SCROLL_HEIGHT);
const [editorForm] = Form.useForm<AnnouncementFormValues>();
const saveMutation = useSaveGameAnnouncementMutation();
const statusMutation = useGameAnnouncementStatusMutation();
const { announcements, total, summary, serverOptions, loading, fetched, fetchError, refresh } =
useGameAnnouncementsQuery(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-announcements-page'];
if (isDark) classes.push('is-dark');
if (isUltra) classes.push('is-ultra');
return classes.join(' ');
}, [isDark, isUltra]);
const updateFilters = useCallback((patch: Partial<GameAnnouncementFilters>) => {
setFilters((current) => ({ ...current, ...patch, page: patch.page ?? 1 }));
}, []);
const openCreateEditor = useCallback(() => {
setEditingAnnouncement(null);
editorForm.setFieldsValue({
title: '',
content: '',
channel: 'login',
priority: 50,
serverIds: [],
startAt: dayjs(),
endAt: dayjs().add(7, 'day'),
});
setEditorOpen(true);
}, [editorForm]);
const openEditEditor = useCallback(
(announcement: GameAnnouncement) => {
setEditingAnnouncement(announcement);
editorForm.setFieldsValue({
title: announcement.title,
content: announcement.content,
channel: announcement.channel,
priority: announcement.priority,
serverIds: announcement.serverIds,
startAt: dayjs(announcement.startAt ?? announcement.createdAt),
endAt: dayjs(announcement.endAt ?? dayjs(announcement.createdAt).add(7, 'day')),
});
setEditorOpen(true);
},
[editorForm],
);
const closeEditor = useCallback(() => {
setEditorOpen(false);
setEditingAnnouncement(null);
editorForm.resetFields();
}, [editorForm]);
const submitEditor = async (mode: 'draft' | 'publish') => {
try {
const values = await editorForm.validateFields();
if (!values.endAt.isAfter(values.startAt)) {
editorForm.setFields([{ name: 'endAt', errors: ['结束时间必须晚于开始时间'] }]);
return;
}
await saveMutation.mutateAsync({
announcementId: editingAnnouncement?.id,
data: {
title: values.title.trim(),
content: values.content.trim(),
channel: values.channel,
serverIds: values.serverIds,
priority: values.priority,
startAt: values.startAt.toISOString(),
endAt: values.endAt.toISOString(),
mode,
},
});
messageApi.success(mode === 'publish' ? '公告已保存并发布' : '公告草稿已保存');
closeEditor();
} catch (error) {
if (error && typeof error === 'object' && 'errorFields' in error) return;
// The HTTP layer reports the server error and keeps the editor open for correction.
}
};
const submitStatus = async () => {
if (!statusTarget) return;
try {
const updated = await statusMutation.mutateAsync({
announcementId: statusTarget.announcement.id,
action: statusTarget.action,
});
setSelectedAnnouncement((current) => (current?.id === updated.id ? updated : current));
messageApi.success(statusTarget.action === 'offline' ? '公告已下线' : '公告已发布');
setStatusTarget(null);
} catch {
// The HTTP layer reports the server error and keeps the confirmation open.
}
};
const columns = useMemo<TableProps<GameAnnouncement>['columns']>(
() => [
{
title: '公告标题',
key: 'title',
width: 260,
className: 'announcements-cell-start',
render: (_, announcement) => (
<div className="announcements-identity">
<Typography.Text strong>{announcement.title}</Typography.Text>
<Typography.Text type="secondary" className="announcements-mono">
{announcement.id}
</Typography.Text>
</div>
),
},
{
title: '渠道',
dataIndex: 'channel',
key: 'channel',
width: 104,
className: 'announcements-cell-start',
render: (channel: GameAnnouncementChannel) => <ChannelTag channel={channel} />,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 92,
className: 'announcements-cell-start',
render: (status: GameAnnouncementStatus) => <StatusTag status={status} />,
},
{
title: '适用区服',
key: 'scope',
width: 196,
className: 'announcements-cell-start',
render: (_, announcement) => (
<Typography.Text ellipsis={{ tooltip: scopeSummary(announcement) }}>
{scopeSummary(announcement)}
</Typography.Text>
),
},
{
title: '优先级',
dataIndex: 'priority',
key: 'priority',
width: 82,
className: 'announcements-cell-start',
render: (priority: number) => (
<span className="announcements-numeric">{integerFormatter.format(priority)}</span>
),
},
{
title: '展示时间',
key: 'schedule',
width: 202,
className: 'announcements-cell-start',
render: (_, announcement) => (
<div className="announcements-stack announcements-time-range">
<span>开始 {formatDateTime(announcement.startAt)}</span>
<Typography.Text type="secondary">
结束 {formatDateTime(announcement.endAt)}
</Typography.Text>
</div>
),
},
{
title: '触达数据',
key: 'metrics',
width: 132,
className: 'announcements-cell-start',
render: (_, announcement) => (
<div className="announcements-stack announcements-numeric">
<span>曝光 {integerFormatter.format(announcement.viewCount)}</span>
<Typography.Text type="secondary">
点击 {integerFormatter.format(announcement.clickCount)}
</Typography.Text>
</div>
),
},
{
title: '更新信息',
key: 'updated',
width: 176,
className: 'announcements-cell-start',
render: (_, announcement) => (
<div className="announcements-stack">
<span className="announcements-time">{formatDateTime(announcement.updatedAt)}</span>
<Typography.Text type="secondary">{announcement.createdBy}</Typography.Text>
</div>
),
},
{
title: '操作',
key: 'actions',
width: 136,
fixed: 'right',
className: 'announcements-cell-start announcements-actions-cell',
render: (_, announcement) => {
const shouldOffline =
announcement.status === 'active' || announcement.status === 'scheduled';
return (
<Space size={2} className="announcements-row-actions">
<Tooltip title="查看详情">
<Button
type="text"
aria-label={`查看公告 ${announcement.title}`}
icon={<EyeOutlined />}
onClick={() => setSelectedAnnouncement(announcement)}
/>
</Tooltip>
<Tooltip title="编辑公告">
<Button
type="text"
aria-label={`编辑公告 ${announcement.title}`}
icon={<EditOutlined />}
onClick={() => openEditEditor(announcement)}
/>
</Tooltip>
<Tooltip title={shouldOffline ? '下线公告' : '发布公告'}>
<Button
type="text"
danger={shouldOffline}
aria-label={`${shouldOffline ? '下线' : '发布'}公告 ${announcement.title}`}
icon={shouldOffline ? <PauseCircleOutlined /> : <SendOutlined />}
disabled={statusMutation.isPending}
onClick={() =>
setStatusTarget({
announcement,
action: shouldOffline ? 'offline' : 'publish',
})
}
/>
</Tooltip>
</Space>
);
},
},
],
[openEditEditor, statusMutation.isPending],
);
const editingPublished =
editingAnnouncement?.status === 'active' || editingAnnouncement?.status === 'scheduled';
return (
<ConfigProvider locale={zhCN} theme={antdThemeConfig}>
{messageContextHolder}
<Layout className={pageClass}>
<AppSidebar />
<Layout className="announcements-content-shell">
<WorkspaceTabs />
<Layout.Content className="announcements-content">
<Spin spinning={!fetched} delay={150} size="large" description="正在加载">
{!fetched ? (
<div className="announcements-loading-spacer" />
) : fetchError ? (
<Result
status="error"
title="公告数据加载失败"
subTitle={fetchError}
extra={
<Button type="primary" loading={loading} onClick={() => refresh()}>
重新加载
</Button>
}
/>
) : (
<div className="announcements-shell">
<Card size="small" className="announcements-summary-card">
<div className="announcements-metric-grid">
<SummaryMetric
label="公告总数"
value={summary.total}
icon={<BellOutlined />}
accent="#1677ff"
/>
<SummaryMetric
label="生效中"
value={summary.active}
icon={<CheckCircleOutlined />}
accent="#52c41a"
/>
<SummaryMetric
label="待发布"
value={summary.scheduled}
icon={<ClockCircleOutlined />}
accent="#13a8a8"
/>
<SummaryMetric
label="草稿"
value={summary.draft}
icon={<FileTextOutlined />}
accent="#8c8c8c"
/>
<SummaryMetric
label="已结束或下线"
value={summary.inactive}
icon={<StopOutlined />}
accent="#ff4d4f"
/>
</div>
</Card>
<Card size="small" className="announcements-filter-card">
<div className="announcements-filter-bar">
<div className="announcements-filter-actions">
<Button
type="primary"
className="announcements-create-button"
icon={<PlusOutlined />}
onClick={openCreateEditor}
>
新建公告
</Button>
<div className="announcements-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="announcements-filter-select"
aria-label="公告状态筛选"
value={filters.status}
options={statusOptions}
onChange={(status) => updateFilters({ status })}
/>
<Select
className="announcements-channel-select"
aria-label="公告渠道筛选"
value={filters.channel}
options={channelOptions}
onChange={(channel) => updateFilters({ channel })}
/>
<Button
className="announcements-filter-refresh"
aria-label="刷新公告数据"
title="刷新公告数据"
icon={<ReloadOutlined />}
loading={loading}
onClick={() => refresh()}
/>
</div>
</div>
</Card>
<Card size="small" className="announcements-table-card">
<div ref={tableViewportRef} className="announcements-table-viewport">
<Table<GameAnnouncement>
rowKey="id"
size="middle"
columns={columns}
dataSource={announcements}
loading={loading}
scroll={{ x: 1480, 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, 620px)"
open={selectedAnnouncement !== null}
rootClassName={`announcements-detail-drawer ${isDark ? 'dark' : 'light'}`}
onClose={() => setSelectedAnnouncement(null)}
>
{selectedAnnouncement && (
<div className="announcements-detail">
<div className="announcements-detail-heading">
<span className="announcements-detail-icon">
<BellOutlined />
</span>
<div>
<Typography.Title level={4}>{selectedAnnouncement.title}</Typography.Title>
<Typography.Text type="secondary" className="announcements-mono">
{selectedAnnouncement.id}
</Typography.Text>
</div>
<StatusTag status={selectedAnnouncement.status} />
</div>
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="渠道">
<ChannelTag channel={selectedAnnouncement.channel} />
</Descriptions.Item>
<Descriptions.Item label="适用区服">
{scopeSummary(selectedAnnouncement)}
</Descriptions.Item>
<Descriptions.Item label="优先级">
{integerFormatter.format(selectedAnnouncement.priority)}
</Descriptions.Item>
<Descriptions.Item label="开始时间">
{formatDateTime(selectedAnnouncement.startAt)}
</Descriptions.Item>
<Descriptions.Item label="结束时间">
{formatDateTime(selectedAnnouncement.endAt)}
</Descriptions.Item>
<Descriptions.Item label="创建信息">
{selectedAnnouncement.createdBy} /{' '}
{formatDateTime(selectedAnnouncement.createdAt)}
</Descriptions.Item>
<Descriptions.Item label="最后更新">
{formatDateTime(selectedAnnouncement.updatedAt)}
</Descriptions.Item>
<Descriptions.Item label="触达数据">
曝光 {integerFormatter.format(selectedAnnouncement.viewCount)} / 点击{' '}
{integerFormatter.format(selectedAnnouncement.clickCount)}
</Descriptions.Item>
<Descriptions.Item label="公告内容">
<div className="announcements-detail-content">{selectedAnnouncement.content}</div>
</Descriptions.Item>
</Descriptions>
</div>
)}
</Drawer>
<Modal
title={editingAnnouncement ? '编辑公告' : '新建公告'}
open={editorOpen}
width={760}
rootClassName="announcements-editor-modal"
destroyOnHidden
footer={[
<Button key="cancel" onClick={closeEditor}>
取消
</Button>,
...(!editingPublished
? [
<Button
key="draft"
loading={saveMutation.isPending}
onClick={() => void submitEditor('draft')}
>
保存草稿
</Button>,
]
: []),
<Button
key="publish"
type="primary"
icon={<SendOutlined />}
loading={saveMutation.isPending}
onClick={() => void submitEditor('publish')}
>
{editingPublished ? '保存修改' : '发布公告'}
</Button>,
]}
onCancel={closeEditor}
>
<Form<AnnouncementFormValues> form={editorForm} layout="vertical" requiredMark={false}>
<Form.Item
name="title"
label="公告标题"
rules={[
{ required: true, whitespace: true, message: '请输入公告标题' },
{ max: 80, message: '公告标题不能超过 80 个字符' },
]}
>
<Input placeholder="请输入公告标题" />
</Form.Item>
<div className="announcements-form-grid">
<Form.Item name="channel" label="展示渠道" rules={[{ required: true }]}>
<Select options={channelOptions.filter((option) => option.value !== 'all')} />
</Form.Item>
<Form.Item
name="priority"
label="优先级"
rules={[{ required: true, message: '请输入优先级' }]}
>
<InputNumber min={1} max={100} precision={0} controls />
</Form.Item>
</div>
<div className="announcements-form-grid announcements-date-grid">
<Form.Item
name="startAt"
label="开始时间"
rules={[{ required: true, message: '请选择开始时间' }]}
>
<DatePicker showTime format="YYYY-MM-DD HH:mm:ss" />
</Form.Item>
<Form.Item
name="endAt"
label="结束时间"
rules={[{ required: true, message: '请选择结束时间' }]}
>
<DatePicker showTime format="YYYY-MM-DD HH:mm:ss" />
</Form.Item>
</div>
<Form.Item name="serverIds" label="适用区服">
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
maxTagCount={3}
placeholder="留空表示全区服"
options={serverOptions}
/>
</Form.Item>
<Form.Item
name="content"
label="公告内容"
rules={[
{ required: true, whitespace: true, message: '请输入公告内容' },
{ max: 5000, message: '公告内容不能超过 5000 个字符' },
]}
>
<Input.TextArea
rows={8}
showCount
maxLength={5000}
placeholder="请输入面向玩家展示的公告内容"
/>
</Form.Item>
</Form>
</Modal>
<Modal
title={statusTarget?.action === 'offline' ? '下线公告' : '发布公告'}
open={statusTarget !== null}
okText={statusTarget?.action === 'offline' ? '确认下线' : '确认发布'}
cancelText="取消"
okButtonProps={{
danger: statusTarget?.action === 'offline',
loading: statusMutation.isPending,
}}
onOk={submitStatus}
onCancel={() => setStatusTarget(null)}
>
<Typography.Paragraph className="announcements-confirm-copy">
{statusTarget
? `公告“${statusTarget.announcement.title}”将${
statusTarget.action === 'offline' ? '立即停止展示' : '按设定时间发布'
}。`
: ''}
</Typography.Paragraph>
</Modal>
</Layout>
</ConfigProvider>
);
}
+572
View File
@@ -0,0 +1,572 @@
.game-cdks-page {
--cdks-page-bg: #e6e8ec;
--cdks-card-bg: #ffffff;
height: 100vh;
min-height: 0;
overflow: hidden;
background: var(--cdks-page-bg);
}
.game-cdks-page.is-dark {
--cdks-page-bg: #1a1b1f;
--cdks-card-bg: #23252b;
}
.game-cdks-page.is-dark.is-ultra {
--cdks-page-bg: #000000;
--cdks-card-bg: #101013;
}
.game-cdks-page .ant-layout,
.game-cdks-page .ant-layout-content,
.cdks-content-shell {
background: transparent;
}
.cdks-content {
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow: hidden;
padding: 20px;
}
.cdks-content-shell {
height: 100%;
min-height: 0;
overflow: hidden;
}
.cdks-content > .ant-spin,
.cdks-content > .ant-spin > .ant-spin-container {
width: 100%;
min-height: 0;
}
.cdks-content > .ant-spin {
display: flex;
flex: 1 1 auto;
}
.cdks-content > .ant-spin > .ant-spin-container {
height: 100%;
}
.cdks-loading-spacer {
height: 100%;
min-height: 0;
}
.cdks-shell {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
height: 100%;
min-height: 0;
}
.cdks-summary-card,
.cdks-filter-card {
flex: 0 0 auto;
}
.cdks-summary-card,
.cdks-filter-card,
.cdks-table-card {
overflow: hidden;
border-radius: 8px;
background: var(--cdks-card-bg);
}
.cdks-table-card {
display: flex;
flex: 1 1 0;
min-height: 0;
}
.cdks-summary-card .ant-card-body {
padding: 0;
}
.cdks-metric-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
background: var(--cdks-card-bg);
}
.cdks-metric {
min-width: 0;
padding: 16px 20px;
background: var(--cdks-card-bg);
}
.cdks-metric-heading {
overflow: hidden;
color: var(--ant-color-text-secondary);
font-size: 12px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.cdks-metric-value-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
margin-top: 8px;
}
.cdks-metric-icon {
display: inline-flex;
flex: 0 0 auto;
color: var(--metric-accent);
font-size: 16px;
}
.cdks-metric-value {
min-width: 0;
overflow-wrap: anywhere;
color: var(--ant-color-text);
font-size: 22px;
font-weight: 500;
font-variant-numeric: tabular-nums;
line-height: 1;
white-space: nowrap;
}
.cdks-filter-card .ant-card-body,
.cdks-table-card .ant-card-body {
min-width: 0;
padding: 0;
}
.cdks-table-card .ant-card-body {
display: flex;
flex: 1 1 auto;
min-height: 0;
}
.cdks-table-viewport {
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
.cdks-filter-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
}
.cdks-filter-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
min-width: 0;
margin-inline-start: auto;
}
.cdks-create-button,
.cdks-filter-refresh {
flex: 0 0 auto;
}
.cdks-search {
display: flex;
width: min(370px, 33vw);
}
.cdks-search .ant-input-affix-wrapper {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
.cdks-search .ant-btn {
border-start-start-radius: 0;
border-end-start-radius: 0;
}
.cdks-filter-select {
width: 126px;
}
.cdks-batch-select {
width: 164px;
}
.cdks-table-card .ant-table-wrapper {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.cdks-table-card .ant-table-wrapper > .ant-spin,
.cdks-table-card .ant-table-wrapper > .ant-spin > .ant-spin-container,
.cdks-table-card .ant-table {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
}
.cdks-table-card .ant-table-container {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
}
.cdks-table-card .ant-table-header {
flex: 0 0 auto;
}
.cdks-table-card .ant-table-body {
flex: 1 1 auto;
min-height: 0;
max-height: none !important;
overflow-y: auto !important;
}
.cdks-table-card .ant-table-thead .ant-table-cell {
text-align: left !important;
vertical-align: middle;
}
.cdks-table-card .ant-table-tbody > tr > .ant-table-cell {
text-align: left;
vertical-align: middle;
}
.cdks-table-card .ant-table-tbody > tr > .cdks-cell-start {
text-align: left;
}
.cdks-table-card .ant-pagination {
flex: 0 0 auto;
margin-block: 10px;
padding-inline: 16px;
}
.cdks-identity,
.cdks-stack {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.cdks-identity .ant-typography,
.cdks-stack .ant-typography {
overflow: hidden;
margin: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.cdks-stack .ant-typography-secondary {
font-size: 12px;
}
.cdks-code,
.cdks-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.cdks-code {
overflow-wrap: anywhere;
}
.cdks-mono {
font-size: 12px;
}
.cdks-numeric,
.cdks-time {
font-variant-numeric: tabular-nums;
}
.cdks-time {
white-space: nowrap;
}
.cdks-reward-summary {
max-width: 100%;
}
.cdks-row-actions {
min-width: 0;
justify-content: flex-start;
}
.cdks-row-actions .ant-btn {
width: 32px;
height: 32px;
}
.cdks-table-card .cdks-actions-cell {
padding-inline: 8px !important;
}
.cdks-detail {
display: flex;
flex-direction: column;
gap: 18px;
}
.cdks-detail-heading {
display: grid;
grid-template-columns: 42px minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
}
.cdks-detail-heading .ant-typography {
margin: 0;
}
.cdks-detail-heading h4.ant-typography {
font-size: 17px;
}
.cdks-detail-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 6px;
background: var(--ant-color-primary-bg);
color: var(--ant-color-primary);
font-size: 20px;
}
.cdks-detail .ant-descriptions-item-label {
width: 116px;
white-space: nowrap;
}
.cdks-detail .ant-descriptions-item-content {
overflow-wrap: anywhere;
}
.cdks-detail-rewards {
display: flex;
flex-direction: column;
gap: 6px;
}
.cdks-detail-rewards > div {
display: flex;
justify-content: space-between;
gap: 16px;
}
.cdks-detail-drawer .ant-drawer-content-wrapper {
height: 100dvh !important;
max-height: 100dvh;
}
.cdks-detail-drawer .ant-drawer-content {
max-height: 100dvh;
overflow: hidden;
}
.cdks-detail-drawer .ant-drawer-body {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
.cdks-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 14px;
}
.cdks-form-grid .ant-input-number,
.cdks-form-grid .ant-picker {
width: 100%;
}
.cdks-batch-modal .ant-modal-body {
max-height: calc(100dvh - 210px);
overflow-y: auto;
overscroll-behavior: contain;
}
.cdks-reward-editor {
border-top: 1px solid var(--ant-color-border-secondary);
padding-top: 12px;
}
.cdks-reward-editor-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
font-weight: 500;
}
.cdks-reward-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 150px 32px;
align-items: end;
gap: 10px;
}
.cdks-reward-row .ant-form-item {
min-width: 0;
}
.cdks-reward-row .ant-input-number {
width: 100%;
}
.cdks-reward-remove {
margin-bottom: 24px;
}
.cdks-confirm-copy {
margin-bottom: 0;
}
@media (max-width: 1240px) {
.cdks-filter-bar {
flex-wrap: wrap;
}
.cdks-filter-actions {
width: 100%;
margin-inline-start: 0;
}
.cdks-search {
flex: 1 1 280px;
width: min(100%, 420px);
}
}
@media (max-width: 900px) {
.cdks-metric {
padding-inline: 14px;
}
.cdks-metric-value-row {
gap: 6px;
}
.cdks-metric-value {
font-size: 20px;
}
}
@media (max-width: 768px) {
.cdks-content {
padding: 8px;
}
.cdks-shell {
gap: 8px;
}
.cdks-metric {
padding: 12px 8px;
}
.cdks-metric-heading {
font-size: 11px;
}
.cdks-metric-value-row {
gap: 5px;
margin-top: 7px;
}
.cdks-metric-icon {
font-size: 14px;
}
.cdks-metric-value {
font-size: 18px;
}
.cdks-filter-bar {
padding: 10px 12px;
}
.cdks-filter-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 32px;
}
.cdks-create-button,
.cdks-search {
grid-column: 1 / -1;
}
.cdks-create-button {
justify-self: end;
}
.cdks-search {
width: 100%;
}
.cdks-filter-select,
.cdks-batch-select {
width: 100%;
}
.cdks-filter-refresh {
width: 32px;
padding-inline: 0;
}
.cdks-table-card .ant-pagination {
padding-inline: 12px;
}
.cdks-table-card .ant-tag {
margin-inline-end: 0;
padding-inline: 4px;
font-size: 12px;
}
.cdks-detail-heading {
grid-template-columns: 38px minmax(0, 1fr);
}
.cdks-detail-heading > .ant-tag {
grid-column: 2;
justify-self: start;
}
.cdks-detail-icon {
width: 38px;
height: 38px;
}
.cdks-detail .ant-descriptions-item-label {
width: 96px;
}
.cdks-form-grid,
.cdks-reward-row {
grid-template-columns: minmax(0, 1fr);
}
.cdks-reward-remove {
justify-self: end;
margin-top: -16px;
margin-bottom: 18px;
}
}
+814
View File
@@ -0,0 +1,814 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { CSSProperties, ReactNode } from 'react';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import {
Button,
Card,
ConfigProvider,
DatePicker,
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,
ClockCircleOutlined,
CopyOutlined,
DeleteOutlined,
EyeOutlined,
GiftOutlined,
KeyOutlined,
PlusOutlined,
ReloadOutlined,
SearchOutlined,
StopOutlined,
} from '@ant-design/icons';
import {
useCreateGameCdkBatchMutation,
useGameCdksQuery,
useGameCdkStatusMutation,
} from '@/api/queries/useGameCdksQuery';
import type { GameCdkFilters } from '@/api/queries/useGameCdksQuery';
import { useTheme } from '@/hooks/useTheme';
import AppSidebar from '@/layouts/AppSidebar';
import WorkspaceTabs from '@/layouts/WorkspaceTabs';
import type { GameCdk, GameCdkReward, GameCdkStatus } from '@/schemas/gameCdk';
import { setMessageInstance } from '@/utils/messageBus';
import './CdksPage.css';
const integerFormatter = new Intl.NumberFormat('zh-CN', {
useGrouping: false,
maximumFractionDigits: 0,
});
const DEFAULT_TABLE_SCROLL_HEIGHT = 240;
const MIN_TABLE_SCROLL_HEIGHT = 96;
const statusMeta: Record<GameCdkStatus, { color: string; label: string }> = {
unused: { color: 'success', label: '未使用' },
used: { color: 'processing', label: '已使用' },
expired: { color: 'default', label: '已过期' },
disabled: { color: 'error', label: '已停用' },
};
const statusOptions = [
{ value: 'all', label: '全部状态' },
{ value: 'unused', label: '未使用' },
{ value: 'used', label: '已使用' },
{ value: 'expired', label: '已过期' },
{ value: 'disabled', label: '已停用' },
] satisfies Array<{ value: GameCdkStatus | 'all'; label: string }>;
interface BatchFormValues {
batchName: string;
prefix: string;
quantity: number;
rewardName: string;
rewards: GameCdkReward[];
serverId: string;
expiresAt: Dayjs | null;
}
function formatDateTime(value: string | null) {
return value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '—';
}
function StatusTag({ status }: { status: GameCdkStatus }) {
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="cdks-metric" style={{ '--metric-accent': accent } as CSSProperties}>
<div className="cdks-metric-heading">{label}</div>
<div className="cdks-metric-value-row">
<span className="cdks-metric-icon">{icon}</span>
<span className="cdks-metric-value">{integerFormatter.format(value)}</span>
</div>
</div>
);
}
function rewardSummary(rewards: GameCdkReward[]) {
return rewards
.map((reward) => `${reward.itemName} ${integerFormatter.format(reward.quantity)}`)
.join(' / ');
}
function copyTextFallback(value: string) {
const textarea = document.createElement('textarea');
textarea.value = value;
textarea.readOnly = true;
textarea.style.position = 'fixed';
textarea.style.insetInlineStart = '-9999px';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
const copied = document.execCommand('copy');
textarea.remove();
return copied;
}
export default function CdksPage() {
const { isDark, isUltra, antdThemeConfig } = useTheme();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => {
setMessageInstance(messageApi);
}, [messageApi]);
const [filters, setFilters] = useState<GameCdkFilters>({
page: 1,
pageSize: 20,
keyword: '',
status: 'all',
batchId: 'all',
});
const [keywordInput, setKeywordInput] = useState('');
const [selectedCdk, setSelectedCdk] = useState<GameCdk | null>(null);
const [batchModalOpen, setBatchModalOpen] = useState(false);
const [statusTarget, setStatusTarget] = useState<GameCdk | null>(null);
const tableViewportRef = useRef<HTMLDivElement>(null);
const [tableScrollHeight, setTableScrollHeight] = useState(DEFAULT_TABLE_SCROLL_HEIGHT);
const [batchForm] = Form.useForm<BatchFormValues>();
const createMutation = useCreateGameCdkBatchMutation();
const statusMutation = useGameCdkStatusMutation();
const {
cdks,
total,
summary,
batchOptions,
serverOptions,
loading,
fetched,
fetchError,
refresh,
} = useGameCdksQuery(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-cdks-page'];
if (isDark) classes.push('is-dark');
if (isUltra) classes.push('is-ultra');
return classes.join(' ');
}, [isDark, isUltra]);
const updateFilters = useCallback((patch: Partial<GameCdkFilters>) => {
setFilters((current) => ({ ...current, ...patch, page: patch.page ?? 1 }));
}, []);
const openBatchModal = useCallback(() => {
batchForm.setFieldsValue({
batchName: '',
prefix: '',
quantity: 100,
rewardName: '',
rewards: [{ itemId: 'diamond', itemName: '钻石', quantity: 500 }],
serverId: 'all',
expiresAt: dayjs().add(30, 'day'),
});
setBatchModalOpen(true);
}, [batchForm]);
const closeBatchModal = useCallback(() => {
setBatchModalOpen(false);
batchForm.resetFields();
}, [batchForm]);
const submitBatch = async (values: BatchFormValues) => {
try {
const result = await createMutation.mutateAsync({
batchName: values.batchName.trim(),
prefix: values.prefix.trim().toUpperCase(),
quantity: values.quantity,
rewardName: values.rewardName.trim(),
rewards: values.rewards.map((reward) => ({
itemId: reward.itemId.trim(),
itemName: reward.itemName.trim(),
quantity: reward.quantity,
})),
serverId: values.serverId === 'all' ? null : values.serverId,
expiresAt: values.expiresAt ? values.expiresAt.toISOString() : null,
});
messageApi.success(`已生成 ${integerFormatter.format(result.createdCount)} 个 CDK`);
closeBatchModal();
} catch {
// The HTTP layer reports the server error and keeps the editor open for correction.
}
};
const copyCode = useCallback(
async (code: string) => {
try {
let copied = false;
try {
await navigator.clipboard.writeText(code);
copied = true;
} catch {
// LAN development runs over HTTP, where the modern Clipboard API is commonly blocked.
copied = copyTextFallback(code);
}
if (!copied) throw new Error('Copy command failed');
messageApi.success('CDK 已复制');
} catch {
messageApi.error('复制失败,请手动复制');
}
},
[messageApi],
);
const submitStatus = async () => {
if (!statusTarget) return;
const nextStatus = statusTarget.status === 'disabled' ? 'unused' : 'disabled';
try {
const updated = await statusMutation.mutateAsync({
cdkId: statusTarget.id,
status: nextStatus,
});
setSelectedCdk((current) => (current?.id === updated.id ? updated : current));
messageApi.success(nextStatus === 'disabled' ? 'CDK 已停用' : 'CDK 已启用');
setStatusTarget(null);
} catch {
// The HTTP layer reports the server error and keeps the confirmation open.
}
};
const columns = useMemo<TableProps<GameCdk>['columns']>(
() => [
{
title: 'CDK',
key: 'code',
width: 196,
className: 'cdks-cell-start',
render: (_, cdk) => (
<div className="cdks-identity">
<Typography.Text strong className="cdks-code">
{cdk.code}
</Typography.Text>
<Typography.Text type="secondary" className="cdks-mono">
ID {cdk.id}
</Typography.Text>
</div>
),
},
{
title: '批次',
key: 'batch',
width: 172,
className: 'cdks-cell-start',
render: (_, cdk) => (
<div className="cdks-stack">
<span>{cdk.batchName}</span>
<Typography.Text type="secondary" className="cdks-mono">
{cdk.batchId}
</Typography.Text>
</div>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 92,
className: 'cdks-cell-start',
render: (status: GameCdkStatus) => <StatusTag status={status} />,
},
{
title: '奖励内容',
key: 'rewards',
width: 220,
className: 'cdks-cell-start',
render: (_, cdk) => (
<div className="cdks-stack">
<span>{cdk.rewardName}</span>
<Typography.Text type="secondary" className="cdks-reward-summary">
{rewardSummary(cdk.rewards)}
</Typography.Text>
</div>
),
},
{
title: '适用范围',
key: 'scope',
width: 132,
className: 'cdks-cell-start',
render: (_, cdk) => (cdk.scope === 'all' ? '全区服通用' : cdk.serverName || '指定区服'),
},
{
title: '有效期至',
dataIndex: 'expiresAt',
key: 'expiresAt',
width: 168,
className: 'cdks-cell-start',
render: (value: string | null) => (
<span className="cdks-time">{value ? formatDateTime(value) : '永久有效'}</span>
),
},
{
title: '兑换信息',
key: 'redemption',
width: 190,
className: 'cdks-cell-start',
render: (_, cdk) =>
cdk.usedByPlayerId ? (
<div className="cdks-stack">
<span>{cdk.usedByPlayerName}</span>
<Typography.Text type="secondary" className="cdks-mono">
ID {cdk.usedByPlayerId} · {formatDateTime(cdk.usedAt)}
</Typography.Text>
</div>
) : (
'—'
),
},
{
title: '创建信息',
key: 'created',
width: 172,
className: 'cdks-cell-start',
render: (_, cdk) => (
<div className="cdks-stack">
<span className="cdks-time">{formatDateTime(cdk.createdAt)}</span>
<Typography.Text type="secondary">{cdk.createdBy}</Typography.Text>
</div>
),
},
{
title: '操作',
key: 'actions',
width: 112,
fixed: 'right',
className: 'cdks-cell-start cdks-actions-cell',
render: (_, cdk) => (
<Space size={2} className="cdks-row-actions">
<Tooltip title="查看详情">
<Button
type="text"
aria-label={`查看 CDK ${cdk.code}`}
icon={<EyeOutlined />}
onClick={() => setSelectedCdk(cdk)}
/>
</Tooltip>
<Tooltip title="复制 CDK">
<Button
type="text"
aria-label={`复制 CDK ${cdk.code}`}
icon={<CopyOutlined />}
onClick={() => void copyCode(cdk.code)}
/>
</Tooltip>
{(cdk.status === 'unused' || cdk.status === 'disabled') && (
<Tooltip title={cdk.status === 'disabled' ? '启用 CDK' : '停用 CDK'}>
<Button
type="text"
danger={cdk.status === 'unused'}
aria-label={`${cdk.status === 'disabled' ? '启用' : '停用'} CDK ${cdk.code}`}
icon={cdk.status === 'disabled' ? <CheckCircleOutlined /> : <StopOutlined />}
disabled={statusMutation.isPending}
onClick={() => setStatusTarget(cdk)}
/>
</Tooltip>
)}
</Space>
),
},
],
[copyCode, statusMutation.isPending],
);
return (
<ConfigProvider locale={zhCN} theme={antdThemeConfig}>
{messageContextHolder}
<Layout className={pageClass}>
<AppSidebar />
<Layout className="cdks-content-shell">
<WorkspaceTabs />
<Layout.Content className="cdks-content">
<Spin spinning={!fetched} delay={150} size="large" description="正在加载">
{!fetched ? (
<div className="cdks-loading-spacer" />
) : fetchError ? (
<Result
status="error"
title="CDK 数据加载失败"
subTitle={fetchError}
extra={
<Button type="primary" loading={loading} onClick={() => refresh()}>
重新加载
</Button>
}
/>
) : (
<div className="cdks-shell">
<Card size="small" className="cdks-summary-card">
<div className="cdks-metric-grid">
<SummaryMetric
label="CDK 总数"
value={summary.total}
icon={<KeyOutlined />}
accent="#1677ff"
/>
<SummaryMetric
label="未使用"
value={summary.unused}
icon={<GiftOutlined />}
accent="#52c41a"
/>
<SummaryMetric
label="已使用"
value={summary.used}
icon={<CheckCircleOutlined />}
accent="#13a8a8"
/>
<SummaryMetric
label="已过期"
value={summary.expired}
icon={<ClockCircleOutlined />}
accent="#8c8c8c"
/>
<SummaryMetric
label="已停用"
value={summary.disabled}
icon={<StopOutlined />}
accent="#ff4d4f"
/>
</div>
</Card>
<Card size="small" className="cdks-filter-card">
<div className="cdks-filter-bar">
<div className="cdks-filter-actions">
<Button
type="primary"
className="cdks-create-button"
icon={<PlusOutlined />}
onClick={openBatchModal}
>
生成 CDK
</Button>
<div className="cdks-search">
<Input
allowClear
aria-label="搜索 CDK"
placeholder="CDK、批次、兑换玩家"
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="搜索 CDK 数据"
icon={<SearchOutlined />}
onClick={() => updateFilters({ keyword: keywordInput.trim() })}
>
搜索
</Button>
</div>
<Select
className="cdks-filter-select"
aria-label="CDK 状态筛选"
value={filters.status}
options={statusOptions}
onChange={(status) => updateFilters({ status })}
/>
<Select
showSearch
optionFilterProp="label"
className="cdks-batch-select"
aria-label="CDK 批次筛选"
value={filters.batchId}
options={[{ value: 'all', label: '全部批次' }, ...batchOptions]}
onChange={(batchId) => updateFilters({ batchId })}
/>
<Button
className="cdks-filter-refresh"
aria-label="刷新 CDK 数据"
title="刷新 CDK 数据"
icon={<ReloadOutlined />}
loading={loading}
onClick={() => refresh()}
/>
</div>
</div>
</Card>
<Card size="small" className="cdks-table-card">
<div ref={tableViewportRef} className="cdks-table-viewport">
<Table<GameCdk>
rowKey="id"
size="middle"
columns={columns}
dataSource={cdks}
loading={loading}
scroll={{ x: 1454, y: tableScrollHeight }}
locale={{ emptyText: '暂无符合条件的 CDK' }}
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="CDK 详情"
placement="right"
size="min(92vw, 580px)"
open={selectedCdk !== null}
rootClassName={`cdks-detail-drawer ${isDark ? 'dark' : 'light'}`}
onClose={() => setSelectedCdk(null)}
>
{selectedCdk && (
<div className="cdks-detail">
<div className="cdks-detail-heading">
<span className="cdks-detail-icon">
<KeyOutlined />
</span>
<div>
<Typography.Title level={4} className="cdks-code">
{selectedCdk.code}
</Typography.Title>
<Typography.Text type="secondary">{selectedCdk.batchName}</Typography.Text>
</div>
<StatusTag status={selectedCdk.status} />
</div>
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="批次 ID">
<span className="cdks-mono">{selectedCdk.batchId}</span>
</Descriptions.Item>
<Descriptions.Item label="礼包名称">{selectedCdk.rewardName}</Descriptions.Item>
<Descriptions.Item label="奖励内容">
<div className="cdks-detail-rewards">
{selectedCdk.rewards.map((reward) => (
<div key={reward.itemId}>
<span>{reward.itemName}</span>
<span className="cdks-numeric">
{integerFormatter.format(reward.quantity)}
</span>
</div>
))}
</div>
</Descriptions.Item>
<Descriptions.Item label="适用范围">
{selectedCdk.scope === 'all' ? '全区服通用' : selectedCdk.serverName}
</Descriptions.Item>
<Descriptions.Item label="创建人">{selectedCdk.createdBy}</Descriptions.Item>
<Descriptions.Item label="创建时间">
{formatDateTime(selectedCdk.createdAt)}
</Descriptions.Item>
<Descriptions.Item label="有效期至">
{selectedCdk.expiresAt ? formatDateTime(selectedCdk.expiresAt) : '永久有效'}
</Descriptions.Item>
{selectedCdk.usedByPlayerId && (
<>
<Descriptions.Item label="兑换玩家">
{selectedCdk.usedByPlayerName} / {selectedCdk.usedByPlayerId}
</Descriptions.Item>
<Descriptions.Item label="兑换时间">
{formatDateTime(selectedCdk.usedAt)}
</Descriptions.Item>
</>
)}
</Descriptions>
</div>
)}
</Drawer>
<Modal
title="生成 CDK 批次"
open={batchModalOpen}
width={780}
okText="确认生成"
cancelText="取消"
okButtonProps={{ loading: createMutation.isPending }}
rootClassName="cdks-batch-modal"
destroyOnHidden
onOk={() => batchForm.submit()}
onCancel={closeBatchModal}
>
<Form<BatchFormValues>
form={batchForm}
layout="vertical"
requiredMark={false}
onFinish={submitBatch}
>
<div className="cdks-form-grid">
<Form.Item
name="batchName"
label="批次名称"
rules={[
{ required: true, whitespace: true, message: '请输入批次名称' },
{ max: 40, message: '批次名称不能超过 40 个字符' },
]}
>
<Input placeholder="例如:周年庆社区礼包" />
</Form.Item>
<Form.Item
name="prefix"
label="CDK 前缀"
normalize={(value: string) => value.toUpperCase()}
rules={[
{ required: true, whitespace: true, message: '请输入 CDK 前缀' },
{ min: 2, max: 12, message: '前缀长度为 2 至 12 个字符' },
{ pattern: /^[A-Za-z0-9]+$/, message: '仅允许字母和数字' },
]}
>
<Input placeholder="例如:ANNIV" />
</Form.Item>
<Form.Item
name="quantity"
label="生成数量"
rules={[{ required: true, message: '请输入生成数量' }]}
>
<InputNumber min={1} max={1000} precision={0} controls />
</Form.Item>
<Form.Item
name="rewardName"
label="礼包名称"
rules={[
{ required: true, whitespace: true, message: '请输入礼包名称' },
{ max: 40, message: '礼包名称不能超过 40 个字符' },
]}
>
<Input placeholder="例如:周年庆福利礼包" />
</Form.Item>
<Form.Item name="serverId" label="适用范围" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
options={[{ value: 'all', label: '全区服通用' }, ...serverOptions]}
/>
</Form.Item>
<Form.Item name="expiresAt" label="有效期至">
<DatePicker
allowClear
showTime
format="YYYY-MM-DD HH:mm:ss"
disabledDate={(current) => current.endOf('day').isBefore(dayjs().startOf('day'))}
/>
</Form.Item>
</div>
<Form.List name="rewards">
{(fields, { add, remove }) => (
<div className="cdks-reward-editor">
<div className="cdks-reward-editor-heading">
<span>奖励内容</span>
<Button
type="text"
icon={<PlusOutlined />}
disabled={fields.length >= 10}
onClick={() => add({ itemId: '', itemName: '', quantity: 1 })}
>
添加奖励
</Button>
</div>
{fields.map((field) => (
<div key={field.key} className="cdks-reward-row">
<Form.Item
{...field}
name={[field.name, 'itemId']}
label="物品 ID"
rules={[{ required: true, whitespace: true, message: '请输入物品 ID' }]}
>
<Input placeholder="diamond" />
</Form.Item>
<Form.Item
{...field}
name={[field.name, 'itemName']}
label="物品名称"
rules={[{ required: true, whitespace: true, message: '请输入物品名称' }]}
>
<Input placeholder="钻石" />
</Form.Item>
<Form.Item
{...field}
name={[field.name, 'quantity']}
label="数量"
rules={[{ required: true, message: '请输入数量' }]}
>
<InputNumber min={1} precision={0} controls />
</Form.Item>
<Tooltip title="删除奖励">
<Button
type="text"
danger
className="cdks-reward-remove"
aria-label={`删除奖励 ${field.name + 1}`}
icon={<DeleteOutlined />}
disabled={fields.length === 1}
onClick={() => remove(field.name)}
/>
</Tooltip>
</div>
))}
</div>
)}
</Form.List>
</Form>
</Modal>
<Modal
title={statusTarget?.status === 'disabled' ? '启用 CDK' : '停用 CDK'}
open={statusTarget !== null}
okText={statusTarget?.status === 'disabled' ? '确认启用' : '确认停用'}
cancelText="取消"
okButtonProps={{
danger: statusTarget?.status === 'unused',
loading: statusMutation.isPending,
}}
onOk={submitStatus}
onCancel={() => setStatusTarget(null)}
>
<Typography.Paragraph className="cdks-confirm-copy">
{statusTarget
? `CDK ${statusTarget.code} 将${statusTarget.status === 'disabled' ? '恢复使用' : '停止兑换'}。`
: ''}
</Typography.Paragraph>
</Modal>
</Layout>
</ConfigProvider>
);
}
+413
View File
@@ -0,0 +1,413 @@
.login-app {
--bg-page: #eef2ff;
--bg-card: rgba(255, 255, 255, 0.72);
--bg-card-solid: #ffffff;
--color-text: rgba(0, 0, 0, 0.88);
--color-text-subtle: rgba(0, 0, 0, 0.55);
--color-accent: #6366f1;
--color-border: rgba(255, 255, 255, 0.6);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.04), 0 18px 50px rgba(99, 102, 241, 0.18);
--blob-1: rgba(99, 102, 241, 0.55);
--blob-2: rgba(236, 72, 153, 0.5);
--blob-3: rgba(20, 184, 166, 0.4);
--blob-4: rgba(251, 191, 36, 0.35);
--blob-5: rgba(56, 189, 248, 0.4);
--grid-color: rgba(99, 102, 241, 0.06);
--vignette: radial-gradient(ellipse at center, transparent 30%, rgba(0, 0, 0, 0.05) 100%);
position: relative;
min-height: 100vh;
overflow: hidden;
background: linear-gradient(135deg, #eef2ff 0%, #fdf2f8 50%, #ecfeff 100%);
}
.login-app.is-dark {
--bg-page: #0d1117;
--bg-card: rgba(28, 30, 36, 0.55);
--bg-card-solid: #1c1e22;
--color-text: rgba(255, 255, 255, 0.92);
--color-text-subtle: rgba(255, 255, 255, 0.55);
--color-accent: #3b82f6;
--color-border: rgba(255, 255, 255, 0.1);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.4), 0 20px 60px rgba(59, 130, 246, 0.22);
--blob-1: rgba(96, 165, 250, 0.42);
--blob-2: rgba(59, 130, 246, 0.42);
--blob-3: rgba(37, 99, 235, 0.35);
--blob-4: rgba(251, 146, 60, 0.18);
--blob-5: rgba(129, 140, 248, 0.3);
--grid-color: rgba(255, 255, 255, 0.04);
--vignette: radial-gradient(ellipse at center, transparent 25%, rgba(0, 0, 0, 0.5) 100%);
background: radial-gradient(ellipse at 25% 20%, #152038 0%, #0d1117 60%);
}
.login-app.is-dark.is-ultra {
--bg-page: #000;
--bg-card: rgba(15, 17, 28, 0.6);
--bg-card-solid: #101013;
--color-border: rgba(255, 255, 255, 0.06);
--blob-1: rgba(99, 102, 241, 0.3);
--blob-2: rgba(236, 72, 153, 0.22);
--blob-3: rgba(34, 211, 238, 0.2);
--blob-4: rgba(251, 146, 60, 0.15);
--blob-5: rgba(168, 85, 247, 0.25);
--grid-color: rgba(255, 255, 255, 0.025);
--vignette: radial-gradient(ellipse at center, transparent 20%, rgba(0, 0, 0, 0.8) 100%);
background: radial-gradient(ellipse at 25% 20%, #0a0a1a 0%, #000 60%);
}
.login-app::before,
.login-app::after {
content: '';
position: absolute;
width: 70vw;
height: 70vw;
max-width: 900px;
max-height: 900px;
border-radius: 50%;
filter: blur(100px);
pointer-events: none;
z-index: 0;
will-change: transform;
}
.login-app::before {
top: -25vw;
left: -20vw;
background: radial-gradient(circle, var(--blob-1) 0%, transparent 65%);
animation: blob-drift-a 24s ease-in-out infinite alternate;
}
.login-app::after {
bottom: -25vw;
right: -20vw;
background: radial-gradient(circle, var(--blob-2) 0%, transparent 65%);
animation: blob-drift-b 30s ease-in-out infinite alternate;
}
.login-content::before {
content: '';
position: absolute;
top: 30%;
left: 50%;
width: 50vw;
height: 50vw;
max-width: 700px;
max-height: 700px;
border-radius: 50%;
background: radial-gradient(circle, var(--blob-3) 0%, transparent 65%);
filter: blur(100px);
pointer-events: none;
z-index: 0;
will-change: transform;
animation: blob-drift-c 36s ease-in-out infinite alternate;
}
.login-content::after {
content: '';
position: absolute;
top: 10%;
right: 15%;
width: 35vw;
height: 35vw;
max-width: 500px;
max-height: 500px;
border-radius: 50%;
background: radial-gradient(circle, var(--blob-4) 0%, transparent 65%);
filter: blur(90px);
pointer-events: none;
z-index: 0;
will-change: transform;
animation: blob-drift-d 28s ease-in-out infinite alternate;
}
.login-wrapper::before {
content: '';
position: absolute;
bottom: 5%;
left: 10%;
width: 35vw;
height: 35vw;
max-width: 500px;
max-height: 500px;
border-radius: 50%;
background: radial-gradient(circle, var(--blob-5) 0%, transparent 65%);
filter: blur(90px);
pointer-events: none;
z-index: 0;
will-change: transform;
animation: blob-drift-e 32s ease-in-out infinite alternate;
}
.login-wrapper::after {
content: '';
position: absolute;
inset: 0;
background-image:
linear-gradient(var(--grid-color) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-color) 1px, transparent 1px);
background-size: 48px 48px;
background-position: center;
-webkit-mask-image: radial-gradient(ellipse at center, black 30%, transparent 75%);
mask-image: radial-gradient(ellipse at center, black 30%, transparent 75%);
pointer-events: none;
z-index: 0;
}
@keyframes blob-drift-a {
0% {
transform: translate(0, 0) scale(1);
}
50% {
transform: translate(18vw, 10vh) scale(1.15);
}
100% {
transform: translate(34vw, 22vh) scale(1.25);
}
}
@keyframes blob-drift-b {
0% {
transform: translate(0, 0) scale(1);
}
50% {
transform: translate(-16vw, -10vh) scale(1.12);
}
100% {
transform: translate(-30vw, -22vh) scale(1.2);
}
}
@keyframes blob-drift-c {
0% {
transform: translate(-50%, -50%) scale(1);
}
50% {
transform: translate(-20%, -20%) scale(1.1);
}
100% {
transform: translate(-80%, -10%) scale(1.05);
}
}
@keyframes blob-drift-d {
0% {
transform: translate(0, 0) scale(0.9);
}
50% {
transform: translate(-12vw, 14vh) scale(1.05);
}
100% {
transform: translate(8vw, -8vh) scale(1.1);
}
}
@keyframes blob-drift-e {
0% {
transform: translate(0, 0) scale(1);
}
50% {
transform: translate(14vw, -8vh) scale(1.1);
}
100% {
transform: translate(-6vw, 12vh) scale(1.15);
}
}
@media (prefers-reduced-motion: reduce) {
.login-app::before,
.login-app::after,
.login-content::before,
.login-content::after,
.login-wrapper::before {
animation: none;
}
}
.login-app .ant-layout-content {
background: transparent;
}
.login-content {
position: relative;
}
.login-content > * {
position: relative;
z-index: 1;
}
.login-toolbar {
position: fixed;
top: 16px;
right: 16px;
z-index: 10;
display: inline-flex;
align-items: center;
gap: 8px;
}
.toolbar-btn {
width: 40px;
height: 40px;
min-width: 40px;
border-radius: 50%;
padding: 0;
}
.toolbar-btn .anticon {
font-size: 18px;
}
.login-wrapper {
position: relative;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
}
.login-loading {
text-align: center;
}
.login-card {
position: relative;
width: 100%;
max-width: 400px;
background: var(--bg-card);
border: 1px solid var(--color-border);
border-radius: 20px;
padding: 40px 32px 28px;
box-shadow: var(--shadow-card);
-webkit-backdrop-filter: blur(24px) saturate(180%);
backdrop-filter: blur(24px) saturate(180%);
z-index: 2;
}
.login-card::before {
content: '';
position: absolute;
inset: 0;
border-radius: 20px;
padding: 1px;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.5),
rgba(255, 255, 255, 0) 40%,
rgba(99, 102, 241, 0.25) 80%
);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.login-app.is-dark .login-card::before {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.15),
rgba(255, 255, 255, 0) 40%,
rgba(59, 130, 246, 0.4) 80%
);
}
@media (max-width: 480px) {
.login-card {
padding: 32px 20px 24px;
}
}
.brand {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.brand-name {
font-size: 28px;
font-weight: 700;
letter-spacing: 1.5px;
color: var(--color-text);
background: linear-gradient(135deg, var(--color-accent), #ec4899);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.login-app.is-dark .brand-name {
background: linear-gradient(135deg, #60a5fa, #1d4ed8);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.brand-accent {
display: block;
width: 40px;
height: 3px;
border-radius: 2px;
background: linear-gradient(90deg, var(--color-accent), #ec4899);
}
.login-app.is-dark .brand-accent {
background: linear-gradient(90deg, #60a5fa, #1d4ed8);
}
.welcome {
text-align: center;
color: var(--color-text);
font-size: 32px;
font-weight: 700;
line-height: 1.2;
min-height: 42px;
margin: 12px 0 28px;
letter-spacing: 0.3px;
}
.welcome b {
display: inline-block;
font-weight: inherit;
animation: headline-in 280ms ease both;
}
@keyframes headline-in {
0% {
opacity: 0;
transform: translateY(6px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.welcome b {
animation: none;
}
}
.login-form .ant-form-item-label > label {
color: var(--color-text);
font-weight: 500;
}
.login-form input.ant-input:-webkit-autofill,
.login-form input.ant-input:-webkit-autofill:hover,
.login-form input.ant-input:-webkit-autofill:focus {
-webkit-text-fill-color: var(--color-text) !important;
-webkit-box-shadow: 0 0 0 1000px var(--bg-card-solid) inset !important;
box-shadow: 0 0 0 1000px var(--bg-card-solid) inset !important;
transition:
background-color 9999s ease-in-out 0s,
color 9999s ease-in-out 0s;
}
.submit-row {
margin-bottom: 0;
}
+257
View File
@@ -0,0 +1,257 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
ConfigProvider,
Form,
Input,
Layout,
Menu,
Popover,
Space,
Spin,
message,
} from 'antd';
import {
KeyOutlined,
LockOutlined,
MoonFilled,
MoonOutlined,
SunOutlined,
TranslationOutlined,
UserOutlined,
} from '@ant-design/icons';
import { FormProvider, useForm } from 'react-hook-form';
import { HttpUtil, LanguageManager } from '@/utils';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { setMessageInstance } from '@/utils/messageBus';
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import { LoginFormSchema, TwoFactorCodeSchema, type LoginFormValues } from '@/schemas/login';
import './LoginPage.css';
const HEADLINE_INTERVAL_MS = 2000;
type LoginForm = LoginFormValues;
const basePath = window.ADMIN_UI_BASE_PATH || '';
export default function LoginPage() {
const { t } = useTranslation();
const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => {
setMessageInstance(messageApi);
}, [messageApi]);
const [fetched, setFetched] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [twoFactorEnable, setTwoFactorEnable] = useState(false);
const [headlineIndex, setHeadlineIndex] = useState(0);
const methods = useForm<LoginForm>({
defaultValues: { username: '', password: '', twoFactorCode: '' },
});
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
const headlineWords = useMemo(() => [t('pages.login.hello'), t('pages.login.title')], [t]);
useEffect(() => {
const timer = window.setInterval(() => {
setHeadlineIndex((i) => (i + 1) % headlineWords.length);
}, HEADLINE_INTERVAL_MS);
return () => window.clearInterval(timer);
}, [headlineWords.length]);
useEffect(() => {
let cancelled = false;
(async () => {
const msg = await HttpUtil.post('/getTwoFactorEnable');
if (cancelled) return;
if (msg.success) setTwoFactorEnable(!!msg.obj);
setFetched(true);
})();
return () => {
cancelled = true;
};
}, []);
const onSubmit = useCallback(async (values: LoginForm) => {
setSubmitting(true);
try {
const msg = await HttpUtil.post('/login', values);
if (msg.success) window.location.href = basePath + 'panel/';
} finally {
setSubmitting(false);
}
}, []);
const onLangChange = useCallback((next: string) => {
setLang(next);
LanguageManager.setLanguage(next);
}, []);
const cycleTheme = useCallback(() => {
pauseAnimationsUntilLeave('login-theme-cycle');
if (!isDark) {
toggleTheme();
if (isUltra) toggleUltra();
} else if (!isUltra) {
toggleUltra();
} else {
toggleUltra();
toggleTheme();
}
}, [isDark, isUltra, toggleTheme, toggleUltra]);
const pageClass = useMemo(() => {
const classes = ['login-app'];
if (isDark) classes.push('is-dark');
if (isUltra) classes.push('is-ultra');
return classes.join(' ');
}, [isDark, isUltra]);
const langMenuItems = useMemo(
() =>
(LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map(
(l) => ({
key: l.value,
label: (
<Space size={8}>
<span aria-hidden="true">{l.icon}</span>
<span>{l.name}</span>
</Space>
),
}),
),
[],
);
const themeIcon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />;
return (
<ConfigProvider theme={antdThemeConfig}>
{messageContextHolder}
<Layout className={pageClass}>
<Layout.Content className="login-content">
<div className="login-toolbar">
<Button
id="login-theme-cycle"
shape="circle"
size="large"
className="toolbar-btn"
aria-label={t('menu.theme')}
title={t('menu.theme')}
icon={themeIcon}
onClick={cycleTheme}
/>
<Popover
rootClassName={isDark ? 'dark' : 'light'}
placement="bottomRight"
trigger="click"
styles={{ content: { padding: 4 } }}
content={
<Menu
mode="vertical"
selectable
selectedKeys={[lang]}
items={langMenuItems}
onClick={({ key }) => onLangChange(key)}
style={{ border: 'none', minWidth: 160 }}
/>
}
>
<Button
shape="circle"
size="large"
className="toolbar-btn"
aria-label={t('pages.settings.language')}
icon={<TranslationOutlined />}
/>
</Popover>
</div>
<div className="login-wrapper">
{!fetched ? (
<div className="login-loading">
<Spin size="large" />
</div>
) : (
<div className="login-card">
<div className="brand">
<span className="brand-name">游戏运营后台</span>
<span className="brand-accent" aria-hidden="true" />
</div>
<h2 className="welcome">
<b key={headlineIndex}>{headlineWords[headlineIndex]}</b>
</h2>
<FormProvider {...methods}>
<Form
layout="vertical"
className="login-form"
onFinish={methods.handleSubmit(onSubmit)}
>
<FormField
name="username"
label={t('username')}
rules={{ validate: rhfZodValidate(LoginFormSchema.shape.username) }}
>
<Input
prefix={<UserOutlined />}
autoComplete="username"
size="large"
placeholder={t('username')}
autoFocus
/>
</FormField>
<FormField
name="password"
label={t('password')}
rules={{ validate: rhfZodValidate(LoginFormSchema.shape.password) }}
>
<Input.Password
prefix={<LockOutlined />}
autoComplete="current-password"
size="large"
placeholder={t('password')}
/>
</FormField>
{twoFactorEnable && (
<FormField
name="twoFactorCode"
label={t('twoFactorCode')}
rules={{ validate: rhfZodValidate(TwoFactorCodeSchema) }}
>
<Input
prefix={<KeyOutlined />}
autoComplete="one-time-code"
size="large"
placeholder={t('twoFactorCode')}
/>
</FormField>
)}
<Form.Item className="submit-row">
<Button
type="primary"
htmlType="submit"
loading={submitting}
size="large"
block
>
{t('login')}
</Button>
</Form.Item>
</Form>
</FormProvider>
</div>
)}
</div>
</Layout.Content>
</Layout>
</ConfigProvider>
);
}
+510
View File
@@ -0,0 +1,510 @@
.game-orders-page {
--orders-page-bg: #e6e8ec;
--orders-card-bg: #ffffff;
--orders-border: var(--ant-color-border-secondary);
height: 100vh;
min-height: 0;
overflow: hidden;
background: var(--orders-page-bg);
}
.game-orders-page.is-dark {
--orders-page-bg: #1a1b1f;
--orders-card-bg: #23252b;
}
.game-orders-page.is-dark.is-ultra {
--orders-page-bg: #000000;
--orders-card-bg: #101013;
}
.game-orders-page .ant-layout,
.game-orders-page .ant-layout-content,
.orders-content-shell {
background: transparent;
}
.orders-content {
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow: hidden;
padding: 20px;
}
.orders-content-shell {
height: 100%;
min-height: 0;
overflow: hidden;
}
.orders-content > .ant-spin,
.orders-content > .ant-spin > .ant-spin-container {
width: 100%;
min-height: 0;
}
.orders-content > .ant-spin {
display: flex;
flex: 1 1 auto;
}
.orders-content > .ant-spin > .ant-spin-container {
height: 100%;
}
.orders-loading-spacer {
height: 100%;
min-height: 0;
}
.orders-shell {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
height: 100%;
min-height: 0;
}
.orders-summary-card,
.orders-filter-card {
flex: 0 0 auto;
}
.orders-summary-card,
.orders-filter-card,
.orders-table-card {
overflow: hidden;
border-radius: 8px;
background: var(--orders-card-bg);
}
.orders-table-card {
display: flex;
flex: 1 1 0;
min-height: 0;
}
.orders-summary-card .ant-card-body {
padding: 0;
}
.orders-metric-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
background: var(--orders-card-bg);
}
.orders-metric {
min-width: 0;
padding: 16px 20px;
background: var(--orders-card-bg);
}
.orders-metric-heading {
overflow: hidden;
color: var(--ant-color-text-secondary);
font-size: 12px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.orders-metric-value-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
margin-top: 8px;
}
.orders-metric-icon {
display: inline-flex;
flex: 0 0 auto;
color: var(--metric-accent);
font-size: 16px;
}
.orders-metric-value {
min-width: 0;
overflow-wrap: anywhere;
color: var(--ant-color-text);
font-size: 22px;
font-weight: 500;
font-variant-numeric: tabular-nums;
line-height: 1;
white-space: nowrap;
}
.orders-filter-card .ant-card-body,
.orders-table-card .ant-card-body {
min-width: 0;
padding: 0;
}
.orders-table-card .ant-card-body {
display: flex;
flex: 1 1 auto;
min-height: 0;
}
.orders-table-viewport {
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
.orders-filter-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
}
.orders-filter-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
min-width: 0;
margin-inline-start: auto;
}
.orders-search {
display: flex;
width: min(410px, 38vw);
}
.orders-search .ant-input-affix-wrapper {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
.orders-search .ant-btn {
border-start-start-radius: 0;
border-end-start-radius: 0;
}
.orders-filter-select {
width: 126px;
}
.orders-channel-select {
width: 138px;
}
.orders-server-select {
width: 150px;
}
.orders-filter-refresh {
flex: 0 0 auto;
}
.orders-table-card .ant-table-wrapper {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.orders-table-card .ant-table-wrapper > .ant-spin,
.orders-table-card .ant-table-wrapper > .ant-spin > .ant-spin-container,
.orders-table-card .ant-table {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
}
.orders-table-card .ant-table-container {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
}
.orders-table-card .ant-table-header {
flex: 0 0 auto;
}
.orders-table-card .ant-table-body {
flex: 1 1 auto;
min-height: 0;
max-height: none !important;
overflow-y: auto !important;
}
.orders-table-card .ant-table-thead .ant-table-cell {
text-align: left !important;
vertical-align: middle;
}
.orders-table-card .ant-table-tbody > tr > .ant-table-cell {
text-align: left;
vertical-align: middle;
}
.orders-table-card .ant-table-tbody > tr > .orders-cell-start {
text-align: left;
}
.orders-table-card .ant-pagination {
flex: 0 0 auto;
margin-block: 10px;
padding-inline: 16px;
}
.orders-identity,
.orders-stack {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.orders-identity .ant-typography,
.orders-stack .ant-typography {
overflow: hidden;
margin: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.orders-stack .ant-typography-secondary {
font-size: 12px;
}
.orders-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.orders-numeric,
.orders-money,
.orders-time-range {
font-variant-numeric: tabular-nums;
}
.orders-money {
font-weight: 500;
}
.orders-time-range {
white-space: nowrap;
}
.orders-row-actions {
min-width: 0;
justify-content: flex-start;
}
.orders-row-actions .ant-btn {
width: 32px;
height: 32px;
}
.orders-table-card .orders-actions-cell {
padding-inline: 8px !important;
}
.orders-detail {
display: flex;
flex-direction: column;
gap: 18px;
}
.orders-detail-heading {
display: grid;
grid-template-columns: 42px minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
}
.orders-detail-heading .ant-typography {
margin: 0;
}
.orders-detail-heading h4.ant-typography {
overflow-wrap: anywhere;
font-size: 17px;
}
.orders-detail-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 6px;
background: var(--ant-color-primary-bg);
color: var(--ant-color-primary);
font-size: 20px;
}
.orders-detail .ant-descriptions-item-label {
width: 116px;
white-space: nowrap;
}
.orders-detail .ant-descriptions-item-content {
overflow-wrap: anywhere;
}
.orders-detail-items {
display: flex;
flex-direction: column;
gap: 6px;
}
.orders-detail-items > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.orders-detail-drawer .ant-drawer-content-wrapper {
height: 100dvh !important;
max-height: 100dvh;
}
.orders-detail-drawer .ant-drawer-content {
max-height: 100dvh;
overflow: hidden;
}
.orders-detail-drawer .ant-drawer-body {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
.orders-refund-modal .ant-modal-body {
max-height: calc(100dvh - 220px);
overflow-y: auto;
overscroll-behavior: contain;
}
.orders-confirm-copy {
margin-bottom: 0;
}
@media (max-width: 1180px) {
.orders-filter-bar {
flex-wrap: wrap;
}
.orders-filter-actions {
width: 100%;
margin-inline-start: 0;
flex-wrap: wrap;
}
.orders-search {
flex: 1 1 300px;
width: min(100%, 450px);
}
}
@media (max-width: 900px) {
.orders-metric {
padding-inline: 14px;
}
.orders-metric-value-row {
gap: 6px;
}
.orders-metric-value {
font-size: 20px;
}
}
@media (max-width: 768px) {
.orders-content {
padding: 8px;
}
.orders-shell {
gap: 8px;
}
.orders-metric {
padding: 12px 8px;
}
.orders-metric-heading {
font-size: 11px;
}
.orders-metric-value-row {
gap: 5px;
margin-top: 7px;
}
.orders-metric-icon {
font-size: 14px;
}
.orders-metric-value {
font-size: 18px;
}
.orders-filter-bar {
padding: 10px 12px;
}
.orders-filter-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)) 32px;
}
.orders-search {
grid-column: 1 / -1;
width: 100%;
}
.orders-filter-select,
.orders-channel-select,
.orders-server-select {
width: 100%;
}
.orders-filter-refresh {
width: 32px;
padding-inline: 0;
}
.orders-table-card .ant-pagination {
padding-inline: 12px;
}
.orders-table-card .ant-tag {
margin-inline-end: 0;
padding-inline: 4px;
font-size: 12px;
}
.orders-detail-heading {
grid-template-columns: 38px minmax(0, 1fr);
}
.orders-detail-heading > .ant-tag {
grid-column: 2;
justify-self: start;
}
.orders-detail-icon {
width: 38px;
height: 38px;
}
.orders-detail .ant-descriptions-item-label {
width: 96px;
}
}
+696
View File
@@ -0,0 +1,696 @@
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,
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 {
ClockCircleOutlined,
DollarOutlined,
EyeOutlined,
GiftOutlined,
ReloadOutlined,
RollbackOutlined,
SearchOutlined,
ShoppingCartOutlined,
SyncOutlined,
} from '@ant-design/icons';
import {
useDeliverGameOrderMutation,
useGameOrdersQuery,
useRefundGameOrderMutation,
} from '@/api/queries/useGameOrdersQuery';
import type { GameOrderFilters } from '@/api/queries/useGameOrdersQuery';
import { useTheme } from '@/hooks/useTheme';
import AppSidebar from '@/layouts/AppSidebar';
import WorkspaceTabs from '@/layouts/WorkspaceTabs';
import type { GameOrder, GameOrderChannel, GameOrderStatus } from '@/schemas/gameOrder';
import { setMessageInstance } from '@/utils/messageBus';
import './OrdersPage.css';
const integerFormatter = new Intl.NumberFormat('zh-CN', {
useGrouping: false,
maximumFractionDigits: 0,
});
const moneyFormatter = new Intl.NumberFormat('zh-CN', {
useGrouping: false,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const DEFAULT_TABLE_SCROLL_HEIGHT = 240;
const MIN_TABLE_SCROLL_HEIGHT = 96;
const statusMeta: Record<GameOrderStatus, { color: string; label: string }> = {
pending: { color: 'warning', label: '待支付' },
paid: { color: 'processing', label: '待发货' },
delivered: { color: 'success', label: '已完成' },
refunding: { color: 'orange', label: '退款中' },
refunded: { color: 'purple', label: '已退款' },
closed: { color: 'default', label: '已关闭' },
};
const channelMeta: Record<GameOrderChannel, { color: string; label: string }> = {
official: { color: 'blue', label: '官方支付' },
app_store: { color: 'geekblue', label: 'App Store' },
google_play: { color: 'green', label: 'Google Play' },
steam: { color: 'cyan', label: 'Steam' },
third_party: { color: 'orange', label: '第三方支付' },
};
const statusOptions = [
{ value: 'all', label: '全部状态' },
{ value: 'pending', label: '待支付' },
{ value: 'paid', label: '待发货' },
{ value: 'delivered', label: '已完成' },
{ value: 'refunding', label: '退款中' },
{ value: 'refunded', label: '已退款' },
{ value: 'closed', label: '已关闭' },
] satisfies Array<{ value: GameOrderStatus | 'all'; label: string }>;
const channelOptions = [
{ value: 'all', label: '全部渠道' },
{ value: 'official', label: '官方支付' },
{ value: 'app_store', label: 'App Store' },
{ value: 'google_play', label: 'Google Play' },
{ value: 'steam', label: 'Steam' },
{ value: 'third_party', label: '第三方支付' },
] satisfies Array<{ value: GameOrderChannel | 'all'; label: string }>;
interface RefundFormValues {
reason: string;
}
function formatDateTime(value: string | null) {
return value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '—';
}
function formatMoney(amount: number, currency: GameOrder['currency']) {
return `${currency === 'CNY' ? '¥' : '$'}${moneyFormatter.format(amount)}`;
}
function StatusTag({ status }: { status: GameOrderStatus }) {
const meta = statusMeta[status];
return (
<Tag color={meta.color} variant="filled">
{meta.label}
</Tag>
);
}
function ChannelTag({ channel }: { channel: GameOrderChannel }) {
const meta = channelMeta[channel];
return <Tag color={meta.color}>{meta.label}</Tag>;
}
function SummaryMetric({
label,
value,
icon,
accent,
}: {
label: string;
value: string;
icon: ReactNode;
accent: string;
}) {
return (
<div className="orders-metric" style={{ '--metric-accent': accent } as CSSProperties}>
<div className="orders-metric-heading">{label}</div>
<div className="orders-metric-value-row">
<span className="orders-metric-icon">{icon}</span>
<span className="orders-metric-value">{value}</span>
</div>
</div>
);
}
function itemSummary(order: GameOrder) {
return order.items
.map((item) => `${item.itemName} ${integerFormatter.format(item.quantity)}`)
.join(' / ');
}
export default function OrdersPage() {
const { isDark, isUltra, antdThemeConfig } = useTheme();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => {
setMessageInstance(messageApi);
}, [messageApi]);
const [filters, setFilters] = useState<GameOrderFilters>({
page: 1,
pageSize: 20,
keyword: '',
status: 'all',
channel: 'all',
serverId: 'all',
});
const [keywordInput, setKeywordInput] = useState('');
const [selectedOrder, setSelectedOrder] = useState<GameOrder | null>(null);
const [deliveryTarget, setDeliveryTarget] = useState<GameOrder | null>(null);
const [refundTarget, setRefundTarget] = useState<GameOrder | null>(null);
const tableViewportRef = useRef<HTMLDivElement>(null);
const [tableScrollHeight, setTableScrollHeight] = useState(DEFAULT_TABLE_SCROLL_HEIGHT);
const [refundForm] = Form.useForm<RefundFormValues>();
const deliveryMutation = useDeliverGameOrderMutation();
const refundMutation = useRefundGameOrderMutation();
const { orders, total, summary, serverOptions, loading, fetched, fetchError, refresh } =
useGameOrdersQuery(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-orders-page'];
if (isDark) classes.push('is-dark');
if (isUltra) classes.push('is-ultra');
return classes.join(' ');
}, [isDark, isUltra]);
const updateFilters = useCallback((patch: Partial<GameOrderFilters>) => {
setFilters((current) => ({ ...current, ...patch, page: patch.page ?? 1 }));
}, []);
const openRefund = useCallback(
(order: GameOrder) => {
refundForm.setFieldsValue({ reason: '' });
setRefundTarget(order);
},
[refundForm],
);
const closeRefund = useCallback(() => {
setRefundTarget(null);
refundForm.resetFields();
}, [refundForm]);
const submitDelivery = async () => {
if (!deliveryTarget) return;
try {
const updated = await deliveryMutation.mutateAsync({
orderId: deliveryTarget.id,
operatorNote: '运营后台手动补发',
});
setSelectedOrder((current) => (current?.id === updated.id ? updated : current));
messageApi.success('订单商品已补发');
setDeliveryTarget(null);
} catch {
// The HTTP layer reports the server error and keeps the confirmation open.
}
};
const submitRefund = async (values: RefundFormValues) => {
if (!refundTarget) return;
try {
const updated = await refundMutation.mutateAsync({
orderId: refundTarget.id,
reason: values.reason.trim(),
});
setSelectedOrder((current) => (current?.id === updated.id ? updated : current));
messageApi.success('退款申请已提交');
closeRefund();
} catch {
// The HTTP layer reports the server error and keeps the form open for correction.
}
};
const columns = useMemo<TableProps<GameOrder>['columns']>(
() => [
{
title: '订单号',
key: 'order',
width: 218,
className: 'orders-cell-start',
render: (_, order) => (
<div className="orders-identity">
<Typography.Text strong className="orders-mono">
{order.id}
</Typography.Text>
<Typography.Text type="secondary" className="orders-mono">
{order.providerOrderId}
</Typography.Text>
</div>
),
},
{
title: '玩家账号',
key: 'player',
width: 174,
className: 'orders-cell-start',
render: (_, order) => (
<div className="orders-stack">
<span>{order.playerName}</span>
<Typography.Text type="secondary" className="orders-mono">
{order.playerId} / {order.accountName}
</Typography.Text>
</div>
),
},
{
title: '区服',
dataIndex: 'serverName',
key: 'serverName',
width: 132,
className: 'orders-cell-start',
},
{
title: '商品内容',
key: 'product',
width: 220,
className: 'orders-cell-start',
render: (_, order) => (
<div className="orders-stack">
<span>
{order.productName}
{order.quantity > 1 ? ` × ${integerFormatter.format(order.quantity)}` : ''}
</span>
<Typography.Text type="secondary" ellipsis={{ tooltip: itemSummary(order) }}>
{itemSummary(order)}
</Typography.Text>
</div>
),
},
{
title: '实付金额',
key: 'amount',
width: 108,
className: 'orders-cell-start',
render: (_, order) => (
<span className="orders-money">{formatMoney(order.amount, order.currency)}</span>
),
},
{
title: '支付渠道',
dataIndex: 'channel',
key: 'channel',
width: 124,
className: 'orders-cell-start',
render: (channel: GameOrderChannel) => <ChannelTag channel={channel} />,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 92,
className: 'orders-cell-start',
render: (status: GameOrderStatus) => <StatusTag status={status} />,
},
{
title: '订单时间',
key: 'createdAt',
width: 190,
className: 'orders-cell-start',
render: (_, order) => (
<div className="orders-stack orders-time-range">
<span>创建 {formatDateTime(order.createdAt)}</span>
<Typography.Text type="secondary">支付 {formatDateTime(order.paidAt)}</Typography.Text>
</div>
),
},
{
title: '操作',
key: 'actions',
width: 136,
fixed: 'right',
className: 'orders-cell-start orders-actions-cell',
render: (_, order) => (
<Space size={2} className="orders-row-actions">
<Tooltip title="查看详情">
<Button
type="text"
aria-label={`查看订单 ${order.id}`}
icon={<EyeOutlined />}
onClick={() => setSelectedOrder(order)}
/>
</Tooltip>
{order.status === 'paid' && (
<Tooltip title="补发商品">
<Button
type="text"
aria-label={`补发订单 ${order.id}`}
icon={<GiftOutlined />}
disabled={deliveryMutation.isPending}
onClick={() => setDeliveryTarget(order)}
/>
</Tooltip>
)}
{(order.status === 'paid' || order.status === 'delivered') && (
<Tooltip title="发起退款">
<Button
type="text"
danger
aria-label={`退款订单 ${order.id}`}
icon={<RollbackOutlined />}
disabled={refundMutation.isPending}
onClick={() => openRefund(order)}
/>
</Tooltip>
)}
</Space>
),
},
],
[deliveryMutation.isPending, openRefund, refundMutation.isPending],
);
return (
<ConfigProvider locale={zhCN} theme={antdThemeConfig}>
{messageContextHolder}
<Layout className={pageClass}>
<AppSidebar />
<Layout className="orders-content-shell">
<WorkspaceTabs />
<Layout.Content className="orders-content">
<Spin spinning={!fetched} delay={150} size="large" description="正在加载">
{!fetched ? (
<div className="orders-loading-spacer" />
) : fetchError ? (
<Result
status="error"
title="订单数据加载失败"
subTitle={fetchError}
extra={
<Button type="primary" loading={loading} onClick={() => refresh()}>
重新加载
</Button>
}
/>
) : (
<div className="orders-shell">
<Card size="small" className="orders-summary-card">
<div className="orders-metric-grid">
<SummaryMetric
label="订单总数"
value={integerFormatter.format(summary.total)}
icon={<ShoppingCartOutlined />}
accent="#1677ff"
/>
<SummaryMetric
label="实付金额(CNY)"
value={`¥${moneyFormatter.format(summary.paidAmount)}`}
icon={<DollarOutlined />}
accent="#52c41a"
/>
<SummaryMetric
label="待支付"
value={integerFormatter.format(summary.pending)}
icon={<ClockCircleOutlined />}
accent="#faad14"
/>
<SummaryMetric
label="待发货"
value={integerFormatter.format(summary.awaitingDelivery)}
icon={<GiftOutlined />}
accent="#13a8a8"
/>
<SummaryMetric
label="退款中"
value={integerFormatter.format(summary.refunding)}
icon={<SyncOutlined />}
accent="#ff4d4f"
/>
</div>
</Card>
<Card size="small" className="orders-filter-card">
<div className="orders-filter-bar">
<div className="orders-filter-actions">
<div className="orders-search">
<Input
allowClear
aria-label="搜索订单"
placeholder="订单号、玩家、账号、商品"
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="orders-filter-select"
aria-label="订单状态筛选"
value={filters.status}
options={statusOptions}
onChange={(status) => updateFilters({ status })}
/>
<Select
className="orders-channel-select"
aria-label="支付渠道筛选"
value={filters.channel}
options={channelOptions}
onChange={(channel) => updateFilters({ channel })}
/>
<Select
showSearch
optionFilterProp="label"
className="orders-server-select"
aria-label="订单区服筛选"
value={filters.serverId}
options={[{ value: 'all', label: '全部区服' }, ...serverOptions]}
onChange={(serverId) => updateFilters({ serverId })}
/>
<Button
className="orders-filter-refresh"
aria-label="刷新订单数据"
title="刷新订单数据"
icon={<ReloadOutlined />}
loading={loading}
onClick={() => refresh()}
/>
</div>
</div>
</Card>
<Card size="small" className="orders-table-card">
<div ref={tableViewportRef} className="orders-table-viewport">
<Table<GameOrder>
rowKey="id"
size="middle"
columns={columns}
dataSource={orders}
loading={loading}
scroll={{ x: 1394, 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, 640px)"
open={selectedOrder !== null}
rootClassName={`orders-detail-drawer ${isDark ? 'dark' : 'light'}`}
onClose={() => setSelectedOrder(null)}
>
{selectedOrder && (
<div className="orders-detail">
<div className="orders-detail-heading">
<span className="orders-detail-icon">
<ShoppingCartOutlined />
</span>
<div>
<Typography.Title level={4} className="orders-mono">
{selectedOrder.id}
</Typography.Title>
<Typography.Text type="secondary">{selectedOrder.productName}</Typography.Text>
</div>
<StatusTag status={selectedOrder.status} />
</div>
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="渠道订单号">
<span className="orders-mono">{selectedOrder.providerOrderId}</span>
</Descriptions.Item>
<Descriptions.Item label="玩家">
{selectedOrder.playerName} / {selectedOrder.playerId}
</Descriptions.Item>
<Descriptions.Item label="账号">
{selectedOrder.accountName} / {selectedOrder.accountId}
</Descriptions.Item>
<Descriptions.Item label="区服">{selectedOrder.serverName}</Descriptions.Item>
<Descriptions.Item label="支付渠道">
<ChannelTag channel={selectedOrder.channel} />
</Descriptions.Item>
<Descriptions.Item label="实付金额">
<span className="orders-money">
{formatMoney(selectedOrder.amount, selectedOrder.currency)}
</span>
</Descriptions.Item>
<Descriptions.Item label="商品内容">
<div className="orders-detail-items">
{selectedOrder.items.map((item) => (
<div key={item.itemId}>
<span>{item.itemName}</span>
<span className="orders-numeric">
{integerFormatter.format(item.quantity)}
</span>
</div>
))}
</div>
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{formatDateTime(selectedOrder.createdAt)}
</Descriptions.Item>
<Descriptions.Item label="支付时间">
{formatDateTime(selectedOrder.paidAt)}
</Descriptions.Item>
<Descriptions.Item label="发货时间">
{formatDateTime(selectedOrder.deliveredAt)}
</Descriptions.Item>
<Descriptions.Item label="退款时间">
{formatDateTime(selectedOrder.refundedAt)}
</Descriptions.Item>
<Descriptions.Item label="下单 IP">{selectedOrder.clientIp}</Descriptions.Item>
{selectedOrder.refundReason && (
<Descriptions.Item label="退款原因">
{selectedOrder.refundReason}
</Descriptions.Item>
)}
{selectedOrder.operatorNote && (
<Descriptions.Item label="运营备注">
{selectedOrder.operatorNote}
</Descriptions.Item>
)}
</Descriptions>
</div>
)}
</Drawer>
<Modal
title="补发订单商品"
open={deliveryTarget !== null}
okText="确认补发"
cancelText="取消"
okButtonProps={{ loading: deliveryMutation.isPending }}
onOk={submitDelivery}
onCancel={() => setDeliveryTarget(null)}
>
<Typography.Paragraph className="orders-confirm-copy">
{deliveryTarget
? `订单 ${deliveryTarget.id} 的“${deliveryTarget.productName}”将重新发放给玩家 ${deliveryTarget.playerName}。`
: ''}
</Typography.Paragraph>
</Modal>
<Modal
title="发起订单退款"
open={refundTarget !== null}
rootClassName="orders-refund-modal"
okText="提交退款"
cancelText="取消"
okButtonProps={{ danger: true, loading: refundMutation.isPending }}
destroyOnHidden
onOk={() => refundForm.submit()}
onCancel={closeRefund}
>
<Form<RefundFormValues>
form={refundForm}
layout="vertical"
requiredMark={false}
onFinish={submitRefund}
>
<Typography.Paragraph>
{refundTarget
? `订单 ${refundTarget.id},退款金额 ${formatMoney(
refundTarget.amount,
refundTarget.currency,
)}。`
: ''}
</Typography.Paragraph>
<Form.Item
name="reason"
label="退款原因"
rules={[
{ required: true, whitespace: true, message: '请输入退款原因' },
{ min: 2, max: 200, message: '退款原因长度为 2 至 200 个字符' },
]}
>
<Input.TextArea rows={4} maxLength={200} showCount placeholder="请输入审核依据" />
</Form.Item>
</Form>
</Modal>
</Layout>
</ConfigProvider>
);
}
+345
View File
@@ -0,0 +1,345 @@
.game-overview-page {
--overview-page-bg: #e6e8ec;
--overview-card-bg: #ffffff;
--overview-border: var(--ant-color-border-secondary);
min-height: 100vh;
background: var(--overview-page-bg);
}
.game-overview-page.is-dark {
--overview-page-bg: #1a1b1f;
--overview-card-bg: #23252b;
}
.game-overview-page.is-dark.is-ultra {
--overview-page-bg: #000000;
--overview-card-bg: #101013;
}
.game-overview-page .ant-layout,
.game-overview-page .ant-layout-content,
.overview-content-shell {
background: transparent;
}
.overview-content {
min-width: 0;
padding: 20px;
}
.overview-loading-spacer {
min-height: calc(100vh - 80px);
}
.overview-shell {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
}
.overview-context {
min-width: 0;
font-size: 12px;
}
.overview-filters {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-shrink: 0;
}
.overview-server-select {
width: 156px;
}
.overview-range {
min-width: 224px;
}
.overview-summary-card,
.overview-panel-card {
overflow: hidden;
border-radius: 8px;
background: var(--overview-card-bg);
}
.overview-summary-card .ant-card-body {
padding: 0;
}
.overview-summary-card .ant-card-head {
min-height: 46px;
padding: 0 12px 0 16px;
}
.overview-summary-card .ant-card-head-wrapper {
gap: 16px;
}
.overview-summary-card .ant-card-head-title,
.overview-summary-card .ant-card-extra {
padding-block: 7px;
}
.overview-summary-card .ant-card-head-title {
flex: 1 1 190px;
overflow: visible;
white-space: normal;
}
.overview-metric-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 1px;
background: var(--overview-border);
}
.overview-metric {
min-width: 0;
padding: 16px;
background: var(--overview-card-bg);
}
.overview-metric-heading {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
font-size: 13px;
color: var(--ant-color-text-secondary);
}
.overview-metric-icon {
display: inline-flex;
color: var(--metric-accent);
font-size: 15px;
}
.overview-metric-value {
margin-top: 10px;
overflow-wrap: anywhere;
font-size: 28px;
font-weight: 600;
line-height: 1.15;
color: var(--ant-color-text);
font-variant-numeric: tabular-nums;
}
.overview-metric-change {
display: flex;
align-items: center;
gap: 4px;
margin-top: 8px;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.overview-metric-change.is-positive {
color: var(--ant-color-success);
}
.overview-metric-change.is-negative {
color: var(--ant-color-error);
}
.overview-metric-period {
margin-inline-start: 2px;
color: var(--ant-color-text-tertiary);
}
.overview-primary-grid,
.overview-secondary-grid {
display: grid;
gap: 12px;
min-width: 0;
}
.overview-primary-grid {
grid-template-columns: minmax(0, 2fr) minmax(340px, 1fr);
}
.overview-secondary-grid {
grid-template-columns: minmax(0, 2fr) minmax(320px, 1fr);
}
.overview-panel-card .ant-card-head {
min-height: 46px;
padding: 0 16px;
}
.overview-panel-card .ant-card-head-title {
font-size: 14px;
font-weight: 600;
}
.overview-panel-card .ant-card-body {
min-width: 0;
padding: 14px 16px 16px;
}
.overview-card-title {
display: inline-flex;
align-items: center;
gap: 7px;
}
.overview-card-extra {
color: var(--ant-color-text-secondary);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.overview-server-card .ant-card-body {
padding: 0;
}
.overview-server-name {
display: flex;
flex-direction: column;
gap: 2px;
}
.overview-server-name .ant-typography-secondary {
font-size: 12px;
}
.overview-capacity {
min-width: 150px;
font-variant-numeric: tabular-nums;
}
.overview-capacity .ant-progress {
margin-top: 3px;
margin-bottom: 0;
}
.overview-capacity-total {
color: var(--ant-color-text-tertiary);
}
.overview-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.overview-alert-card .ant-card-body {
padding: 0 16px;
}
.overview-alert-list .ant-list-item {
padding: 14px 0;
}
.overview-alert-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-width: 0;
}
.overview-alert-mark {
width: 3px;
height: 32px;
flex: 0 0 auto;
border-radius: 2px;
}
.overview-alert-copy {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.overview-alert-copy .ant-typography {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.overview-alert-copy .ant-typography-secondary {
font-size: 12px;
}
@media (max-width: 1240px) {
.overview-metric-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.overview-primary-grid,
.overview-secondary-grid {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 960px) {
.overview-summary-card .ant-card-head-wrapper {
align-items: stretch;
flex-direction: column;
gap: 8px;
padding-block: 10px;
}
.overview-summary-card .ant-card-head-title,
.overview-summary-card .ant-card-extra {
flex: 0 0 auto;
width: 100%;
padding: 0;
margin-inline-start: 0;
}
.overview-filters {
justify-content: flex-start;
}
}
@media (max-width: 768px) {
.overview-content {
padding: 8px;
}
.overview-shell {
gap: 8px;
}
.overview-filters {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
width: 100%;
}
.overview-server-select {
grid-column: 1 / -1;
width: 100%;
}
.overview-range {
min-width: 0;
}
.overview-metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.overview-metric {
padding: 14px;
}
.overview-metric-value {
font-size: 24px;
}
.overview-panel-card .ant-card-head,
.overview-panel-card .ant-card-body {
padding-inline: 12px;
}
.overview-server-card .ant-card-body {
padding: 0;
}
}
+435
View File
@@ -0,0 +1,435 @@
import { useMemo, useState } from 'react';
import type { CSSProperties, ReactNode } from 'react';
import dayjs from 'dayjs';
import {
Badge,
Button,
Card,
ConfigProvider,
Empty,
Layout,
List,
Progress,
Result,
Segmented,
Select,
Spin,
Table,
Tag,
Typography,
} from 'antd';
import type { TableProps } from 'antd';
import {
ArrowDownOutlined,
ArrowUpOutlined,
CloudServerOutlined,
DollarOutlined,
ReloadOutlined,
ShoppingCartOutlined,
TeamOutlined,
ThunderboltOutlined,
UserAddOutlined,
UserOutlined,
} from '@ant-design/icons';
import { useGameOverviewQuery } from '@/api/queries/useGameOverviewQuery';
import { Sparkline } from '@/components/viz';
import { useTheme } from '@/hooks/useTheme';
import AppSidebar from '@/layouts/AppSidebar';
import WorkspaceTabs from '@/layouts/WorkspaceTabs';
import type { GameOverview, GameServerHealth, OverviewAlert } from '@/schemas/gameOverview';
import type { OverviewRange } from '@/schemas/gameOverview';
import './OverviewPage.css';
type MetricValue = GameOverview['metrics'][keyof GameOverview['metrics']];
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 rangeOptions = [
{ label: '今日', value: 'today' },
{ label: '近 7 日', value: '7d' },
{ label: '近 30 日', value: '30d' },
];
const statusMeta: Record<GameServerHealth['status'], { color: string; label: string }> = {
online: { color: 'success', label: '运行中' },
maintenance: { color: 'warning', label: '维护中' },
offline: { color: 'error', label: '离线' },
};
const alertColors: Record<OverviewAlert['level'], string> = {
error: '#ff4d4f',
warning: '#faad14',
info: '#1677ff',
};
function MetricCell({
label,
metric,
icon,
accent,
formatter = (value) => integerFormatter.format(value),
}: {
label: string;
metric: MetricValue;
icon: ReactNode;
accent: string;
formatter?: (value: number) => string;
}) {
const rising = metric.change >= 0;
return (
<div className="overview-metric" style={{ '--metric-accent': accent } as CSSProperties}>
<div className="overview-metric-heading">
<span className="overview-metric-icon">{icon}</span>
<span>{label}</span>
</div>
<div className="overview-metric-value">{formatter(metric.value)}</div>
<div className={`overview-metric-change ${rising ? 'is-positive' : 'is-negative'}`}>
{rising ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
<span>{Math.abs(metric.change).toFixed(1)}%</span>
<span className="overview-metric-period">较上一周期</span>
</div>
</div>
);
}
function loadColor(percent: number) {
if (percent >= 85) return '#ff4d4f';
if (percent >= 70) return '#faad14';
return '#1677ff';
}
export default function OverviewPage() {
const { isDark, isUltra, antdThemeConfig } = useTheme();
const [range, setRange] = useState<OverviewRange>('today');
const [serverId, setServerId] = useState('all');
const { overview, loading, fetched, fetchError, refresh } = useGameOverviewQuery({
range,
serverId,
});
const pageClass = useMemo(() => {
const classes = ['game-overview-page'];
if (isDark) classes.push('is-dark');
if (isUltra) classes.push('is-ultra');
return classes.join(' ');
}, [isDark, isUltra]);
const serverColumns = useMemo<TableProps<GameServerHealth>['columns']>(
() => [
{
title: '区服',
dataIndex: 'name',
key: 'name',
width: 190,
render: (_, record) => (
<div className="overview-server-name">
<Typography.Text strong>{record.name}</Typography.Text>
<Typography.Text type="secondary">{record.region}</Typography.Text>
</div>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 110,
render: (status: GameServerHealth['status']) => {
const meta = statusMeta[status];
return (
<Tag color={meta.color} bordered={false}>
{meta.label}
</Tag>
);
},
},
{
title: '在线 / 容量',
key: 'players',
width: 230,
render: (_, record) => {
const percent = Math.round((record.onlinePlayers / record.capacity) * 100);
return (
<div className="overview-capacity">
<div>
<span>{integerFormatter.format(record.onlinePlayers)}</span>
<span className="overview-capacity-total">
{' / '}
{integerFormatter.format(record.capacity)}
</span>
</div>
<Progress
percent={percent}
showInfo={false}
size="small"
strokeColor={loadColor(percent)}
/>
</div>
);
},
},
{
title: '负载',
dataIndex: 'loadPercent',
key: 'loadPercent',
width: 90,
render: (value: number) => `${Math.round(value)}%`,
},
{
title: '版本',
dataIndex: 'version',
key: 'version',
width: 120,
render: (value: string) => <span className="overview-mono">{value}</span>,
},
{
title: '最后心跳',
dataIndex: 'lastHeartbeat',
key: 'lastHeartbeat',
width: 150,
render: (value: string) => dayjs(value).format('MM-DD HH:mm:ss'),
},
],
[],
);
return (
<ConfigProvider theme={antdThemeConfig}>
<Layout className={pageClass}>
<AppSidebar />
<Layout className="overview-content-shell">
<WorkspaceTabs />
<Layout.Content className="overview-content">
<Spin spinning={!fetched} delay={150} size="large" description="正在加载">
{!fetched ? (
<div className="overview-loading-spacer" />
) : fetchError ? (
<Result
status="error"
title="总览数据加载失败"
subTitle={fetchError}
extra={
<Button type="primary" loading={loading} onClick={() => refresh()}>
重新加载
</Button>
}
/>
) : overview ? (
<div className="overview-shell">
<Card
size="small"
className="overview-summary-card"
title={
<Typography.Text className="overview-context" type="secondary">
数据更新于 {dayjs(overview.generatedAt).format('YYYY-MM-DD HH:mm:ss')}
</Typography.Text>
}
extra={
<div className="overview-filters">
<Select
className="overview-server-select"
aria-label="区服筛选"
value={serverId}
options={overview.serverOptions.map((server) => ({
value: server.id,
label: server.name,
}))}
onChange={setServerId}
/>
<Segmented
className="overview-range"
aria-label="统计周期"
value={range}
options={rangeOptions}
onChange={(value) => setRange(value as OverviewRange)}
/>
<Button
aria-label="刷新数据"
title="刷新数据"
icon={<ReloadOutlined />}
loading={loading}
onClick={() => refresh()}
/>
</div>
}
>
<div className="overview-metric-grid">
<MetricCell
label="总玩家"
metric={overview.metrics.totalPlayers}
icon={<UserOutlined />}
accent="#1677ff"
/>
<MetricCell
label="周期新增"
metric={overview.metrics.newPlayers}
icon={<UserAddOutlined />}
accent="#13a8a8"
/>
<MetricCell
label="周期活跃"
metric={overview.metrics.activePlayers}
icon={<TeamOutlined />}
accent="#52c41a"
/>
<MetricCell
label="当前在线"
metric={overview.metrics.onlinePlayers}
icon={<ThunderboltOutlined />}
accent="#722ed1"
/>
<MetricCell
label="周期流水"
metric={overview.metrics.revenue}
icon={<DollarOutlined />}
accent="#fa8c16"
formatter={(value) => currencyFormatter.format(value)}
/>
<MetricCell
label="周期订单"
metric={overview.metrics.orders}
icon={<ShoppingCartOutlined />}
accent="#eb2f96"
/>
</div>
</Card>
<div className="overview-primary-grid">
<Card
size="small"
className="overview-panel-card"
title="玩家趋势"
extra={
<span className="overview-card-extra">
当前在线 {integerFormatter.format(overview.metrics.onlinePlayers.value)}
</span>
}
>
<Sparkline
data={overview.trends.onlinePlayers}
data2={overview.trends.activePlayers}
labels={overview.trends.labels}
name1="在线玩家"
name2="活跃玩家"
stroke="#1677ff"
stroke2="#13a8a8"
height={250}
showAxes
showTooltip
tickCountX={5}
valueMax={null}
yFormatter={(value) => integerFormatter.format(Math.round(value))}
tooltipFormatter={(value) => integerFormatter.format(Math.round(value))}
/>
</Card>
<Card
size="small"
className="overview-panel-card"
title="流水趋势"
extra={
<span className="overview-card-extra">
{currencyFormatter.format(overview.metrics.revenue.value)}
</span>
}
>
<Sparkline
data={overview.trends.revenue}
labels={overview.trends.labels}
name1="流水"
stroke="#fa8c16"
height={250}
showAxes
showTooltip
showLegend={false}
tickCountX={5}
valueMax={null}
yFormatter={(value) => `¥${Math.round(value / 1000)}k`}
tooltipFormatter={(value) => currencyFormatter.format(Math.round(value))}
/>
</Card>
</div>
<div className="overview-secondary-grid">
<Card
size="small"
className="overview-panel-card overview-server-card"
title={
<span className="overview-card-title">
<CloudServerOutlined />
区服状态
</span>
}
extra={
<span className="overview-card-extra">
{overview.servers.length} 个区服
</span>
}
>
<Table<GameServerHealth>
rowKey="id"
size="small"
columns={serverColumns}
dataSource={overview.servers}
pagination={false}
scroll={{ x: 890 }}
locale={{ emptyText: '暂无区服数据' }}
/>
</Card>
<Card
size="small"
className="overview-panel-card overview-alert-card"
title="运营待办"
extra={
<span className="overview-card-extra">
{overview.alerts.reduce((sum, item) => sum + item.count, 0)} 项
</span>
}
>
{overview.alerts.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无待办" />
) : (
<List
className="overview-alert-list"
dataSource={overview.alerts}
renderItem={(item) => (
<List.Item key={item.id}>
<div className="overview-alert-item">
<span
className="overview-alert-mark"
style={{ background: alertColors[item.level] }}
/>
<div className="overview-alert-copy">
<Typography.Text strong>{item.title}</Typography.Text>
<Typography.Text type="secondary">
{item.description}
</Typography.Text>
</div>
<Badge count={item.count} color={alertColors[item.level]} />
</div>
</List.Item>
)}
/>
)}
</Card>
</div>
</div>
) : null}
</Spin>
</Layout.Content>
</Layout>
</Layout>
</ConfigProvider>
);
}
+500
View File
@@ -0,0 +1,500 @@
.game-players-page {
--players-page-bg: #e6e8ec;
--players-card-bg: #ffffff;
--players-border: var(--ant-color-border-secondary);
height: 100vh;
min-height: 0;
overflow: hidden;
background: var(--players-page-bg);
}
.game-players-page.is-dark {
--players-page-bg: #1a1b1f;
--players-card-bg: #23252b;
}
.game-players-page.is-dark.is-ultra {
--players-page-bg: #000000;
--players-card-bg: #101013;
}
.game-players-page .ant-layout,
.game-players-page .ant-layout-content,
.players-content-shell {
background: transparent;
}
.players-content {
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow: hidden;
padding: 20px;
}
.players-content-shell {
height: 100%;
min-height: 0;
overflow: hidden;
}
.players-content > .ant-spin,
.players-content > .ant-spin > .ant-spin-container {
width: 100%;
min-height: 0;
}
.players-content > .ant-spin {
display: flex;
flex: 1 1 auto;
}
.players-content > .ant-spin > .ant-spin-container {
height: 100%;
}
.players-loading-spacer {
height: 100%;
min-height: 0;
}
.players-shell {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
height: 100%;
min-height: 0;
}
.players-summary-card,
.players-filter-card {
flex: 0 0 auto;
}
.players-summary-card,
.players-filter-card,
.players-table-card {
overflow: hidden;
border-radius: 8px;
background: var(--players-card-bg);
}
.players-table-card {
display: flex;
flex: 1 1 0;
min-height: 0;
}
.players-summary-card .ant-card-body {
padding: 0;
}
.players-metric-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
background: var(--players-card-bg);
}
.players-metric {
min-width: 0;
padding: 16px 20px;
background: var(--players-card-bg);
}
.players-metric-heading {
overflow: hidden;
color: var(--ant-color-text-secondary);
font-size: 12px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.players-metric-value-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
margin-top: 8px;
}
.players-metric-icon {
display: inline-flex;
flex: 0 0 auto;
color: var(--metric-accent);
font-size: 16px;
}
.players-metric-value {
min-width: 0;
overflow-wrap: anywhere;
color: var(--ant-color-text);
font-size: 22px;
font-weight: 500;
font-variant-numeric: tabular-nums;
line-height: 1;
white-space: nowrap;
}
.players-filter-card .ant-card-body,
.players-table-card .ant-card-body {
min-width: 0;
padding: 0;
}
.players-table-card .ant-card-body {
display: flex;
flex: 1 1 auto;
min-height: 0;
}
.players-table-viewport {
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
.players-filter-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
}
.players-filter-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
min-width: 0;
margin-inline-start: auto;
}
.players-search {
display: flex;
width: min(410px, 38vw);
}
.players-search .ant-input-affix-wrapper {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
.players-search .ant-btn {
border-start-start-radius: 0;
border-end-start-radius: 0;
}
.players-filter-select {
width: 126px;
}
.players-server-select {
width: 150px;
}
.players-filter-refresh {
flex: 0 0 auto;
}
.players-table-card .ant-table-wrapper {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.players-table-card .ant-table-wrapper > .ant-spin,
.players-table-card .ant-table-wrapper > .ant-spin > .ant-spin-container,
.players-table-card .ant-table {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
}
.players-table-card .ant-table-container {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
}
.players-table-card .ant-table-header {
flex: 0 0 auto;
}
.players-table-card .ant-table-body {
flex: 1 1 auto;
min-height: 0;
max-height: none !important;
overflow-y: auto !important;
}
.players-table-card .ant-table-thead .ant-table-cell {
text-align: left !important;
vertical-align: middle;
}
.players-table-card .ant-table-tbody > tr > .ant-table-cell {
text-align: left;
vertical-align: middle;
}
.players-table-card .ant-table-tbody > tr > .players-cell-start {
text-align: left;
}
.players-table-card .ant-pagination {
flex: 0 0 auto;
margin-block: 10px;
padding-inline: 16px;
}
.players-identity,
.players-stack {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.players-identity .ant-typography,
.players-stack .ant-typography {
overflow: hidden;
margin: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.players-stack .ant-typography-secondary {
font-size: 12px;
}
.players-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.players-numeric,
.players-time {
font-variant-numeric: tabular-nums;
}
.players-time {
white-space: nowrap;
}
.players-row-actions {
min-width: 0;
justify-content: flex-start;
}
.players-row-actions .ant-btn {
width: 32px;
height: 32px;
}
.players-table-card .players-actions-cell {
padding-inline: 8px !important;
}
.players-detail {
display: flex;
flex-direction: column;
gap: 18px;
}
.players-detail-heading {
display: grid;
grid-template-columns: 42px minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
}
.players-detail-heading .ant-typography {
margin: 0;
}
.players-detail-heading h4.ant-typography {
overflow-wrap: anywhere;
font-size: 17px;
}
.players-detail-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 6px;
background: var(--ant-color-primary-bg);
color: var(--ant-color-primary);
font-size: 20px;
}
.players-detail .ant-descriptions-item-label {
width: 116px;
white-space: nowrap;
}
.players-detail .ant-descriptions-item-content {
overflow-wrap: anywhere;
}
.players-detail-drawer .ant-drawer-content-wrapper {
height: 100dvh !important;
max-height: 100dvh;
}
.players-detail-drawer .ant-drawer-content {
max-height: 100dvh;
overflow: hidden;
}
.players-detail-drawer .ant-drawer-body {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
.players-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 14px;
}
.players-form-grid .ant-input-number {
width: 100%;
}
.players-resource-modal .ant-modal-body {
max-height: calc(100dvh - 220px);
overflow-y: auto;
overscroll-behavior: contain;
}
.players-confirm-copy {
margin-bottom: 0;
}
@media (max-width: 1180px) {
.players-filter-bar {
flex-wrap: wrap;
}
.players-filter-actions {
width: 100%;
margin-inline-start: 0;
}
.players-search {
flex: 1 1 300px;
width: min(100%, 450px);
}
}
@media (max-width: 900px) {
.players-metric {
padding-inline: 14px;
}
.players-metric-value-row {
gap: 6px;
}
.players-metric-value {
font-size: 20px;
}
}
@media (max-width: 768px) {
.players-content {
padding: 8px;
}
.players-shell {
gap: 8px;
}
.players-metric {
padding: 12px 8px;
}
.players-metric-heading {
font-size: 11px;
}
.players-metric-value-row {
gap: 5px;
margin-top: 7px;
}
.players-metric-icon {
font-size: 14px;
}
.players-metric-value {
font-size: 18px;
}
.players-filter-bar {
padding: 10px 12px;
}
.players-filter-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 32px;
}
.players-search {
grid-column: 1 / -1;
width: 100%;
}
.players-filter-select,
.players-server-select {
width: 100%;
}
.players-filter-refresh {
width: 32px;
padding-inline: 0;
}
.players-table-card .ant-pagination {
padding-inline: 12px;
}
.players-table-card .ant-tag {
margin-inline-end: 0;
padding-inline: 4px;
font-size: 12px;
}
.players-detail-heading {
grid-template-columns: 38px minmax(0, 1fr);
}
.players-detail-heading > .ant-tag {
grid-column: 2;
justify-self: start;
}
.players-detail-icon {
width: 38px;
height: 38px;
}
.players-detail .ant-descriptions-item-label {
width: 96px;
}
.players-form-grid {
grid-template-columns: minmax(0, 1fr);
}
}
+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>
);
}
+514
View File
@@ -0,0 +1,514 @@
.game-servers-page {
--servers-page-bg: #e6e8ec;
--servers-card-bg: #ffffff;
--servers-border: var(--ant-color-border-secondary);
height: 100vh;
min-height: 0;
overflow: hidden;
background: var(--servers-page-bg);
}
.game-servers-page.is-dark {
--servers-page-bg: #1a1b1f;
--servers-card-bg: #23252b;
}
.game-servers-page.is-dark.is-ultra {
--servers-page-bg: #000000;
--servers-card-bg: #101013;
}
.game-servers-page .ant-layout,
.game-servers-page .ant-layout-content,
.servers-content-shell {
background: transparent;
}
.servers-content {
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow: hidden;
padding: 20px;
}
.servers-content-shell {
height: 100%;
min-height: 0;
overflow: hidden;
}
.servers-content > .ant-spin,
.servers-content > .ant-spin > .ant-spin-container {
width: 100%;
min-height: 0;
}
.servers-content > .ant-spin {
display: flex;
flex: 1 1 auto;
}
.servers-content > .ant-spin > .ant-spin-container {
height: 100%;
}
.servers-loading-spacer {
height: 100%;
min-height: 0;
}
.servers-shell {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
height: 100%;
min-height: 0;
}
.servers-summary-card,
.servers-filter-card {
flex: 0 0 auto;
}
.servers-summary-card,
.servers-filter-card,
.servers-table-card {
overflow: hidden;
border-radius: 8px;
background: var(--servers-card-bg);
}
.servers-table-card {
display: flex;
flex: 1 1 0;
min-height: 0;
}
.servers-summary-card .ant-card-body {
padding: 0;
}
.servers-metric-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
background: var(--servers-card-bg);
}
.servers-metric {
min-width: 0;
padding: 16px 20px;
background: var(--servers-card-bg);
}
.servers-metric-heading {
overflow: hidden;
color: var(--ant-color-text-secondary);
font-size: 12px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.servers-metric-value-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
margin-top: 8px;
}
.servers-metric-icon {
display: inline-flex;
flex: 0 0 auto;
color: var(--metric-accent);
font-size: 16px;
}
.servers-metric-value {
min-width: 0;
overflow-wrap: anywhere;
color: var(--ant-color-text);
font-size: 22px;
font-weight: 500;
font-variant-numeric: tabular-nums;
line-height: 1;
white-space: nowrap;
}
.servers-filter-card .ant-card-body,
.servers-table-card .ant-card-body {
min-width: 0;
padding: 0;
}
.servers-table-card .ant-card-body {
display: flex;
flex: 1 1 auto;
min-height: 0;
}
.servers-table-viewport {
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
.servers-filter-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
}
.servers-filter-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
min-width: 0;
margin-inline-start: auto;
}
.servers-create-button,
.servers-filter-refresh {
flex: 0 0 auto;
}
.servers-search {
display: flex;
width: min(390px, 35vw);
}
.servers-search .ant-input-affix-wrapper {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
.servers-search .ant-btn {
border-start-start-radius: 0;
border-end-start-radius: 0;
}
.servers-filter-select {
width: 126px;
}
.servers-table-card .ant-table-wrapper {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.servers-table-card .ant-table-wrapper > .ant-spin,
.servers-table-card .ant-table-wrapper > .ant-spin > .ant-spin-container,
.servers-table-card .ant-table {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
}
.servers-table-card .ant-table-container {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
}
.servers-table-card .ant-table-header {
flex: 0 0 auto;
}
.servers-table-card .ant-table-body {
flex: 1 1 auto;
min-height: 0;
max-height: none !important;
overflow-y: auto !important;
}
.servers-table-card .ant-table-thead .ant-table-cell {
text-align: left !important;
vertical-align: middle;
}
.servers-table-card .ant-table-tbody > tr > .ant-table-cell {
text-align: left;
vertical-align: middle;
}
.servers-table-card .ant-table-tbody > tr > .servers-cell-start {
text-align: left;
}
.servers-table-card .ant-pagination {
flex: 0 0 auto;
margin-block: 10px;
padding-inline: 16px;
}
.servers-identity {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.servers-identity .ant-typography {
overflow: hidden;
margin: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.servers-identity .ant-typography-secondary {
font-size: 12px;
}
.servers-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.servers-numeric,
.servers-time {
font-variant-numeric: tabular-nums;
}
.servers-nowrap,
.servers-time {
white-space: nowrap;
}
.servers-load {
width: 120px;
margin: 0;
}
.servers-load .ant-progress-text {
min-width: 34px;
font-variant-numeric: tabular-nums;
}
.servers-row-actions {
min-width: 0;
justify-content: flex-start;
}
.servers-row-actions .ant-btn {
width: 32px;
height: 32px;
}
.servers-table-card .servers-actions-cell {
padding-inline: 8px !important;
}
.servers-detail {
display: flex;
flex-direction: column;
gap: 18px;
}
.servers-detail-heading {
display: grid;
grid-template-columns: 42px minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
}
.servers-detail-heading .ant-typography {
margin: 0;
}
.servers-detail-heading h4.ant-typography {
overflow-wrap: anywhere;
font-size: 17px;
}
.servers-detail-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 6px;
background: var(--ant-color-primary-bg);
color: var(--ant-color-primary);
font-size: 20px;
}
.servers-detail .ant-descriptions-item-label {
width: 116px;
white-space: nowrap;
}
.servers-detail .ant-descriptions-item-content {
overflow-wrap: anywhere;
}
.servers-detail-drawer .ant-drawer-content-wrapper {
height: 100dvh !important;
max-height: 100dvh;
}
.servers-detail-drawer .ant-drawer-content {
max-height: 100dvh;
overflow: hidden;
}
.servers-detail-drawer .ant-drawer-body {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
.servers-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 14px;
}
.servers-editor-modal .ant-modal-body {
max-height: calc(100dvh - 220px);
overflow-y: auto;
overscroll-behavior: contain;
}
.servers-number-input,
.servers-date-input {
width: 100%;
}
.servers-confirm-copy {
margin-bottom: 0;
}
@media (max-width: 1240px) {
.servers-filter-bar {
flex-wrap: wrap;
}
.servers-filter-actions {
width: 100%;
margin-inline-start: 0;
}
.servers-search {
flex: 1 1 280px;
width: min(100%, 430px);
}
}
@media (max-width: 1000px) {
.servers-metric {
padding-inline: 14px;
}
.servers-metric-value-row {
gap: 6px;
}
.servers-metric-value {
font-size: 20px;
}
}
@media (max-width: 768px) {
.servers-content {
padding: 8px;
}
.servers-shell {
gap: 8px;
}
.servers-metric {
padding: 12px 8px;
}
.servers-metric-heading {
font-size: 11px;
}
.servers-metric-value-row {
gap: 5px;
margin-top: 7px;
}
.servers-metric-icon {
font-size: 14px;
}
.servers-metric-value {
font-size: 18px;
}
.servers-filter-bar {
padding: 10px 12px;
}
.servers-filter-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 32px;
}
.servers-create-button,
.servers-search {
grid-column: 1 / -1;
}
.servers-create-button {
justify-self: end;
}
.servers-search {
width: 100%;
}
.servers-filter-select {
width: 100%;
}
.servers-filter-refresh {
width: 32px;
padding-inline: 0;
}
.servers-table-card .ant-pagination {
padding-inline: 12px;
}
.servers-table-card .ant-tag {
margin-inline-end: 0;
padding-inline: 4px;
font-size: 12px;
}
.servers-detail-heading {
grid-template-columns: 38px minmax(0, 1fr);
}
.servers-detail-heading > .ant-tag {
grid-column: 2;
justify-self: start;
}
.servers-detail-icon {
width: 38px;
height: 38px;
}
.servers-detail .ant-descriptions-item-label {
width: 96px;
}
.servers-form-grid {
grid-template-columns: minmax(0, 1fr);
}
}
+814
View File
@@ -0,0 +1,814 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { CSSProperties, ReactNode } from 'react';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import {
Button,
Card,
ConfigProvider,
DatePicker,
Descriptions,
Drawer,
Form,
Input,
InputNumber,
Layout,
message,
Modal,
Progress,
Result,
Select,
Space,
Spin,
Table,
Tag,
Tooltip,
Typography,
} from 'antd';
import type { TableProps } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import {
CheckCircleOutlined,
CloudServerOutlined,
ClusterOutlined,
DisconnectOutlined,
EditOutlined,
EyeOutlined,
GlobalOutlined,
PauseCircleOutlined,
PlayCircleOutlined,
PlusOutlined,
ReloadOutlined,
SearchOutlined,
TeamOutlined,
ToolOutlined,
} from '@ant-design/icons';
import {
useCreateGameServerMutation,
useGameServersQuery,
useGameServerStatusMutation,
useUpdateGameServerMutation,
} from '@/api/queries/useGameServersQuery';
import type { GameServerFilters } from '@/api/queries/useGameServersQuery';
import { useTheme } from '@/hooks/useTheme';
import AppSidebar from '@/layouts/AppSidebar';
import WorkspaceTabs from '@/layouts/WorkspaceTabs';
import type { GameServer, GameServerStatus, GameServerUpsertInput } from '@/schemas/gameServer';
import { setMessageInstance } from '@/utils/messageBus';
import './ServersPage.css';
const integerFormatter = new Intl.NumberFormat('zh-CN', {
useGrouping: false,
maximumFractionDigits: 0,
});
const DEFAULT_TABLE_SCROLL_HEIGHT = 240;
const MIN_TABLE_SCROLL_HEIGHT = 96;
const statusMeta: Record<GameServerStatus, { color: string; label: string }> = {
online: { color: 'success', label: '运行中' },
maintenance: { color: 'warning', label: '维护中' },
offline: { color: 'error', label: '离线' },
};
const statusOptions = [
{ value: 'all', label: '全部状态' },
{ value: 'online', label: '运行中' },
{ value: 'maintenance', label: '维护中' },
{ value: 'offline', label: '离线' },
] satisfies Array<{ value: GameServerStatus | 'all'; label: string }>;
interface ServerFormValues {
name: string;
code: string;
region: string;
host: string;
port: number;
capacity: number;
version: string;
openedAt: Dayjs;
}
interface MaintenanceFormValues {
maintenanceNote: string;
}
function formatDateTime(value: string) {
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
}
function StatusTag({ status }: { status: GameServerStatus }) {
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="servers-metric" style={{ '--metric-accent': accent } as CSSProperties}>
<div className="servers-metric-heading">{label}</div>
<div className="servers-metric-value-row">
<span className="servers-metric-icon">{icon}</span>
<span className="servers-metric-value">{integerFormatter.format(value)}</span>
</div>
</div>
);
}
export default function ServersPage() {
const { isDark, isUltra, antdThemeConfig } = useTheme();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => {
setMessageInstance(messageApi);
}, [messageApi]);
const [filters, setFilters] = useState<GameServerFilters>({
page: 1,
pageSize: 20,
keyword: '',
status: 'all',
region: 'all',
});
const [keywordInput, setKeywordInput] = useState('');
const [selectedServer, setSelectedServer] = useState<GameServer | null>(null);
const [editorTarget, setEditorTarget] = useState<GameServer | null>(null);
const [editorOpen, setEditorOpen] = useState(false);
const [maintenanceTarget, setMaintenanceTarget] = useState<GameServer | null>(null);
const [recoverTarget, setRecoverTarget] = useState<GameServer | null>(null);
const tableViewportRef = useRef<HTMLDivElement>(null);
const [tableScrollHeight, setTableScrollHeight] = useState(DEFAULT_TABLE_SCROLL_HEIGHT);
const [serverForm] = Form.useForm<ServerFormValues>();
const [maintenanceForm] = Form.useForm<MaintenanceFormValues>();
const createMutation = useCreateGameServerMutation();
const updateMutation = useUpdateGameServerMutation();
const statusMutation = useGameServerStatusMutation();
const { servers, total, summary, regionOptions, loading, fetched, fetchError, refresh } =
useGameServersQuery(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-servers-page'];
if (isDark) classes.push('is-dark');
if (isUltra) classes.push('is-ultra');
return classes.join(' ');
}, [isDark, isUltra]);
const updateFilters = useCallback((patch: Partial<GameServerFilters>) => {
setFilters((current) => ({ ...current, ...patch, page: patch.page ?? 1 }));
}, []);
const openCreate = useCallback(() => {
setEditorTarget(null);
serverForm.setFieldsValue({
name: '',
code: '',
region: '',
host: '',
port: 7001,
capacity: 6000,
version: '1.0.0',
openedAt: dayjs(),
});
setEditorOpen(true);
}, [serverForm]);
const openEdit = useCallback(
(server: GameServer) => {
setEditorTarget(server);
serverForm.setFieldsValue({
name: server.name,
code: server.code,
region: server.region,
host: server.host,
port: server.port,
capacity: server.capacity,
version: server.version,
openedAt: dayjs(server.openedAt),
});
setEditorOpen(true);
},
[serverForm],
);
const closeEditor = useCallback(() => {
setEditorOpen(false);
setEditorTarget(null);
serverForm.resetFields();
}, [serverForm]);
const submitEditor = async (values: ServerFormValues) => {
const payload: GameServerUpsertInput = {
name: values.name.trim(),
code: values.code.trim(),
region: values.region.trim(),
host: values.host.trim(),
port: values.port,
capacity: values.capacity,
version: values.version.trim(),
openedAt: values.openedAt.toISOString(),
};
try {
const saved = editorTarget
? await updateMutation.mutateAsync({ serverId: editorTarget.id, ...payload })
: await createMutation.mutateAsync(payload);
setSelectedServer((current) => (current?.id === saved.id ? saved : current));
messageApi.success(editorTarget ? '区服已更新' : '区服已新增');
closeEditor();
} catch {
// The HTTP layer reports the server error and keeps the editor open for correction.
}
};
const openMaintenance = useCallback(
(server: GameServer) => {
setMaintenanceTarget(server);
maintenanceForm.setFieldsValue({ maintenanceNote: server.maintenanceNote || '' });
},
[maintenanceForm],
);
const submitMaintenance = async (values: MaintenanceFormValues) => {
if (!maintenanceTarget) return;
try {
const updated = await statusMutation.mutateAsync({
serverId: maintenanceTarget.id,
status: 'maintenance',
maintenanceNote: values.maintenanceNote,
});
setSelectedServer((current) => (current?.id === updated.id ? updated : current));
messageApi.success('区服已进入维护');
setMaintenanceTarget(null);
maintenanceForm.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({
serverId: recoverTarget.id,
status: 'online',
});
setSelectedServer((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<GameServer>['columns']>(
() => [
{
title: '区服',
key: 'server',
width: 176,
className: 'servers-cell-start',
render: (_, server) => (
<div className="servers-identity">
<Typography.Text strong>{server.name}</Typography.Text>
<Typography.Text type="secondary" className="servers-mono">
{server.code}
</Typography.Text>
</div>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 102,
className: 'servers-cell-start',
render: (status: GameServerStatus) => <StatusTag status={status} />,
},
{
title: '地区',
dataIndex: 'region',
key: 'region',
width: 88,
className: 'servers-cell-start',
},
{
title: '接入地址',
key: 'endpoint',
width: 190,
className: 'servers-cell-start',
render: (_, server) => (
<span className="servers-mono servers-nowrap">
{server.host}:{integerFormatter.format(server.port)}
</span>
),
},
{
title: '在线 / 容量',
key: 'players',
width: 126,
className: 'servers-cell-start',
render: (_, server) => (
<span className="servers-numeric servers-nowrap">
{integerFormatter.format(server.onlinePlayers)} /{' '}
{integerFormatter.format(server.capacity)}
</span>
),
},
{
title: '负载',
dataIndex: 'loadPercent',
key: 'loadPercent',
width: 150,
className: 'servers-cell-start',
render: (value: number) => (
<Progress
className="servers-load"
percent={Math.round(value)}
size="small"
strokeColor={value >= 80 ? '#ff4d4f' : value >= 60 ? '#faad14' : '#52c41a'}
/>
),
},
{
title: '版本',
dataIndex: 'version',
key: 'version',
width: 108,
className: 'servers-cell-start',
render: (value: string) => <span className="servers-mono">{value}</span>,
},
{
title: '开服时间',
dataIndex: 'openedAt',
key: 'openedAt',
width: 170,
className: 'servers-cell-start',
render: (value: string) => <span className="servers-time">{formatDateTime(value)}</span>,
},
{
title: '最后心跳',
dataIndex: 'lastHeartbeat',
key: 'lastHeartbeat',
width: 170,
className: 'servers-cell-start',
render: (value: string) => <span className="servers-time">{formatDateTime(value)}</span>,
},
{
title: '操作',
key: 'actions',
width: 120,
fixed: 'right',
className: 'servers-cell-start servers-actions-cell',
render: (_, server) => (
<Space size={2} className="servers-row-actions">
<Tooltip title="查看详情">
<Button
type="text"
aria-label={`查看区服 ${server.name}`}
icon={<EyeOutlined />}
onClick={() => setSelectedServer(server)}
/>
</Tooltip>
<Tooltip title="编辑区服">
<Button
type="text"
aria-label={`编辑区服 ${server.name}`}
icon={<EditOutlined />}
onClick={() => openEdit(server)}
/>
</Tooltip>
{server.status === 'online' ? (
<Tooltip title="进入维护">
<Button
type="text"
danger
aria-label={`维护区服 ${server.name}`}
icon={<PauseCircleOutlined />}
disabled={statusMutation.isPending}
onClick={() => openMaintenance(server)}
/>
</Tooltip>
) : (
<Tooltip title="恢复运行">
<Button
type="text"
aria-label={`恢复区服 ${server.name}`}
icon={<PlayCircleOutlined />}
disabled={statusMutation.isPending}
onClick={() => setRecoverTarget(server)}
/>
</Tooltip>
)}
</Space>
),
},
],
[openEdit, openMaintenance, statusMutation.isPending],
);
const saving = createMutation.isPending || updateMutation.isPending;
return (
<ConfigProvider locale={zhCN} theme={antdThemeConfig}>
{messageContextHolder}
<Layout className={pageClass}>
<AppSidebar />
<Layout className="servers-content-shell">
<WorkspaceTabs />
<Layout.Content className="servers-content">
<Spin spinning={!fetched} delay={150} size="large" description="正在加载">
{!fetched ? (
<div className="servers-loading-spacer" />
) : fetchError ? (
<Result
status="error"
title="区服数据加载失败"
subTitle={fetchError}
extra={
<Button type="primary" loading={loading} onClick={() => refresh()}>
重新加载
</Button>
}
/>
) : (
<div className="servers-shell">
<Card size="small" className="servers-summary-card">
<div className="servers-metric-grid">
<SummaryMetric
label="总区服"
value={summary.total}
icon={<ClusterOutlined />}
accent="#1677ff"
/>
<SummaryMetric
label="运行中"
value={summary.online}
icon={<CheckCircleOutlined />}
accent="#52c41a"
/>
<SummaryMetric
label="维护中"
value={summary.maintenance}
icon={<ToolOutlined />}
accent="#faad14"
/>
<SummaryMetric
label="离线"
value={summary.offline}
icon={<DisconnectOutlined />}
accent="#ff4d4f"
/>
<SummaryMetric
label="当前在线"
value={summary.onlinePlayers}
icon={<TeamOutlined />}
accent="#13a8a8"
/>
</div>
</Card>
<Card size="small" className="servers-filter-card">
<div className="servers-filter-bar">
<div className="servers-filter-actions">
<Button
type="primary"
className="servers-create-button"
icon={<PlusOutlined />}
onClick={openCreate}
>
新增区服
</Button>
<div className="servers-search">
<Input
allowClear
aria-label="搜索区服"
placeholder="区服名称、代号、接入地址"
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="servers-filter-select"
aria-label="区服状态筛选"
value={filters.status}
options={statusOptions}
onChange={(status) => updateFilters({ status })}
/>
<Select
className="servers-filter-select"
aria-label="区服地区筛选"
value={filters.region}
options={[{ value: 'all', label: '全部地区' }, ...regionOptions]}
onChange={(region) => updateFilters({ region })}
/>
<Button
className="servers-filter-refresh"
aria-label="刷新区服数据"
title="刷新区服数据"
icon={<ReloadOutlined />}
loading={loading}
onClick={() => refresh()}
/>
</div>
</div>
</Card>
<Card size="small" className="servers-table-card">
<div ref={tableViewportRef} className="servers-table-viewport">
<Table<GameServer>
rowKey="id"
size="middle"
columns={columns}
dataSource={servers}
loading={loading}
scroll={{ x: 1400, 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={selectedServer !== null}
rootClassName={`servers-detail-drawer ${isDark ? 'dark' : 'light'}`}
onClose={() => setSelectedServer(null)}
>
{selectedServer && (
<div className="servers-detail">
<div className="servers-detail-heading">
<span className="servers-detail-icon">
<CloudServerOutlined />
</span>
<div>
<Typography.Title level={4}>{selectedServer.name}</Typography.Title>
<Typography.Text type="secondary" className="servers-mono">
{selectedServer.code}
</Typography.Text>
</div>
<StatusTag status={selectedServer.status} />
</div>
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="地区">{selectedServer.region}</Descriptions.Item>
<Descriptions.Item label="接入地址">
<span className="servers-mono">
{selectedServer.host}:{integerFormatter.format(selectedServer.port)}
</span>
</Descriptions.Item>
<Descriptions.Item label="在线 / 容量">
{integerFormatter.format(selectedServer.onlinePlayers)} /{' '}
{integerFormatter.format(selectedServer.capacity)}
</Descriptions.Item>
<Descriptions.Item label="当前负载">
{integerFormatter.format(Math.round(selectedServer.loadPercent))}%
</Descriptions.Item>
<Descriptions.Item label="服务端版本">
<span className="servers-mono">{selectedServer.version}</span>
</Descriptions.Item>
<Descriptions.Item label="开服时间">
{formatDateTime(selectedServer.openedAt)}
</Descriptions.Item>
<Descriptions.Item label="最后心跳">
{formatDateTime(selectedServer.lastHeartbeat)}
</Descriptions.Item>
{selectedServer.maintenanceNote && (
<Descriptions.Item label="维护说明">
{selectedServer.maintenanceNote}
</Descriptions.Item>
)}
</Descriptions>
</div>
)}
</Drawer>
<Modal
title={editorTarget ? `编辑区服:${editorTarget.name}` : '新增区服'}
open={editorOpen}
width={720}
okText={editorTarget ? '保存修改' : '确认新增'}
cancelText="取消"
okButtonProps={{ loading: saving }}
rootClassName="servers-editor-modal"
destroyOnHidden
onOk={() => serverForm.submit()}
onCancel={closeEditor}
>
<Form<ServerFormValues>
form={serverForm}
layout="vertical"
requiredMark={false}
className="servers-editor-form"
onFinish={submitEditor}
>
<div className="servers-form-grid">
<Form.Item
name="name"
label="区服名称"
rules={[
{ required: true, whitespace: true, message: '请输入区服名称' },
{ max: 40, message: '区服名称不能超过 40 个字符' },
]}
>
<Input placeholder="例如:曙光一区" />
</Form.Item>
<Form.Item
name="code"
label="区服代号"
rules={[
{ required: true, whitespace: true, message: '请输入区服代号' },
{ max: 32, message: '区服代号不能超过 32 个字符' },
{
pattern: /^[A-Za-z0-9_-]+$/,
message: '仅允许字母、数字、下划线和短横线',
},
]}
>
<Input placeholder="例如:S001" />
</Form.Item>
<Form.Item
name="region"
label="地区"
rules={[
{ required: true, whitespace: true, message: '请输入地区' },
{ max: 24, message: '地区不能超过 24 个字符' },
]}
>
<Input prefix={<GlobalOutlined />} placeholder="例如:华东" />
</Form.Item>
<Form.Item
name="version"
label="服务端版本"
rules={[
{ required: true, whitespace: true, message: '请输入服务端版本' },
{ max: 40, message: '版本号不能超过 40 个字符' },
]}
>
<Input placeholder="例如:1.8.12" />
</Form.Item>
<Form.Item
name="host"
label="接入地址"
rules={[
{ required: true, whitespace: true, message: '请输入 IP 或域名' },
{ max: 255, message: '接入地址不能超过 255 个字符' },
]}
>
<Input placeholder="例如:10.20.1.10" />
</Form.Item>
<Form.Item
name="port"
label="端口"
rules={[{ required: true, message: '请输入端口' }]}
>
<InputNumber
min={1}
max={65535}
precision={0}
controls
className="servers-number-input"
/>
</Form.Item>
<Form.Item
name="capacity"
label="容量"
rules={[{ required: true, message: '请输入区服容量' }]}
>
<InputNumber min={1} precision={0} controls className="servers-number-input" />
</Form.Item>
<Form.Item
name="openedAt"
label="开服时间"
rules={[{ required: true, message: '请选择开服时间' }]}
>
<DatePicker showTime format="YYYY-MM-DD HH:mm:ss" className="servers-date-input" />
</Form.Item>
</div>
</Form>
</Modal>
<Modal
title={`进入维护${maintenanceTarget ? `:${maintenanceTarget.name}` : ''}`}
open={maintenanceTarget !== null}
okText="确认维护"
cancelText="取消"
okButtonProps={{ danger: true, loading: statusMutation.isPending }}
destroyOnHidden
onOk={() => maintenanceForm.submit()}
onCancel={() => {
setMaintenanceTarget(null);
maintenanceForm.resetFields();
}}
>
<Form<MaintenanceFormValues>
form={maintenanceForm}
layout="vertical"
requiredMark={false}
onFinish={submitMaintenance}
>
<Form.Item
name="maintenanceNote"
label="维护说明"
rules={[
{ required: true, whitespace: true, message: '请输入维护说明' },
{ max: 200, message: '维护说明不能超过 200 个字符' },
]}
>
<Input.TextArea rows={4} placeholder="填写停服原因或维护内容" />
</Form.Item>
</Form>
</Modal>
<Modal
title="恢复运行"
open={recoverTarget !== null}
okText="确认恢复"
cancelText="取消"
okButtonProps={{ loading: statusMutation.isPending }}
onOk={submitRecover}
onCancel={() => setRecoverTarget(null)}
>
<p className="servers-confirm-copy">区服 {recoverTarget?.name} 将恢复为运行中状态。</p>
</Modal>
</Layout>
</ConfigProvider>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: true,
retry: 1,
},
mutations: {
retry: 0,
},
},
});
+61
View File
@@ -0,0 +1,61 @@
import { lazy, Suspense } from 'react';
import { createBrowserRouter, Navigate, type RouteObject } from 'react-router';
import { Spin } from 'antd';
import PanelLayout from '@/layouts/PanelLayout';
const OverviewPage = lazy(() => import('@/pages/overview/OverviewPage'));
const AccountsPage = lazy(() => import('@/pages/accounts/AccountsPage'));
const ServersPage = lazy(() => import('@/pages/servers/ServersPage'));
const PlayersPage = lazy(() => import('@/pages/players/PlayersPage'));
const CdksPage = lazy(() => import('@/pages/cdks/CdksPage'));
const AnnouncementsPage = lazy(() => import('@/pages/announcements/AnnouncementsPage'));
const OrdersPage = lazy(() => import('@/pages/orders/OrdersPage'));
function withSuspense(node: React.ReactNode) {
return (
<Suspense
fallback={
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '60vh',
}}
>
<Spin size="large" />
</div>
}
>
{node}
</Suspense>
);
}
const routes: RouteObject[] = [
{
path: '/',
element: <PanelLayout />,
children: [
{ index: true, element: withSuspense(<OverviewPage />) },
{ path: 'accounts', element: withSuspense(<AccountsPage />) },
{ path: 'servers', element: withSuspense(<ServersPage />) },
{ path: 'players', element: withSuspense(<PlayersPage />) },
{ path: 'cdks', element: withSuspense(<CdksPage />) },
{ path: 'announcements', element: withSuspense(<AnnouncementsPage />) },
{ path: 'orders', element: withSuspense(<OrdersPage />) },
{ path: '*', element: <Navigate to="/" replace /> },
],
},
];
function computeBasename() {
const raw = (typeof window !== 'undefined' && window.ADMIN_UI_BASE_PATH) || '/';
const trimmed = raw.replace(/\/+$/, '');
return `${trimmed}/panel`;
}
export const router = createBrowserRouter(routes, {
basename: computeBasename(),
});
+48
View File
@@ -0,0 +1,48 @@
import { z } from 'zod';
export const GameAccountStatusSchema = z.enum(['active', 'banned', 'locked']);
export const GameAccountPlatformSchema = z.enum(['android', 'ios', 'pc']);
export const GameAccountSchema = z.object({
id: z.string().min(1),
username: z.string().min(1),
platform: GameAccountPlatformSchema,
channel: z.string().min(1),
characterCount: z.number().int().nonnegative(),
deviceCount: z.number().int().nonnegative(),
totalRecharge: z.number().nonnegative(),
status: GameAccountStatusSchema,
registerTime: z.string().min(1),
lastLoginTime: z.string().min(1),
lastLoginIp: z.string().min(1),
banReason: z.string().nullable(),
banUntil: z.string().nullable(),
});
export const GameAccountSummarySchema = z.object({
total: z.number().int().nonnegative(),
newToday: z.number().int().nonnegative(),
active: z.number().int().nonnegative(),
banned: z.number().int().nonnegative(),
locked: z.number().int().nonnegative(),
});
export const GameAccountListSchema = z.object({
items: z.array(GameAccountSchema),
total: z.number().int().nonnegative(),
page: z.number().int().positive(),
pageSize: z.number().int().positive(),
summary: GameAccountSummarySchema,
platformOptions: z.array(
z.object({
value: GameAccountPlatformSchema,
label: z.string().min(1),
}),
),
});
export type GameAccountStatus = z.infer<typeof GameAccountStatusSchema>;
export type GameAccountPlatform = z.infer<typeof GameAccountPlatformSchema>;
export type GameAccount = z.infer<typeof GameAccountSchema>;
export type GameAccountSummary = z.infer<typeof GameAccountSummarySchema>;
export type GameAccountList = z.infer<typeof GameAccountListSchema>;
+91
View File
@@ -0,0 +1,91 @@
import { z } from 'zod';
export const GameAnnouncementStatusSchema = z.enum([
'draft',
'scheduled',
'active',
'ended',
'offline',
]);
export const GameAnnouncementChannelSchema = z.enum(['login', 'banner', 'marquee']);
export const GameAnnouncementScopeSchema = z.enum(['all', 'servers']);
export const GameAnnouncementSchema = z
.object({
id: z.string().min(1),
title: z.string().min(1),
content: z.string().min(1),
channel: GameAnnouncementChannelSchema,
status: GameAnnouncementStatusSchema,
scope: GameAnnouncementScopeSchema,
serverIds: z.array(z.string().min(1)),
serverNames: z.array(z.string().min(1)),
priority: z.number().int().min(1).max(100),
startAt: z.string().nullable(),
endAt: z.string().nullable(),
createdAt: z.string().min(1),
updatedAt: z.string().min(1),
createdBy: z.string().min(1),
publishedAt: z.string().nullable(),
viewCount: z.number().int().nonnegative(),
clickCount: z.number().int().nonnegative(),
})
.strict();
export const GameAnnouncementSummarySchema = z
.object({
total: z.number().int().nonnegative(),
active: z.number().int().nonnegative(),
scheduled: z.number().int().nonnegative(),
draft: z.number().int().nonnegative(),
inactive: z.number().int().nonnegative(),
})
.strict();
export const GameAnnouncementOptionSchema = z
.object({
value: z.string().min(1),
label: z.string().min(1),
})
.strict();
export const GameAnnouncementListSchema = z
.object({
items: z.array(GameAnnouncementSchema),
total: z.number().int().nonnegative(),
page: z.number().int().positive(),
pageSize: z.number().int().positive(),
summary: GameAnnouncementSummarySchema,
serverOptions: z.array(GameAnnouncementOptionSchema),
})
.strict();
export const GameAnnouncementUpsertInputSchema = z
.object({
title: z.string().trim().min(1).max(80),
content: z.string().trim().min(1).max(5000),
channel: GameAnnouncementChannelSchema,
serverIds: z.array(z.string().min(1)).max(100),
priority: z.number().int().min(1).max(100),
startAt: z.string().nullable(),
endAt: z.string().nullable(),
mode: z.enum(['draft', 'publish']),
})
.strict()
.refine(
(value) => !value.startAt || !value.endAt || value.startAt < value.endAt,
'Announcement end time must be later than the start time',
);
export const GameAnnouncementStatusInputSchema = z
.object({ action: z.enum(['publish', 'offline']) })
.strict();
export type GameAnnouncementStatus = z.infer<typeof GameAnnouncementStatusSchema>;
export type GameAnnouncementChannel = z.infer<typeof GameAnnouncementChannelSchema>;
export type GameAnnouncementScope = z.infer<typeof GameAnnouncementScopeSchema>;
export type GameAnnouncement = z.infer<typeof GameAnnouncementSchema>;
export type GameAnnouncementSummary = z.infer<typeof GameAnnouncementSummarySchema>;
export type GameAnnouncementList = z.infer<typeof GameAnnouncementListSchema>;
export type GameAnnouncementUpsertInput = z.infer<typeof GameAnnouncementUpsertInputSchema>;
export type GameAnnouncementStatusInput = z.infer<typeof GameAnnouncementStatusInputSchema>;
+101
View File
@@ -0,0 +1,101 @@
import { z } from 'zod';
export const GameCdkStatusSchema = z.enum(['unused', 'used', 'expired', 'disabled']);
export const GameCdkScopeSchema = z.enum(['all', 'server']);
export const GameCdkRewardSchema = z
.object({
itemId: z.string().trim().min(1).max(64),
itemName: z.string().trim().min(1).max(40),
quantity: z.number().int().positive(),
})
.strict();
export const GameCdkSchema = z
.object({
id: z.string().min(1),
code: z.string().min(1),
batchId: z.string().min(1),
batchName: z.string().min(1),
status: GameCdkStatusSchema,
rewardName: z.string().min(1),
rewards: z.array(GameCdkRewardSchema).min(1),
scope: GameCdkScopeSchema,
serverId: z.string().nullable(),
serverName: z.string().nullable(),
createdAt: z.string().min(1),
expiresAt: z.string().nullable(),
usedByPlayerId: z.string().nullable(),
usedByPlayerName: z.string().nullable(),
usedAt: z.string().nullable(),
createdBy: z.string().min(1),
})
.strict();
export const GameCdkSummarySchema = z
.object({
total: z.number().int().nonnegative(),
unused: z.number().int().nonnegative(),
used: z.number().int().nonnegative(),
expired: z.number().int().nonnegative(),
disabled: z.number().int().nonnegative(),
})
.strict();
export const GameCdkOptionSchema = z
.object({
value: z.string().min(1),
label: z.string().min(1),
})
.strict();
export const GameCdkListSchema = z
.object({
items: z.array(GameCdkSchema),
total: z.number().int().nonnegative(),
page: z.number().int().positive(),
pageSize: z.number().int().positive(),
summary: GameCdkSummarySchema,
batchOptions: z.array(GameCdkOptionSchema),
serverOptions: z.array(GameCdkOptionSchema),
})
.strict();
export const GameCdkBatchCreateInputSchema = z
.object({
batchName: z.string().trim().min(1).max(40),
prefix: z
.string()
.trim()
.min(2)
.max(12)
.regex(/^[A-Za-z0-9]+$/),
quantity: z.number().int().min(1).max(1000),
rewardName: z.string().trim().min(1).max(40),
rewards: z.array(GameCdkRewardSchema).min(1).max(10),
serverId: z.string().nullable(),
expiresAt: z.string().nullable(),
})
.strict();
export const GameCdkBatchCreateResultSchema = z
.object({
batchId: z.string().min(1),
batchName: z.string().min(1),
createdCount: z.number().int().positive(),
})
.strict();
export const GameCdkStatusInputSchema = z
.object({ status: z.enum(['unused', 'disabled']) })
.strict();
export type GameCdkStatus = z.infer<typeof GameCdkStatusSchema>;
export type GameCdkScope = z.infer<typeof GameCdkScopeSchema>;
export type GameCdkReward = z.infer<typeof GameCdkRewardSchema>;
export type GameCdk = z.infer<typeof GameCdkSchema>;
export type GameCdkSummary = z.infer<typeof GameCdkSummarySchema>;
export type GameCdkList = z.infer<typeof GameCdkListSchema>;
export type GameCdkBatchCreateInput = z.infer<typeof GameCdkBatchCreateInputSchema>;
export type GameCdkBatchCreateResult = z.infer<typeof GameCdkBatchCreateResultSchema>;
export type GameCdkStatusInput = z.infer<typeof GameCdkStatusInputSchema>;
+101
View File
@@ -0,0 +1,101 @@
import { z } from 'zod';
export const GameOrderStatusSchema = z.enum([
'pending',
'paid',
'delivered',
'refunding',
'refunded',
'closed',
]);
export const GameOrderChannelSchema = z.enum([
'official',
'app_store',
'google_play',
'steam',
'third_party',
]);
export const GameOrderCurrencySchema = z.enum(['CNY', 'USD']);
export const GameOrderItemSchema = z
.object({
itemId: z.string().min(1),
itemName: z.string().min(1),
quantity: z.number().int().positive(),
})
.strict();
export const GameOrderSchema = z
.object({
id: z.string().min(1),
providerOrderId: z.string().min(1),
accountId: z.string().min(1),
accountName: z.string().min(1),
playerId: z.string().min(1),
playerName: z.string().min(1),
serverId: z.string().min(1),
serverName: z.string().min(1),
productId: z.string().min(1),
productName: z.string().min(1),
quantity: z.number().int().positive(),
items: z.array(GameOrderItemSchema).min(1),
amount: z.number().nonnegative(),
currency: GameOrderCurrencySchema,
channel: GameOrderChannelSchema,
status: GameOrderStatusSchema,
createdAt: z.string().min(1),
paidAt: z.string().nullable(),
deliveredAt: z.string().nullable(),
refundedAt: z.string().nullable(),
updatedAt: z.string().min(1),
clientIp: z.string().min(1),
refundReason: z.string().nullable(),
operatorNote: z.string().nullable(),
})
.strict();
export const GameOrderSummarySchema = z
.object({
total: z.number().int().nonnegative(),
paidAmount: z.number().nonnegative(),
pending: z.number().int().nonnegative(),
awaitingDelivery: z.number().int().nonnegative(),
refunding: z.number().int().nonnegative(),
})
.strict();
export const GameOrderOptionSchema = z
.object({
value: z.string().min(1),
label: z.string().min(1),
})
.strict();
export const GameOrderListSchema = z
.object({
items: z.array(GameOrderSchema),
total: z.number().int().nonnegative(),
page: z.number().int().positive(),
pageSize: z.number().int().positive(),
summary: GameOrderSummarySchema,
serverOptions: z.array(GameOrderOptionSchema),
})
.strict();
export const GameOrderDeliveryInputSchema = z
.object({ operatorNote: z.string().trim().max(200).nullable() })
.strict();
export const GameOrderRefundInputSchema = z
.object({ reason: z.string().trim().min(2).max(200) })
.strict();
export type GameOrderStatus = z.infer<typeof GameOrderStatusSchema>;
export type GameOrderChannel = z.infer<typeof GameOrderChannelSchema>;
export type GameOrderCurrency = z.infer<typeof GameOrderCurrencySchema>;
export type GameOrderItem = z.infer<typeof GameOrderItemSchema>;
export type GameOrder = z.infer<typeof GameOrderSchema>;
export type GameOrderSummary = z.infer<typeof GameOrderSummarySchema>;
export type GameOrderList = z.infer<typeof GameOrderListSchema>;
export type GameOrderDeliveryInput = z.infer<typeof GameOrderDeliveryInputSchema>;
export type GameOrderRefundInput = z.infer<typeof GameOrderRefundInputSchema>;
+60
View File
@@ -0,0 +1,60 @@
import { z } from 'zod';
export const OverviewRangeSchema = z.enum(['today', '7d', '30d']);
const OverviewMetricSchema = z.object({
value: z.number().nonnegative(),
change: z.number(),
});
const ServerOptionSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
});
export const GameServerHealthSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
region: z.string().min(1),
status: z.enum(['online', 'maintenance', 'offline']),
onlinePlayers: z.number().int().nonnegative(),
capacity: z.number().int().positive(),
loadPercent: z.number().min(0).max(100),
version: z.string().min(1),
lastHeartbeat: z.string().min(1),
});
export const OverviewAlertSchema = z.object({
id: z.string().min(1),
level: z.enum(['error', 'warning', 'info']),
title: z.string().min(1),
description: z.string().min(1),
count: z.number().int().nonnegative(),
});
export const GameOverviewSchema = z.object({
generatedAt: z.string().min(1),
range: OverviewRangeSchema,
serverOptions: z.array(ServerOptionSchema),
metrics: z.object({
totalPlayers: OverviewMetricSchema,
newPlayers: OverviewMetricSchema,
activePlayers: OverviewMetricSchema,
onlinePlayers: OverviewMetricSchema,
revenue: OverviewMetricSchema,
orders: OverviewMetricSchema,
}),
trends: z.object({
labels: z.array(z.string()),
onlinePlayers: z.array(z.number().nonnegative()),
activePlayers: z.array(z.number().nonnegative()),
revenue: z.array(z.number().nonnegative()),
}),
servers: z.array(GameServerHealthSchema),
alerts: z.array(OverviewAlertSchema),
});
export type OverviewRange = z.infer<typeof OverviewRangeSchema>;
export type GameOverview = z.infer<typeof GameOverviewSchema>;
export type GameServerHealth = z.infer<typeof GameServerHealthSchema>;
export type OverviewAlert = z.infer<typeof OverviewAlertSchema>;
+79
View File
@@ -0,0 +1,79 @@
import { z } from 'zod';
export const GamePlayerStatusSchema = z.enum(['online', 'offline', 'banned']);
export const GamePlayerSchema = z
.object({
id: z.string().min(1),
name: z.string().min(1),
accountId: z.string().min(1),
accountName: z.string().min(1),
serverId: z.string().min(1),
serverName: z.string().min(1),
level: z.number().int().positive(),
vipLevel: z.number().int().nonnegative(),
combatPower: z.number().int().nonnegative(),
gold: z.number().int().nonnegative(),
diamonds: z.number().int().nonnegative(),
totalRecharge: z.number().nonnegative(),
status: GamePlayerStatusSchema,
createdAt: z.string().min(1),
lastLoginTime: z.string().min(1),
lastLoginIp: z.string().min(1),
banReason: z.string().nullable(),
banUntil: z.string().nullable(),
})
.strict();
export const GamePlayerSummarySchema = z
.object({
total: z.number().int().nonnegative(),
online: z.number().int().nonnegative(),
activeToday: z.number().int().nonnegative(),
newToday: z.number().int().nonnegative(),
banned: z.number().int().nonnegative(),
})
.strict();
export const GamePlayerServerOptionSchema = z
.object({
value: z.string().min(1),
label: z.string().min(1),
})
.strict();
export const GamePlayerListSchema = z
.object({
items: z.array(GamePlayerSchema),
total: z.number().int().nonnegative(),
page: z.number().int().positive(),
pageSize: z.number().int().positive(),
summary: GamePlayerSummarySchema,
serverOptions: z.array(GamePlayerServerOptionSchema),
})
.strict();
export const GamePlayerResourceInputSchema = z
.object({
level: z.number().int().min(1).max(999),
vipLevel: z.number().int().min(0).max(99),
combatPower: z.number().int().nonnegative(),
gold: z.number().int().nonnegative(),
diamonds: z.number().int().nonnegative(),
})
.strict();
export const GamePlayerStatusInputSchema = z
.object({
status: z.enum(['offline', 'banned']),
reason: z.string().trim().max(100).optional(),
durationDays: z.number().int().nonnegative().optional(),
})
.strict();
export type GamePlayerStatus = z.infer<typeof GamePlayerStatusSchema>;
export type GamePlayer = z.infer<typeof GamePlayerSchema>;
export type GamePlayerSummary = z.infer<typeof GamePlayerSummarySchema>;
export type GamePlayerList = z.infer<typeof GamePlayerListSchema>;
export type GamePlayerResourceInput = z.infer<typeof GamePlayerResourceInputSchema>;
export type GamePlayerStatusInput = z.infer<typeof GamePlayerStatusInputSchema>;
+77
View File
@@ -0,0 +1,77 @@
import { z } from 'zod';
export const GameServerStatusSchema = z.enum(['online', 'maintenance', 'offline']);
export const GameServerSchema = z
.object({
id: z.string().min(1),
name: z.string().min(1),
code: z.string().min(1),
region: z.string().min(1),
status: GameServerStatusSchema,
host: z.string().min(1),
port: z.number().int().min(1).max(65535),
onlinePlayers: z.number().int().nonnegative(),
capacity: z.number().int().positive(),
loadPercent: z.number().min(0).max(100),
version: z.string().min(1),
openedAt: z.string().min(1),
lastHeartbeat: z.string().min(1),
maintenanceNote: z.string().nullable(),
})
.strict();
export const GameServerSummarySchema = z
.object({
total: z.number().int().nonnegative(),
online: z.number().int().nonnegative(),
maintenance: z.number().int().nonnegative(),
offline: z.number().int().nonnegative(),
onlinePlayers: z.number().int().nonnegative(),
})
.strict();
export const GameServerRegionOptionSchema = z
.object({
value: z.string().min(1),
label: z.string().min(1),
})
.strict();
export const GameServerListSchema = z
.object({
items: z.array(GameServerSchema),
total: z.number().int().nonnegative(),
page: z.number().int().positive(),
pageSize: z.number().int().positive(),
summary: GameServerSummarySchema,
regionOptions: z.array(GameServerRegionOptionSchema),
})
.strict();
export const GameServerUpsertInputSchema = z
.object({
name: z.string().trim().min(1).max(40),
code: z.string().trim().min(1).max(32),
region: z.string().trim().min(1).max(24),
host: z.string().trim().min(1).max(255),
port: z.number().int().min(1).max(65535),
capacity: z.number().int().positive(),
version: z.string().trim().min(1).max(40),
openedAt: z.string().min(1),
})
.strict();
export const GameServerStatusInputSchema = z
.object({
status: z.enum(['online', 'maintenance']),
maintenanceNote: z.string().trim().max(200).optional(),
})
.strict();
export type GameServerStatus = z.infer<typeof GameServerStatusSchema>;
export type GameServer = z.infer<typeof GameServerSchema>;
export type GameServerSummary = z.infer<typeof GameServerSummarySchema>;
export type GameServerList = z.infer<typeof GameServerListSchema>;
export type GameServerUpsertInput = z.infer<typeof GameServerUpsertInputSchema>;
export type GameServerStatusInput = z.infer<typeof GameServerStatusInputSchema>;
+15
View File
@@ -0,0 +1,15 @@
import { z } from 'zod';
export const LoginFormSchema = z.object({
username: z.string().min(1, 'username'),
password: z.string().min(1, 'password'),
twoFactorCode: z.string().optional(),
});
export const TwoFactorCodeSchema = z.string().min(1, 'twoFactorCode');
export const TotpCodeSchema = z
.string()
.regex(/^\d{6}$/, 'pages.settings.security.twoFactorModalError');
export type LoginFormValues = z.infer<typeof LoginFormSchema>;
+43
View File
@@ -0,0 +1,43 @@
* {
scrollbar-width: none;
-ms-overflow-style: none;
}
*::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
.ant-dropdown .ant-dropdown-menu {
max-height: calc(100vh - 32px);
overflow-y: auto;
}
.ant-dropdown-menu-item:not(.ant-dropdown-menu-item-disabled):not(
.ant-dropdown-menu-item-danger
):hover,
.ant-dropdown-menu-item:not(.ant-dropdown-menu-item-disabled):not(
.ant-dropdown-menu-item-danger
):hover
.ant-dropdown-menu-title-content,
.ant-dropdown-menu-item:not(.ant-dropdown-menu-item-disabled):not(
.ant-dropdown-menu-item-danger
):hover
> .anticon {
color: var(--ant-color-primary) !important;
}
.ant-dropdown-menu-item:not(.ant-dropdown-menu-item-disabled):not(
.ant-dropdown-menu-item-danger
):hover {
background-color: color-mix(in srgb, var(--ant-color-primary) 14%, transparent) !important;
}
.ant-dropdown-menu-item-divider {
background-color: var(--ant-color-border) !important;
}
body.dark .ant-dropdown-menu-item-divider {
background-color: rgba(255, 255, 255, 0.12) !important;
}
+44
View File
@@ -0,0 +1,44 @@
import { screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { afterEach, expect, test } from 'vitest';
import AppSidebar from '@/layouts/AppSidebar';
import { renderWithProviders } from './test-utils';
afterEach(() => {
localStorage.clear();
});
function renderSidebar() {
return renderWithProviders(
<MemoryRouter>
<AppSidebar />
</MemoryRouter>,
);
}
test('keeps the desktop sidebar permanently expanded without pin or theme controls', () => {
localStorage.setItem('sidebar-pinned', 'false');
const first = renderSidebar();
const sidebar = first.container.querySelector('.ant-layout-sider');
const sidebarRoot = first.container.querySelector('.ant-sidebar');
expect(sidebar?.classList.contains('ant-layout-sider-collapsed')).toBe(false);
expect(sidebar?.getAttribute('style')).toContain('width: 220px');
expect(sidebarRoot?.getAttribute('style')).toBeNull();
expect(first.container.querySelector('.sidebar-pin')).toBeNull();
expect(first.container.querySelector('.sidebar-theme-cycle')).toBeNull();
expect(screen.getByText('游戏运营后台')).not.toBeNull();
expect(screen.getByText('总览')).not.toBeNull();
expect(screen.getByText('账号管理')).not.toBeNull();
expect(screen.getByText('区服管理')).not.toBeNull();
expect(screen.getByText('玩家管理')).not.toBeNull();
expect(screen.getByText('CDK管理')).not.toBeNull();
expect(screen.getByText('公告管理')).not.toBeNull();
expect(screen.getByText('订单管理')).not.toBeNull();
expect(screen.queryByText('公告分组')).toBeNull();
expect(screen.queryByText('公告发布')).toBeNull();
expect(first.container.querySelector('.sider-utility')).toBeNull();
expect(first.container.querySelector('.drawer-utility')).toBeNull();
expect(localStorage.getItem('sidebar-pinned')).toBe('false');
});
+44
View File
@@ -0,0 +1,44 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { setupHttp } from '@/api/http-init';
import { updateGameAccountStatus } from '@/api/queries/useGameAccountsQuery';
import { handlers as gameMockHandlers } from '@/mocks/handlers';
import { server } from './msw/server';
const ORIGIN = 'http://localhost';
describe('game account status API contract', () => {
beforeEach(() => {
vi.stubGlobal('document', { querySelector: () => null });
window.ADMIN_UI_BASE_PATH = ORIGIN;
setupHttp();
server.use(...gameMockHandlers);
});
afterEach(() => {
vi.unstubAllGlobals();
delete window.ADMIN_UI_BASE_PATH;
});
it('sends JSON that the mock and future Go endpoint can decode', async () => {
const banned = await updateGameAccountStatus({
accountId: '10000002',
status: 'banned',
reason: '接口契约测试',
durationDays: 7,
});
expect(banned.status).toBe('banned');
expect(banned.banReason).toBe('接口契约测试');
expect(banned.banUntil).not.toBeNull();
const restored = await updateGameAccountStatus({
accountId: '10000002',
status: 'active',
});
expect(restored.status).toBe('active');
expect(restored.banReason).toBeNull();
expect(restored.banUntil).toBeNull();
});
});
+255
View File
@@ -0,0 +1,255 @@
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { beforeEach, expect, test, vi } from 'vitest';
import AccountsPage from '@/pages/accounts/AccountsPage';
import type { GameAccount } from '@/schemas/gameAccount';
import { renderWithProviders } from './test-utils';
const queryMock = vi.hoisted(() => vi.fn());
const mutateAsyncMock = vi.hoisted(() => vi.fn());
const refreshMock = vi.hoisted(() => vi.fn());
vi.mock('@/layouts/AppSidebar', () => ({
default: () => <aside data-testid="sidebar" />,
}));
vi.mock('@/api/queries/useGameAccountsQuery', () => ({
useGameAccountsQuery: (filters: unknown) => queryMock(filters),
useGameAccountStatusMutation: () => ({
mutateAsync: mutateAsyncMock,
isPending: false,
}),
}));
const activeAccount: GameAccount = {
id: '10000001',
username: 'account_0001',
platform: 'android',
channel: '官网',
characterCount: 3,
deviceCount: 2,
totalRecharge: 486320,
status: 'active',
registerTime: '2026-08-27T02:00:00.000Z',
lastLoginTime: '2026-08-27T11:00:00.000Z',
lastLoginIp: '203.0.113.20',
banReason: null,
banUntil: null,
};
const bannedAccount: GameAccount = {
...activeAccount,
id: '10000002',
username: 'account_0002',
platform: 'ios',
channel: '应用商店',
status: 'banned',
banReason: '异常支付行为复核中',
banUntil: '2026-09-03T11:00:00.000Z',
};
const lockedAccount: GameAccount = {
...activeAccount,
id: '10000003',
username: 'account_0003',
platform: 'pc',
channel: 'Steam',
status: 'locked',
};
function queryResult(accounts: GameAccount[] = [activeAccount, bannedAccount, lockedAccount]) {
return {
accounts,
total: 12864,
summary: {
total: 12864,
newToday: 216,
active: 12140,
banned: 524,
locked: 200,
},
platformOptions: [
{ value: 'android', label: 'Android' },
{ value: 'ios', label: 'iOS' },
{ value: 'pc', label: 'PC' },
],
loading: false,
fetched: true,
fetchError: '',
refresh: refreshMock,
};
}
beforeEach(() => {
queryMock.mockReset();
queryMock.mockReturnValue(queryResult());
mutateAsyncMock.mockReset();
refreshMock.mockReset();
sessionStorage.clear();
});
function renderAccountsPage() {
return renderWithProviders(
<MemoryRouter initialEntries={['/accounts']}>
<AccountsPage />
</MemoryRouter>,
);
}
test('renders login account metrics and values without thousand separators', () => {
renderAccountsPage();
expect(screen.getByRole('heading', { name: '账号管理' })).not.toBeNull();
expect(screen.queryByText('游戏登录账号')).toBeNull();
expect(screen.getByText('总账号')).not.toBeNull();
expect(screen.getByText('12864')).not.toBeNull();
expect(screen.queryByText('12,864')).toBeNull();
expect(screen.getAllByText('¥486320').length).toBeGreaterThan(0);
expect(screen.queryByText('¥486,320')).toBeNull();
expect(screen.getByText('20 / 页')).not.toBeNull();
expect(screen.queryByText('20 / page')).toBeNull();
expect(document.querySelector('.accounts-table-card .ant-table-virtual')).toBeNull();
expect(document.querySelector('.accounts-table-card .ant-table-body')).not.toBeNull();
expect(screen.getByText('account_0001')).not.toBeNull();
expect(screen.getByText('account_0002')).not.toBeNull();
const firstRow = document.querySelector('.accounts-table-card .ant-table-tbody .ant-table-row');
const firstRowCells = firstRow?.querySelectorAll(':scope > .ant-table-cell') ?? [];
const expectedCellClasses = [
'accounts-cell-start',
'accounts-cell-start',
'accounts-cell-start',
'accounts-cell-start',
'accounts-cell-start',
'accounts-cell-start',
'accounts-cell-start',
'accounts-cell-start',
'accounts-cell-start',
];
expect(firstRowCells).toHaveLength(expectedCellClasses.length);
expectedCellClasses.forEach((className, index) => {
expect(firstRowCells[index].classList.contains(className)).toBe(true);
});
});
test('applies keyword and status filters from the account toolbar', async () => {
renderAccountsPage();
fireEvent.change(screen.getByLabelText('搜索账号'), {
target: { value: '203.0.113.20' },
});
fireEvent.click(screen.getByRole('button', { name: '搜索账号数据' }));
await waitFor(() => {
expect(queryMock).toHaveBeenLastCalledWith(
expect.objectContaining({ keyword: '203.0.113.20', page: 1 }),
);
});
fireEvent.mouseDown(screen.getByLabelText('账号状态筛选'));
fireEvent.click(
await screen.findByText('已封禁', { selector: '.ant-select-item-option-content' }),
);
await waitFor(() => {
expect(queryMock).toHaveBeenLastCalledWith(
expect.objectContaining({ keyword: '203.0.113.20', status: 'banned', page: 1 }),
);
});
});
test('renders the account controls as one toolbar with refresh last', () => {
renderAccountsPage();
expect(screen.queryByText('账号列表')).toBeNull();
expect(screen.queryByText('共 12864 个账号')).toBeNull();
const actions = document.querySelector('.accounts-filter-actions');
if (!actions) throw new Error('Account filter actions not found');
expect(actions.contains(screen.getByLabelText('搜索账号'))).toBe(true);
expect(actions.contains(screen.getByLabelText('账号状态筛选'))).toBe(true);
expect(actions.contains(screen.getByLabelText('账号平台筛选'))).toBe(true);
expect(actions.lastElementChild).toBe(screen.getByRole('button', { name: '刷新账号数据' }));
});
test('opens account details', async () => {
renderAccountsPage();
fireEvent.click(screen.getByLabelText('查看账号 account_0001'));
expect(await screen.findByText('账号详情')).not.toBeNull();
expect(screen.getAllByText('203.0.113.20').length).toBeGreaterThan(0);
expect(screen.getAllByText('¥486320').length).toBeGreaterThan(0);
});
test('submits a ban operation', async () => {
mutateAsyncMock.mockResolvedValue({
...activeAccount,
status: 'banned',
banReason: '异常登录行为',
});
renderAccountsPage();
fireEvent.click(screen.getByLabelText('封禁账号 account_0001'));
const modalTitle = await screen.findByText('封禁账号:account_0001');
const dialog = modalTitle.closest<HTMLElement>('.ant-modal');
if (!dialog) throw new Error('Ban modal not found');
fireEvent.change(within(dialog).getByLabelText('封禁原因'), {
target: { value: '异常登录行为' },
});
fireEvent.click(within(dialog).getByRole('button', { name: '确认封禁' }));
await waitFor(() => {
expect(mutateAsyncMock).toHaveBeenCalledWith({
accountId: '10000001',
status: 'banned',
reason: '异常登录行为',
durationDays: 7,
});
});
});
test('restores a banned account after confirmation', async () => {
mutateAsyncMock.mockResolvedValue({
...bannedAccount,
status: 'active',
banReason: null,
banUntil: null,
});
renderAccountsPage();
fireEvent.click(screen.getByLabelText('解封账号 account_0002'));
const modalTitle = await screen.findByText('解除封禁');
const dialog = modalTitle.closest<HTMLElement>('.ant-modal');
if (!dialog) throw new Error('Recovery modal not found');
expect(within(dialog).getByText('账号 account_0002 将恢复为正常状态。')).not.toBeNull();
fireEvent.click(within(dialog).getByRole('button', { name: '确认解封' }));
await waitFor(() => {
expect(mutateAsyncMock).toHaveBeenCalledWith({
accountId: '10000002',
status: 'active',
});
});
});
test('unlocks a locked account after confirmation', async () => {
mutateAsyncMock.mockResolvedValue({
...lockedAccount,
status: 'active',
});
renderAccountsPage();
fireEvent.click(screen.getByLabelText('解锁账号 account_0003'));
const modalTitle = await screen.findByText('解锁账号');
const dialog = modalTitle.closest<HTMLElement>('.ant-modal');
if (!dialog) throw new Error('Unlock modal not found');
fireEvent.click(within(dialog).getByRole('button', { name: '确认解锁' }));
await waitFor(() => {
expect(mutateAsyncMock).toHaveBeenCalledWith({
accountId: '10000003',
status: 'active',
});
});
});
+123
View File
@@ -0,0 +1,123 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { setupHttp } from '@/api/http-init';
import {
fetchGameAnnouncements,
saveGameAnnouncement,
updateGameAnnouncementStatus,
} from '@/api/queries/useGameAnnouncementsQuery';
import { handlers as gameMockHandlers } from '@/mocks/handlers';
import {
GameAnnouncementSchema,
GameAnnouncementStatusInputSchema,
GameAnnouncementUpsertInputSchema,
} from '@/schemas/gameAnnouncement';
import { server } from './msw/server';
const ORIGIN = 'http://localhost';
describe('game announcement API contract', () => {
beforeEach(() => {
vi.stubGlobal('document', { querySelector: () => null });
window.ADMIN_UI_BASE_PATH = ORIGIN;
setupHttp();
server.use(...gameMockHandlers);
});
afterEach(() => {
vi.unstubAllGlobals();
delete window.ADMIN_UI_BASE_PATH;
});
it('lists, creates, edits and changes announcement status', async () => {
const list = await fetchGameAnnouncements({
page: 1,
pageSize: 20,
keyword: '',
status: 'all',
channel: 'all',
});
expect(list.items.length).toBeGreaterThan(0);
expect(list.summary.total).toBeGreaterThan(0);
expect(list.serverOptions.length).toBeGreaterThan(0);
const created = await saveGameAnnouncement({
data: {
title: '契约测试公告',
content: '用于验证未来 Go 服务端接入的数据契约。',
channel: 'login',
serverIds: [],
priority: 80,
startAt: '2026-08-28T12:00:00.000Z',
endAt: '2026-09-04T12:00:00.000Z',
mode: 'publish',
},
});
expect(created.title).toBe('契约测试公告');
expect(created.status).toBe('scheduled');
const edited = await saveGameAnnouncement({
announcementId: created.id,
data: {
title: '契约测试公告已编辑',
content: created.content,
channel: 'banner',
serverIds: [],
priority: 90,
startAt: created.startAt,
endAt: created.endAt,
mode: 'publish',
},
});
expect(edited.title).toBe('契约测试公告已编辑');
expect(edited.channel).toBe('banner');
const offline = await updateGameAnnouncementStatus({
announcementId: created.id,
action: 'offline',
});
expect(offline.status).toBe('offline');
});
it('rejects unknown fields in announcement objects', () => {
const validAnnouncement = {
id: 'announcement-1',
title: '版本更新公告',
content: '游戏将更新至最新版本。',
channel: 'login',
status: 'active',
scope: 'all',
serverIds: [],
serverNames: [],
priority: 80,
startAt: '2026-08-26T12:00:00.000Z',
endAt: '2026-09-03T12:00:00.000Z',
createdAt: '2026-08-25T12:00:00.000Z',
updatedAt: '2026-08-26T12:00:00.000Z',
createdBy: 'admin',
publishedAt: '2026-08-26T11:00:00.000Z',
viewCount: 3200000,
clickCount: 180000,
};
expect(
GameAnnouncementSchema.safeParse({ ...validAnnouncement, unexpected: true }).success,
).toBe(false);
expect(
GameAnnouncementUpsertInputSchema.safeParse({
title: '测试公告',
content: '测试内容',
channel: 'login',
serverIds: [],
priority: 80,
startAt: '2026-08-28T12:00:00.000Z',
endAt: '2026-09-04T12:00:00.000Z',
mode: 'publish',
unexpected: true,
}).success,
).toBe(false);
expect(
GameAnnouncementStatusInputSchema.safeParse({ action: 'offline', unexpected: true }).success,
).toBe(false);
});
});
+238
View File
@@ -0,0 +1,238 @@
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { beforeEach, expect, test, vi } from 'vitest';
import AnnouncementsPage from '@/pages/announcements/AnnouncementsPage';
import type { GameAnnouncement } from '@/schemas/gameAnnouncement';
import { renderWithProviders } from './test-utils';
const queryMock = vi.hoisted(() => vi.fn());
const saveMutateAsyncMock = vi.hoisted(() => vi.fn());
const statusMutateAsyncMock = vi.hoisted(() => vi.fn());
const refreshMock = vi.hoisted(() => vi.fn());
vi.mock('@/layouts/AppSidebar', () => ({
default: () => <aside data-testid="sidebar" />,
}));
vi.mock('@/api/queries/useGameAnnouncementsQuery', () => ({
useGameAnnouncementsQuery: (filters: unknown) => queryMock(filters),
useSaveGameAnnouncementMutation: () => ({
mutateAsync: saveMutateAsyncMock,
isPending: false,
}),
useGameAnnouncementStatusMutation: () => ({
mutateAsync: statusMutateAsyncMock,
isPending: false,
}),
}));
const activeAnnouncement: GameAnnouncement = {
id: 'announcement-1',
title: '版本更新公告',
content: '游戏将更新至最新版本,请各位玩家提前结束副本并保存进度。',
channel: 'login',
status: 'active',
scope: 'all',
serverIds: [],
serverNames: [],
priority: 80,
startAt: '2026-08-26T12:00:00.000Z',
endAt: '2026-09-03T12:00:00.000Z',
createdAt: '2026-08-25T12:00:00.000Z',
updatedAt: '2026-08-26T12:00:00.000Z',
createdBy: 'admin',
publishedAt: '2026-08-26T11:00:00.000Z',
viewCount: 3200000,
clickCount: 180000,
};
const draftAnnouncement: GameAnnouncement = {
...activeAnnouncement,
id: 'announcement-2',
title: '新区开放公告',
channel: 'banner',
status: 'draft',
scope: 'servers',
serverIds: ['aurora-1'],
serverNames: ['曙光一区'],
publishedAt: null,
viewCount: 0,
clickCount: 0,
};
function queryResult(announcements: GameAnnouncement[] = [activeAnnouncement, draftAnnouncement]) {
return {
announcements,
total: 98765,
summary: {
total: 98765,
active: 45678,
scheduled: 12345,
draft: 9876,
inactive: 20866,
},
serverOptions: [
{ value: 'aurora-1', label: '曙光一区' },
{ value: 'spark-2', label: '星火二区' },
],
loading: false,
fetched: true,
fetchError: '',
refresh: refreshMock,
};
}
beforeEach(() => {
queryMock.mockReset();
queryMock.mockReturnValue(queryResult());
saveMutateAsyncMock.mockReset();
statusMutateAsyncMock.mockReset();
refreshMock.mockReset();
sessionStorage.clear();
});
function renderAnnouncementsPage() {
return renderWithProviders(
<MemoryRouter initialEntries={['/announcements']}>
<AnnouncementsPage />
</MemoryRouter>,
);
}
test('renders one announcement page with native scrolling and ungrouped numbers', () => {
renderAnnouncementsPage();
expect(screen.getByRole('heading', { name: '公告管理' })).not.toBeNull();
expect(screen.queryByText('公告分组')).toBeNull();
expect(screen.queryByText('公告发布')).toBeNull();
expect(screen.getByText('98765')).not.toBeNull();
expect(screen.queryByText('98,765')).toBeNull();
expect(screen.getByText('曝光 3200000')).not.toBeNull();
expect(screen.queryByText('曝光 3,200,000')).toBeNull();
expect(screen.getByText('20 / 页')).not.toBeNull();
expect(document.querySelector('.announcements-table-card .ant-table-virtual')).toBeNull();
expect(document.querySelector('.announcements-table-card .ant-table-body')).not.toBeNull();
const firstRow = document.querySelector(
'.announcements-table-card .ant-table-tbody .ant-table-row',
);
const firstRowCells = firstRow?.querySelectorAll(':scope > .ant-table-cell') ?? [];
expect(firstRowCells).toHaveLength(9);
firstRowCells.forEach((cell) => {
expect(cell.classList.contains('announcements-cell-start')).toBe(true);
});
});
test('keeps announcement controls in one right-aligned toolbar with refresh last', () => {
renderAnnouncementsPage();
expect(screen.queryByText('公告列表')).toBeNull();
expect(screen.queryByText('共 98765 个公告')).toBeNull();
const actions = document.querySelector('.announcements-filter-actions');
if (!actions) throw new Error('Announcement filter actions not found');
expect(actions.querySelector('.announcements-create-button')?.textContent).toContain('新建公告');
expect(actions.contains(screen.getByLabelText('搜索公告'))).toBe(true);
expect(actions.contains(screen.getByLabelText('公告状态筛选'))).toBe(true);
expect(actions.contains(screen.getByLabelText('公告渠道筛选'))).toBe(true);
expect(actions.lastElementChild).toBe(screen.getByRole('button', { name: '刷新公告数据' }));
});
test('applies keyword, status and channel filters', async () => {
renderAnnouncementsPage();
fireEvent.change(screen.getByLabelText('搜索公告'), { target: { value: '更新' } });
fireEvent.click(screen.getByRole('button', { name: '搜索公告数据' }));
await waitFor(() => {
expect(queryMock).toHaveBeenLastCalledWith(
expect.objectContaining({ keyword: '更新', page: 1 }),
);
});
fireEvent.mouseDown(screen.getByLabelText('公告状态筛选'));
fireEvent.click(
await screen.findByText('已下线', { selector: '.ant-select-item-option-content' }),
);
await waitFor(() => {
expect(queryMock).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'offline' }));
});
fireEvent.mouseDown(screen.getByLabelText('公告渠道筛选'));
fireEvent.click(
await screen.findByText('跑马灯', { selector: '.ant-select-item-option-content' }),
);
await waitFor(() => {
expect(queryMock).toHaveBeenLastCalledWith(expect.objectContaining({ channel: 'marquee' }));
});
});
test('opens announcement details', async () => {
renderAnnouncementsPage();
fireEvent.click(screen.getByLabelText('查看公告 版本更新公告'));
expect(await screen.findByText('公告详情')).not.toBeNull();
expect(screen.getAllByText('版本更新公告').length).toBeGreaterThan(0);
expect(screen.getAllByText(/曝光 3200000/).length).toBeGreaterThan(0);
});
test('creates an announcement from the single management page', async () => {
saveMutateAsyncMock.mockResolvedValue({ ...activeAnnouncement, id: 'announcement-created' });
renderAnnouncementsPage();
const createButton = document.querySelector<HTMLButtonElement>('.announcements-create-button');
if (!createButton) throw new Error('Create announcement button not found');
fireEvent.click(createButton);
const dialog = await screen.findByRole('dialog');
fireEvent.change(within(dialog).getByLabelText('公告标题'), {
target: { value: '周年庆活动公告' },
});
fireEvent.change(within(dialog).getByLabelText('公告内容'), {
target: { value: '周年庆活动现已开启。' },
});
fireEvent.click(within(dialog).getByRole('button', { name: /发布公告/ }));
await waitFor(() => {
expect(saveMutateAsyncMock).toHaveBeenCalledWith({
announcementId: undefined,
data: {
title: '周年庆活动公告',
content: '周年庆活动现已开启。',
channel: 'login',
serverIds: [],
priority: 50,
startAt: expect.any(String),
endAt: expect.any(String),
mode: 'publish',
},
});
});
});
test('takes active announcements offline and publishes drafts', async () => {
statusMutateAsyncMock
.mockResolvedValueOnce({ ...activeAnnouncement, status: 'offline' })
.mockResolvedValueOnce({ ...draftAnnouncement, status: 'active' });
renderAnnouncementsPage();
fireEvent.click(screen.getByLabelText('下线公告 版本更新公告'));
let dialog = await screen.findByRole('dialog');
fireEvent.click(within(dialog).getByRole('button', { name: '确认下线' }));
await waitFor(() => {
expect(statusMutateAsyncMock).toHaveBeenCalledWith({
announcementId: 'announcement-1',
action: 'offline',
});
});
fireEvent.click(screen.getByLabelText('发布公告 新区开放公告'));
const publishTitle = await screen.findByText('发布公告', { selector: '.ant-modal-title' });
dialog = publishTitle.closest<HTMLElement>('.ant-modal')!;
fireEvent.click(within(dialog).getByRole('button', { name: '确认发布' }));
await waitFor(() => {
expect(statusMutateAsyncMock).toHaveBeenLastCalledWith({
announcementId: 'announcement-2',
action: 'publish',
});
});
});
+106
View File
@@ -0,0 +1,106 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { setupHttp } from '@/api/http-init';
import {
createGameCdkBatch,
fetchGameCdks,
updateGameCdkStatus,
} from '@/api/queries/useGameCdksQuery';
import { handlers as gameMockHandlers } from '@/mocks/handlers';
import {
GameCdkBatchCreateInputSchema,
GameCdkSchema,
GameCdkStatusInputSchema,
} from '@/schemas/gameCdk';
import { server } from './msw/server';
const ORIGIN = 'http://localhost';
describe('game CDK API contract', () => {
beforeEach(() => {
vi.stubGlobal('document', { querySelector: () => null });
window.ADMIN_UI_BASE_PATH = ORIGIN;
setupHttp();
server.use(...gameMockHandlers);
});
afterEach(() => {
vi.unstubAllGlobals();
delete window.ADMIN_UI_BASE_PATH;
});
it('lists, creates and changes CDK status using the future Go contract', async () => {
const list = await fetchGameCdks({
page: 1,
pageSize: 20,
keyword: '',
status: 'all',
batchId: 'all',
});
expect(list.items.length).toBeGreaterThan(0);
expect(list.summary.total).toBeGreaterThan(0);
expect(list.batchOptions.length).toBeGreaterThan(0);
expect(list.serverOptions.length).toBeGreaterThan(0);
const created = await createGameCdkBatch({
batchName: '契约测试礼包',
prefix: 'CONTRACT',
quantity: 3,
rewardName: '契约测试奖励',
rewards: [
{ itemId: 'diamond', itemName: '钻石', quantity: 500 },
{ itemId: 'gold', itemName: '金币', quantity: 200000 },
],
serverId: null,
expiresAt: '2026-09-27T12:00:00.000Z',
});
expect(created.batchName).toBe('契约测试礼包');
expect(created.createdCount).toBe(3);
const target = list.items.find((cdk) => cdk.status === 'unused');
if (!target) throw new Error('Unused CDK fixture not found');
const disabled = await updateGameCdkStatus({ cdkId: target.id, status: 'disabled' });
expect(disabled.status).toBe('disabled');
const enabled = await updateGameCdkStatus({ cdkId: target.id, status: 'unused' });
expect(enabled.status).toBe('unused');
});
it('rejects unknown fields in CDK response and mutation objects', () => {
const validCdk = {
id: 'cdk-1',
code: 'LAUNCH-000001',
batchId: 'launch-gift',
batchName: '开服庆典礼包',
status: 'unused',
rewardName: '开服庆典礼包',
rewards: [{ itemId: 'diamond', itemName: '钻石', quantity: 500 }],
scope: 'all',
serverId: null,
serverName: null,
createdAt: '2026-08-20T02:00:00.000Z',
expiresAt: '2026-09-27T12:00:00.000Z',
usedByPlayerId: null,
usedByPlayerName: null,
usedAt: null,
createdBy: 'admin',
};
expect(GameCdkSchema.safeParse({ ...validCdk, unexpected: true }).success).toBe(false);
expect(
GameCdkBatchCreateInputSchema.safeParse({
batchName: '契约测试礼包',
prefix: 'CONTRACT',
quantity: 3,
rewardName: '契约测试奖励',
rewards: [{ itemId: 'diamond', itemName: '钻石', quantity: 500 }],
serverId: null,
expiresAt: null,
unexpected: true,
}).success,
).toBe(false);
expect(
GameCdkStatusInputSchema.safeParse({ status: 'disabled', unexpected: true }).success,
).toBe(false);
});
});
+270
View File
@@ -0,0 +1,270 @@
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { beforeEach, expect, test, vi } from 'vitest';
import CdksPage from '@/pages/cdks/CdksPage';
import type { GameCdk } from '@/schemas/gameCdk';
import { renderWithProviders } from './test-utils';
const queryMock = vi.hoisted(() => vi.fn());
const createMutateAsyncMock = vi.hoisted(() => vi.fn());
const statusMutateAsyncMock = vi.hoisted(() => vi.fn());
const refreshMock = vi.hoisted(() => vi.fn());
vi.mock('@/layouts/AppSidebar', () => ({
default: () => <aside data-testid="sidebar" />,
}));
vi.mock('@/api/queries/useGameCdksQuery', () => ({
useGameCdksQuery: (filters: unknown) => queryMock(filters),
useCreateGameCdkBatchMutation: () => ({
mutateAsync: createMutateAsyncMock,
isPending: false,
}),
useGameCdkStatusMutation: () => ({
mutateAsync: statusMutateAsyncMock,
isPending: false,
}),
}));
const unusedCdk: GameCdk = {
id: 'cdk-1',
code: 'LAUNCH-000001',
batchId: 'launch-gift',
batchName: '开服庆典礼包',
status: 'unused',
rewardName: '开服庆典礼包',
rewards: [
{ itemId: 'diamond', itemName: '钻石', quantity: 500 },
{ itemId: 'gold', itemName: '金币', quantity: 5000000 },
],
scope: 'all',
serverId: null,
serverName: null,
createdAt: '2026-08-20T02:00:00.000Z',
expiresAt: '2026-09-27T12:00:00.000Z',
usedByPlayerId: null,
usedByPlayerName: null,
usedAt: null,
createdBy: 'admin',
};
const usedCdk: GameCdk = {
...unusedCdk,
id: 'cdk-2',
code: 'RETURN-000002',
batchId: 'return-gift',
batchName: '老玩家回归礼包',
status: 'used',
usedByPlayerId: '50000001',
usedByPlayerName: '长风01',
usedAt: '2026-08-27T11:00:00.000Z',
};
const disabledCdk: GameCdk = {
...unusedCdk,
id: 'cdk-3',
code: 'COMM-000003',
batchId: 'community-gift',
batchName: '社区活动礼包',
status: 'disabled',
};
function queryResult(cdks: GameCdk[] = [unusedCdk, usedCdk, disabledCdk]) {
return {
cdks,
total: 98765,
summary: {
total: 98765,
unused: 76543,
used: 20000,
expired: 2000,
disabled: 222,
},
batchOptions: [
{ value: 'launch-gift', label: '开服庆典礼包' },
{ value: 'return-gift', label: '老玩家回归礼包' },
],
serverOptions: [
{ value: 'aurora-1', label: '曙光一区' },
{ value: 'server-4', label: '荣耀四区' },
],
loading: false,
fetched: true,
fetchError: '',
refresh: refreshMock,
};
}
beforeEach(() => {
queryMock.mockReset();
queryMock.mockReturnValue(queryResult());
createMutateAsyncMock.mockReset();
statusMutateAsyncMock.mockReset();
refreshMock.mockReset();
sessionStorage.clear();
});
function renderCdksPage() {
return renderWithProviders(
<MemoryRouter initialEntries={['/cdks']}>
<CdksPage />
</MemoryRouter>,
);
}
test('renders CDK metrics, native scrolling table and ungrouped numbers', () => {
renderCdksPage();
expect(screen.getByRole('heading', { name: 'CDK管理' })).not.toBeNull();
expect(screen.getByText('CDK 总数')).not.toBeNull();
expect(screen.getByText('98765')).not.toBeNull();
expect(screen.queryByText('98,765')).toBeNull();
expect(screen.getAllByText('钻石 500 / 金币 5000000').length).toBeGreaterThan(0);
expect(screen.queryByText('钻石 500 / 金币 5,000,000')).toBeNull();
expect(screen.getByText('20 / 页')).not.toBeNull();
expect(document.querySelector('.cdks-table-card .ant-table-virtual')).toBeNull();
expect(document.querySelector('.cdks-table-card .ant-table-body')).not.toBeNull();
const firstRow = document.querySelector('.cdks-table-card .ant-table-tbody .ant-table-row');
const firstRowCells = firstRow?.querySelectorAll(':scope > .ant-table-cell') ?? [];
expect(firstRowCells).toHaveLength(9);
firstRowCells.forEach((cell) => {
expect(cell.classList.contains('cdks-cell-start')).toBe(true);
});
});
test('keeps CDK controls in one right-aligned toolbar with refresh last', () => {
renderCdksPage();
expect(screen.queryByText('CDK 列表')).toBeNull();
expect(screen.queryByText('共 98765 个 CDK')).toBeNull();
const actions = document.querySelector('.cdks-filter-actions');
if (!actions) throw new Error('CDK filter actions not found');
expect(actions.querySelector('.cdks-create-button')?.textContent).toContain('生成 CDK');
expect(actions.contains(screen.getByLabelText('搜索 CDK'))).toBe(true);
expect(actions.contains(screen.getByLabelText('CDK 状态筛选'))).toBe(true);
expect(actions.contains(screen.getByLabelText('CDK 批次筛选'))).toBe(true);
expect(actions.lastElementChild).toBe(screen.getByRole('button', { name: '刷新 CDK 数据' }));
});
test('applies keyword, status and batch filters', async () => {
renderCdksPage();
fireEvent.change(screen.getByLabelText('搜索 CDK'), { target: { value: 'LAUNCH' } });
fireEvent.click(screen.getByRole('button', { name: '搜索 CDK 数据' }));
await waitFor(() => {
expect(queryMock).toHaveBeenLastCalledWith(
expect.objectContaining({ keyword: 'LAUNCH', page: 1 }),
);
});
fireEvent.mouseDown(screen.getByLabelText('CDK 状态筛选'));
fireEvent.click(
await screen.findByText('已停用', { selector: '.ant-select-item-option-content' }),
);
await waitFor(() => {
expect(queryMock).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'disabled' }));
});
fireEvent.mouseDown(screen.getByLabelText('CDK 批次筛选'));
fireEvent.click(
await screen.findByText('开服庆典礼包', { selector: '.ant-select-item-option-content' }),
);
await waitFor(() => {
expect(queryMock).toHaveBeenLastCalledWith(expect.objectContaining({ batchId: 'launch-gift' }));
});
});
test('opens CDK details', async () => {
renderCdksPage();
fireEvent.click(screen.getByLabelText('查看 CDK LAUNCH-000001'));
expect(await screen.findByText('CDK 详情')).not.toBeNull();
expect(screen.getAllByText('LAUNCH-000001').length).toBeGreaterThan(0);
expect(screen.getAllByText('5000000').length).toBeGreaterThan(0);
});
test('falls back to the document copy command on HTTP pages', async () => {
const clipboardWrite = vi.fn().mockRejectedValue(new Error('Clipboard unavailable'));
const execCommand = vi.fn().mockReturnValue(true);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: clipboardWrite },
});
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: execCommand,
});
renderCdksPage();
fireEvent.click(screen.getByLabelText('复制 CDK LAUNCH-000001'));
await waitFor(() => {
expect(clipboardWrite).toHaveBeenCalledWith('LAUNCH-000001');
expect(execCommand).toHaveBeenCalledWith('copy');
});
});
test('creates a structured CDK batch', async () => {
createMutateAsyncMock.mockResolvedValue({
batchId: 'batch-created',
batchName: '周年庆社区礼包',
createdCount: 100,
});
renderCdksPage();
const createButton = document.querySelector<HTMLButtonElement>('.cdks-create-button');
if (!createButton) throw new Error('Create CDK button not found');
fireEvent.click(createButton);
const dialog = await screen.findByRole('dialog');
fireEvent.change(within(dialog).getByLabelText('批次名称'), {
target: { value: '周年庆社区礼包' },
});
fireEvent.change(within(dialog).getByLabelText('CDK 前缀'), { target: { value: 'anniv' } });
fireEvent.change(within(dialog).getByLabelText('礼包名称'), {
target: { value: '周年庆福利礼包' },
});
fireEvent.click(within(dialog).getByRole('button', { name: '确认生成' }));
await waitFor(() => {
expect(createMutateAsyncMock).toHaveBeenCalledWith({
batchName: '周年庆社区礼包',
prefix: 'ANNIV',
quantity: 100,
rewardName: '周年庆福利礼包',
rewards: [{ itemId: 'diamond', itemName: '钻石', quantity: 500 }],
serverId: null,
expiresAt: expect.any(String),
});
});
});
test('disables and enables CDKs', async () => {
statusMutateAsyncMock
.mockResolvedValueOnce({ ...unusedCdk, status: 'disabled' })
.mockResolvedValueOnce({ ...disabledCdk, status: 'unused' });
renderCdksPage();
fireEvent.click(screen.getByLabelText('停用 CDK LAUNCH-000001'));
let dialog = await screen.findByRole('dialog');
fireEvent.click(within(dialog).getByRole('button', { name: '确认停用' }));
await waitFor(() => {
expect(statusMutateAsyncMock).toHaveBeenCalledWith({
cdkId: 'cdk-1',
status: 'disabled',
});
});
fireEvent.click(screen.getByLabelText('启用 CDK COMM-000003'));
const enableTitle = await screen.findByText('启用 CDK', { selector: '.ant-modal-title' });
dialog = enableTitle.closest<HTMLElement>('.ant-modal')!;
fireEvent.click(within(dialog).getByRole('button', { name: '确认启用' }));
await waitFor(() => {
expect(statusMutateAsyncMock).toHaveBeenLastCalledWith({
cdkId: 'cdk-3',
status: 'unused',
});
});
});
+120
View File
@@ -0,0 +1,120 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { setupHttp } from '@/api/http-init';
import {
deliverGameOrder,
fetchGameOrders,
refundGameOrder,
} from '@/api/queries/useGameOrdersQuery';
import { handlers as gameMockHandlers } from '@/mocks/handlers';
import {
GameOrderDeliveryInputSchema,
GameOrderRefundInputSchema,
GameOrderSchema,
} from '@/schemas/gameOrder';
import { server } from './msw/server';
const ORIGIN = 'http://localhost';
describe('game order API contract', () => {
beforeEach(() => {
vi.stubGlobal('document', { querySelector: () => null });
window.ADMIN_UI_BASE_PATH = ORIGIN;
setupHttp();
server.use(...gameMockHandlers);
});
afterEach(() => {
vi.unstubAllGlobals();
delete window.ADMIN_UI_BASE_PATH;
});
it('lists, delivers and refunds orders using the future Go contract', async () => {
const list = await fetchGameOrders({
page: 1,
pageSize: 20,
keyword: '',
status: 'all',
channel: 'all',
serverId: 'all',
});
expect(list.items.length).toBeGreaterThan(0);
expect(list.summary.total).toBeGreaterThan(0);
expect(list.summary.paidAmount).toBeGreaterThan(0);
expect(list.serverOptions.length).toBeGreaterThan(0);
const paidList = await fetchGameOrders({
page: 1,
pageSize: 20,
keyword: '',
status: 'paid',
channel: 'all',
serverId: 'all',
});
const deliveryTarget = paidList.items[0];
if (!deliveryTarget) throw new Error('Paid order fixture not found');
const delivered = await deliverGameOrder({
orderId: deliveryTarget.id,
operatorNote: '契约测试补发',
});
expect(delivered.status).toBe('delivered');
expect(delivered.operatorNote).toBe('契约测试补发');
const deliveredList = await fetchGameOrders({
page: 1,
pageSize: 20,
keyword: '',
status: 'delivered',
channel: 'all',
serverId: 'all',
});
const refundTarget = deliveredList.items[0];
if (!refundTarget) throw new Error('Delivered order fixture not found');
const refunding = await refundGameOrder({
orderId: refundTarget.id,
reason: '契约测试退款',
});
expect(refunding.status).toBe('refunding');
expect(refunding.refundReason).toBe('契约测试退款');
});
it('rejects unknown fields in order response and mutation objects', () => {
const validOrder = {
id: 'GO20260827000001',
providerOrderId: 'WEB-860000000',
accountId: 'account-1',
accountName: 'player@example.com',
playerId: '50000001',
playerName: '长风01',
serverId: 'aurora-1',
serverName: '曙光一区',
productId: 'monthly-card',
productName: '月卡',
quantity: 1,
items: [{ itemId: 'diamond', itemName: '钻石', quantity: 300 }],
amount: 30,
currency: 'CNY',
channel: 'official',
status: 'paid',
createdAt: '2026-08-27T10:00:00.000Z',
paidAt: '2026-08-27T10:02:00.000Z',
deliveredAt: null,
refundedAt: null,
updatedAt: '2026-08-27T10:02:00.000Z',
clientIp: '198.51.100.40',
refundReason: null,
operatorNote: null,
};
expect(GameOrderSchema.safeParse({ ...validOrder, unexpected: true }).success).toBe(false);
expect(
GameOrderDeliveryInputSchema.safeParse({
operatorNote: '测试补发',
unexpected: true,
}).success,
).toBe(false);
expect(
GameOrderRefundInputSchema.safeParse({ reason: '测试退款', unexpected: true }).success,
).toBe(false);
});
});
+243
View File
@@ -0,0 +1,243 @@
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { beforeEach, expect, test, vi } from 'vitest';
import OrdersPage from '@/pages/orders/OrdersPage';
import type { GameOrder } from '@/schemas/gameOrder';
import { renderWithProviders } from './test-utils';
const queryMock = vi.hoisted(() => vi.fn());
const deliverMutateAsyncMock = vi.hoisted(() => vi.fn());
const refundMutateAsyncMock = vi.hoisted(() => vi.fn());
const refreshMock = vi.hoisted(() => vi.fn());
vi.mock('@/layouts/AppSidebar', () => ({
default: () => <aside data-testid="sidebar" />,
}));
vi.mock('@/api/queries/useGameOrdersQuery', () => ({
useGameOrdersQuery: (filters: unknown) => queryMock(filters),
useDeliverGameOrderMutation: () => ({
mutateAsync: deliverMutateAsyncMock,
isPending: false,
}),
useRefundGameOrderMutation: () => ({
mutateAsync: refundMutateAsyncMock,
isPending: false,
}),
}));
const paidOrder: GameOrder = {
id: 'GO20260827000001',
providerOrderId: 'WEB-860000000',
accountId: 'account-1',
accountName: 'player@example.com',
playerId: '50000001',
playerName: '长风01',
serverId: 'aurora-1',
serverName: '曙光一区',
productId: 'diamond-680',
productName: '680 钻石',
quantity: 2,
items: [
{ itemId: 'diamond', itemName: '钻石', quantity: 1360 },
{ itemId: 'vip-point', itemName: 'VIP 经验', quantity: 136 },
],
amount: 136,
currency: 'CNY',
channel: 'official',
status: 'paid',
createdAt: '2026-08-27T10:00:00.000Z',
paidAt: '2026-08-27T10:02:00.000Z',
deliveredAt: null,
refundedAt: null,
updatedAt: '2026-08-27T10:02:00.000Z',
clientIp: '198.51.100.40',
refundReason: null,
operatorNote: '渠道回调成功,等待补发',
};
const deliveredOrder: GameOrder = {
...paidOrder,
id: 'GO20260827000002',
providerOrderId: 'STM-860007919',
productId: 'steam-founder',
productName: '创始人礼包',
quantity: 1,
items: [
{ itemId: 'founder-badge', itemName: '创始人徽章', quantity: 1 },
{ itemId: 'diamond', itemName: '钻石', quantity: 1980 },
],
amount: 19.99,
currency: 'USD',
channel: 'steam',
status: 'delivered',
deliveredAt: '2026-08-27T10:05:00.000Z',
updatedAt: '2026-08-27T10:05:00.000Z',
operatorNote: null,
};
function queryResult(orders: GameOrder[] = [paidOrder, deliveredOrder]) {
return {
orders,
total: 98765,
summary: {
total: 98765,
paidAmount: 123456789.5,
pending: 23456,
awaitingDelivery: 3456,
refunding: 78,
},
serverOptions: [
{ value: 'aurora-1', label: '曙光一区' },
{ value: 'spark-2', label: '星火二区' },
],
loading: false,
fetched: true,
fetchError: '',
refresh: refreshMock,
};
}
beforeEach(() => {
queryMock.mockReset();
queryMock.mockReturnValue(queryResult());
deliverMutateAsyncMock.mockReset();
refundMutateAsyncMock.mockReset();
refreshMock.mockReset();
sessionStorage.clear();
});
function renderOrdersPage() {
return renderWithProviders(
<MemoryRouter initialEntries={['/orders']}>
<OrdersPage />
</MemoryRouter>,
);
}
test('renders order metrics, native scrolling table and ungrouped numbers', () => {
renderOrdersPage();
expect(screen.getByRole('heading', { name: '订单管理' })).not.toBeNull();
expect(screen.getByText('98765')).not.toBeNull();
expect(screen.queryByText('98,765')).toBeNull();
expect(screen.getByText('¥123456789.50')).not.toBeNull();
expect(screen.queryByText('¥123,456,789.50')).toBeNull();
expect(screen.getByText('钻石 1360 / VIP 经验 136')).not.toBeNull();
expect(screen.queryByText('钻石 1,360 / VIP 经验 136')).toBeNull();
expect(screen.getByText('20 / 页')).not.toBeNull();
expect(document.querySelector('.orders-table-card .ant-table-virtual')).toBeNull();
expect(document.querySelector('.orders-table-card .ant-table-body')).not.toBeNull();
const firstRow = document.querySelector('.orders-table-card .ant-table-tbody .ant-table-row');
const firstRowCells = firstRow?.querySelectorAll(':scope > .ant-table-cell') ?? [];
expect(firstRowCells).toHaveLength(9);
firstRowCells.forEach((cell) => {
expect(cell.classList.contains('orders-cell-start')).toBe(true);
});
});
test('keeps order controls in one right-aligned toolbar with refresh last', () => {
renderOrdersPage();
expect(screen.queryByText('订单列表')).toBeNull();
expect(screen.queryByText('共 98765 个订单')).toBeNull();
const actions = document.querySelector('.orders-filter-actions');
if (!actions) throw new Error('Order filter actions not found');
expect(actions.querySelector('[aria-label="搜索订单"]')).not.toBeNull();
expect(actions.querySelector('[aria-label="订单状态筛选"]')).not.toBeNull();
expect(actions.querySelector('[aria-label="支付渠道筛选"]')).not.toBeNull();
expect(actions.querySelector('[aria-label="订单区服筛选"]')).not.toBeNull();
expect(actions.lastElementChild?.getAttribute('aria-label')).toBe('刷新订单数据');
});
test('applies keyword, status, channel and server filters', async () => {
renderOrdersPage();
fireEvent.change(screen.getByLabelText('搜索订单'), { target: { value: 'GO20260827' } });
fireEvent.click(screen.getByLabelText('搜索订单数据'));
await waitFor(() => {
expect(queryMock).toHaveBeenLastCalledWith(
expect.objectContaining({ keyword: 'GO20260827', page: 1 }),
);
});
fireEvent.mouseDown(screen.getByLabelText('订单状态筛选'));
fireEvent.click(
await screen.findByText('已退款', { selector: '.ant-select-item-option-content' }),
);
await waitFor(() => {
expect(queryMock).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'refunded' }));
});
fireEvent.mouseDown(screen.getByLabelText('支付渠道筛选'));
fireEvent.click(
await screen.findByText('Steam', { selector: '.ant-select-item-option-content' }),
);
await waitFor(() => {
expect(queryMock).toHaveBeenLastCalledWith(expect.objectContaining({ channel: 'steam' }));
});
fireEvent.mouseDown(screen.getByLabelText('订单区服筛选'));
fireEvent.click(
await screen.findByText('曙光一区', { selector: '.ant-select-item-option-content' }),
);
await waitFor(() => {
expect(queryMock).toHaveBeenLastCalledWith(expect.objectContaining({ serverId: 'aurora-1' }));
});
});
test('opens order details and keeps large values ungrouped', async () => {
renderOrdersPage();
fireEvent.click(screen.getByLabelText(`查看订单 ${paidOrder.id}`));
expect(await screen.findByText('订单详情')).not.toBeNull();
expect(screen.getAllByText(paidOrder.id).length).toBeGreaterThan(0);
expect(screen.getAllByText('1360').length).toBeGreaterThan(0);
expect(screen.queryByText('1,360')).toBeNull();
});
test('delivers paid orders', async () => {
deliverMutateAsyncMock.mockResolvedValue({
...paidOrder,
status: 'delivered',
deliveredAt: '2026-08-27T10:06:00.000Z',
});
renderOrdersPage();
fireEvent.click(screen.getByLabelText(`补发订单 ${paidOrder.id}`));
const dialog = await screen.findByRole('dialog');
fireEvent.click(within(dialog).getByRole('button', { name: '确认补发' }));
await waitFor(() => {
expect(deliverMutateAsyncMock).toHaveBeenCalledWith({
orderId: paidOrder.id,
operatorNote: '运营后台手动补发',
});
});
});
test('submits a refund for paid or delivered orders', async () => {
refundMutateAsyncMock.mockResolvedValue({
...deliveredOrder,
status: 'refunding',
refundReason: '玩家重复购买',
});
renderOrdersPage();
fireEvent.click(screen.getByLabelText(`退款订单 ${deliveredOrder.id}`));
const dialog = await screen.findByRole('dialog');
fireEvent.change(within(dialog).getByLabelText('退款原因'), {
target: { value: '玩家重复购买' },
});
fireEvent.click(within(dialog).getByRole('button', { name: '提交退款' }));
await waitFor(() => {
expect(refundMutateAsyncMock).toHaveBeenCalledWith({
orderId: deliveredOrder.id,
reason: '玩家重复购买',
});
});
});
+94
View File
@@ -0,0 +1,94 @@
import { fireEvent, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { beforeEach, expect, test, vi } from 'vitest';
import OverviewPage from '@/pages/overview/OverviewPage';
import { renderWithProviders } from './test-utils';
const refreshMock = vi.hoisted(() => vi.fn());
vi.mock('@/layouts/AppSidebar', () => ({
default: () => <aside data-testid="sidebar" />,
}));
vi.mock('@/components/viz', () => ({
Sparkline: ({ name1 }: { name1?: string }) => <div data-testid="sparkline">{name1}</div>,
}));
vi.mock('@/api/queries/useGameOverviewQuery', () => ({
useGameOverviewQuery: () => ({
loading: false,
fetched: true,
fetchError: '',
refresh: refreshMock,
overview: {
generatedAt: '2026-08-27T12:00:00.000Z',
range: 'today',
serverOptions: [
{ id: 'all', name: '全部区服' },
{ id: 'aurora-1', name: '曙光一区' },
],
metrics: {
totalPlayers: { value: 1286420, change: 3.6 },
newPlayers: { value: 8642, change: 11.8 },
activePlayers: { value: 68421, change: 7.2 },
onlinePlayers: { value: 10213, change: 5.4 },
revenue: { value: 486320, change: 9.6 },
orders: { value: 12684, change: 8.1 },
},
trends: {
labels: ['00:00', '01:00', '02:00'],
onlinePlayers: [8200, 9100, 10213],
activePlayers: [21000, 24000, 26000],
revenue: [16000, 19000, 22000],
},
servers: [
{
id: 'aurora-1',
name: '曙光一区',
region: '华东',
status: 'online',
onlinePlayers: 4216,
capacity: 8000,
loadPercent: 53,
version: '1.8.12',
lastHeartbeat: '2026-08-27T11:59:56.000Z',
},
],
alerts: [
{
id: 'failed-orders',
level: 'error',
title: '异常订单待处理',
description: '支付成功但尚未完成发货',
count: 3,
},
],
},
}),
}));
beforeEach(() => {
refreshMock.mockReset();
sessionStorage.clear();
});
test('renders the game operations overview and refresh control', () => {
renderWithProviders(
<MemoryRouter>
<OverviewPage />
</MemoryRouter>,
);
expect(screen.getByRole('heading', { name: '总览' })).not.toBeNull();
expect(screen.getByText('总玩家')).not.toBeNull();
expect(screen.getAllByText('10213').length).toBeGreaterThan(0);
expect(screen.queryByText('10,213')).toBeNull();
expect(screen.getByText('曙光一区')).not.toBeNull();
expect(screen.getByText('运行中')).not.toBeNull();
expect(screen.getByText('异常订单待处理')).not.toBeNull();
expect(screen.getAllByTestId('sparkline')).toHaveLength(2);
fireEvent.click(screen.getByRole('button', { name: '刷新数据' }));
expect(refreshMock).toHaveBeenCalledTimes(1);
});
+114
View File
@@ -0,0 +1,114 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { setupHttp } from '@/api/http-init';
import {
fetchGamePlayers,
updateGamePlayerResources,
updateGamePlayerStatus,
} from '@/api/queries/useGamePlayersQuery';
import { handlers as gameMockHandlers } from '@/mocks/handlers';
import {
GamePlayerResourceInputSchema,
GamePlayerSchema,
GamePlayerStatusInputSchema,
} from '@/schemas/gamePlayer';
import { server } from './msw/server';
const ORIGIN = 'http://localhost';
describe('game player API contract', () => {
beforeEach(() => {
vi.stubGlobal('document', { querySelector: () => null });
window.ADMIN_UI_BASE_PATH = ORIGIN;
setupHttp();
server.use(...gameMockHandlers);
});
afterEach(() => {
vi.unstubAllGlobals();
delete window.ADMIN_UI_BASE_PATH;
});
it('lists, updates resources and changes status using the future Go contract', async () => {
const list = await fetchGamePlayers({
page: 1,
pageSize: 20,
keyword: '',
status: 'all',
serverId: 'all',
});
expect(list.items.length).toBeGreaterThan(0);
expect(list.summary.total).toBeGreaterThan(0);
expect(list.serverOptions.length).toBeGreaterThan(0);
const target = list.items[0];
const updated = await updateGamePlayerResources({
playerId: target.id,
level: 99,
vipLevel: 12,
combatPower: 9876543,
gold: 3200000000000,
diamonds: 98000000,
});
expect(updated.level).toBe(99);
expect(updated.gold).toBe(3200000000000);
const banned = await updateGamePlayerStatus({
playerId: target.id,
status: 'banned',
reason: '契约测试',
durationDays: 7,
});
expect(banned.status).toBe('banned');
expect(banned.banReason).toBe('契约测试');
const restored = await updateGamePlayerStatus({
playerId: target.id,
status: 'offline',
});
expect(restored.status).toBe('offline');
expect(restored.banReason).toBeNull();
});
it('rejects unknown fields in player response and mutation objects', () => {
const validPlayer = {
id: '50000001',
name: '长风01',
accountId: '10000001',
accountName: 'account_0001',
serverId: 'aurora-1',
serverName: '曙光一区',
level: 86,
vipLevel: 8,
combatPower: 7654321,
gold: 3200000000000,
diamonds: 98000000,
totalRecharge: 12800,
status: 'online',
createdAt: '2026-08-20T02:00:00.000Z',
lastLoginTime: '2026-08-27T11:00:00.000Z',
lastLoginIp: '198.51.100.30',
banReason: null,
banUntil: null,
};
expect(GamePlayerSchema.safeParse({ ...validPlayer, unexpected: true }).success).toBe(false);
expect(
GamePlayerResourceInputSchema.safeParse({
level: 86,
vipLevel: 8,
combatPower: 7654321,
gold: 3200000000000,
diamonds: 98000000,
unexpected: true,
}).success,
).toBe(false);
expect(
GamePlayerStatusInputSchema.safeParse({
status: 'banned',
reason: '测试',
unexpected: true,
}).success,
).toBe(false);
});
});

Some files were not shown because too many files have changed in this diff Show More