初始化游戏运营后台
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