初始化游戏运营后台
This commit is contained in:
@@ -0,0 +1 @@
|
||||
VITE_MOCK_API=true
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
*.log
|
||||
*.zip
|
||||
*.tsbuildinfo
|
||||
coverage/
|
||||
storybook-static/
|
||||
@@ -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__"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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` 将主内容区居中收窄。
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
Generated
+8508
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type FieldName = string | ReadonlyArray<string | number>;
|
||||
|
||||
export function toDotted(name: FieldName): string {
|
||||
return typeof name === 'string' ? name : name.join('.');
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Sparkline } from './Sparkline';
|
||||
@@ -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();
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface Window {
|
||||
ADMIN_UI_BASE_PATH?: string;
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"username": "اسم المستخدم",
|
||||
"password": "الباسورد",
|
||||
"twoFactorCode": "الكود",
|
||||
"login": "تسجيل الدخول",
|
||||
"menu": {
|
||||
"theme": "الثيم"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
"hello": "أهلا",
|
||||
"title": "أهلاً وسهلاً"
|
||||
},
|
||||
"settings": {
|
||||
"language": "اللغة"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"username": "نامکاربری",
|
||||
"password": "رمزعبور",
|
||||
"twoFactorCode": "کد",
|
||||
"login": "ورود",
|
||||
"menu": {
|
||||
"theme": "تم"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
"hello": "سلام",
|
||||
"title": "خوشآمدید"
|
||||
},
|
||||
"settings": {
|
||||
"language": "زبان"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"username": "ユーザー名",
|
||||
"password": "パスワード",
|
||||
"twoFactorCode": "コード",
|
||||
"login": "ログイン",
|
||||
"menu": {
|
||||
"theme": "テーマ"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
"hello": "こんにちは",
|
||||
"title": "ようこそ"
|
||||
},
|
||||
"settings": {
|
||||
"language": "言語"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"username": "Имя пользователя",
|
||||
"password": "Пароль",
|
||||
"twoFactorCode": "Код 2FA",
|
||||
"login": "Войти",
|
||||
"menu": {
|
||||
"theme": "Тема"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
"hello": "Привет!",
|
||||
"title": "Добро пожаловать!"
|
||||
},
|
||||
"settings": {
|
||||
"language": "Язык интерфейса"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"username": "Ім'я користувача",
|
||||
"password": "Пароль",
|
||||
"twoFactorCode": "Код",
|
||||
"login": "Увійти",
|
||||
"menu": {
|
||||
"theme": "Тема"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
"hello": "Привіт",
|
||||
"title": "Привітання!"
|
||||
},
|
||||
"settings": {
|
||||
"language": "Мова"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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ữ"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"twoFactorCode": "代码",
|
||||
"login": "登录",
|
||||
"menu": {
|
||||
"theme": "主题"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
"hello": "你好",
|
||||
"title": "欢迎"
|
||||
},
|
||||
"settings": {
|
||||
"language": "语言"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"username": "使用者名稱",
|
||||
"password": "密碼",
|
||||
"twoFactorCode": "代碼",
|
||||
"login": "登入",
|
||||
"menu": {
|
||||
"theme": "主題"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
"hello": "你好",
|
||||
"title": "歡迎"
|
||||
},
|
||||
"settings": {
|
||||
"language": "語言"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Outlet } from 'react-router';
|
||||
|
||||
import { usePageTitle } from '@/hooks/usePageTitle';
|
||||
|
||||
export default function PanelLayout() {
|
||||
usePageTitle();
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: '玩家重复购买',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user